Skip to content

Commit 7a1c931

Browse files
committed
feat: add DevWorkspace pruner
Signed-off-by: Oleksii Kurinnyi <[email protected]>
1 parent 1e29e0c commit 7a1c931

File tree

9 files changed

+384
-0
lines changed

9 files changed

+384
-0
lines changed

apis/controller/v1alpha1/devworkspaceoperatorconfig_types.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,29 @@ type OperatorConfiguration struct {
4949
EnableExperimentalFeatures *bool `json:"enableExperimentalFeatures,omitempty"`
5050
}
5151

52+
type CleanupCronJobConfig struct {
53+
// Enable determines whether the cleanup cron job is enabled.
54+
// Defaults to false if not specified.
55+
// +kubebuilder:validation:Optional
56+
Enable *bool `json:"enable,omitempty"`
57+
// RetainTime specifies the minimum time (in seconds) since a DevWorkspace was last started before it is considered stale and eligible for cleanup.
58+
// For example, a value of 2592000 (30 days) would mean that any DevWorkspace that has not been started in the last 30 days will be deleted.
59+
// Defaults to 2592000 seconds (30 days) if not specified.
60+
// +kubebuilder:validation:Minimum=0
61+
// +kubebuilder:default:=2592000
62+
// +kubebuilder:validation:Optional
63+
RetainTime *int32 `json:"retainTime,omitempty"`
64+
// DryRun determines whether the cleanup cron job should be run in dry-run mode.
65+
// If set to true, the cron job will not delete any DevWorkspaces, but will log the DevWorkspaces that would have been deleted.
66+
// Defaults to false if not specified.
67+
// +kubebuilder:validation:Optional
68+
DryRun *bool `json:"dryRun,omitempty"`
69+
// Schedule specifies the cron schedule for the cleanup cron job.
70+
// +kubebuilder:default:="0 0 1 * *"
71+
// +kubebuilder:validation:Optional
72+
Schedule string `json:"schedule,omitempty"`
73+
}
74+
5275
type RoutingConfig struct {
5376
// DefaultRoutingClass specifies the routingClass to be used when a DevWorkspace
5477
// specifies an empty `.spec.routingClass`. Supported routingClasses can be defined
@@ -161,6 +184,8 @@ type WorkspaceConfig struct {
161184
PodAnnotations map[string]string `json:"podAnnotations,omitempty"`
162185
// RuntimeClassName defines the spec.runtimeClassName for DevWorkspace pods created by the DevWorkspace Operator.
163186
RuntimeClassName *string `json:"runtimeClassName,omitempty"`
187+
// CleanupCronJobConfig defines configuration options for a cron job that automatically cleans up stale DevWorkspaces.
188+
CleanupCronJob *CleanupCronJobConfig `json:"cleanupCronJob,omitempty"`
164189
}
165190

166191
type WebhookConfig struct {
Lines changed: 290 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,290 @@
1+
//
2+
// Copyright (c) 2019-2024 Red Hat, Inc.
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
//
15+
16+
package controllers
17+
18+
import (
19+
"context"
20+
"fmt"
21+
"time"
22+
23+
"github.com/go-logr/logr"
24+
"k8s.io/apimachinery/pkg/runtime"
25+
"k8s.io/apimachinery/pkg/runtime/schema"
26+
ctrl "sigs.k8s.io/controller-runtime"
27+
"sigs.k8s.io/controller-runtime/pkg/client"
28+
"sigs.k8s.io/controller-runtime/pkg/controller"
29+
"sigs.k8s.io/controller-runtime/pkg/event"
30+
"sigs.k8s.io/controller-runtime/pkg/predicate"
31+
32+
dwv2 "github.com/devfile/api/v2/pkg/apis/workspaces/v1alpha2"
33+
controllerv1alpha1 "github.com/devfile/devworkspace-operator/apis/controller/v1alpha1"
34+
"github.com/devfile/devworkspace-operator/pkg/conditions"
35+
"github.com/devfile/devworkspace-operator/pkg/config"
36+
37+
"github.com/operator-framework/operator-lib/prune"
38+
"github.com/robfig/cron/v3"
39+
40+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
41+
)
42+
43+
// DevWorkspacePrunerReconciler reconciles DevWorkspace objects for pruning purposes.
44+
type DevWorkspacePrunerReconciler struct {
45+
client.Client
46+
Log logr.Logger
47+
Scheme *runtime.Scheme
48+
49+
cron *cron.Cron
50+
}
51+
52+
// SetupWithManager sets up the controller with the Manager.
53+
func (r *DevWorkspacePrunerReconciler) SetupWithManager(mgr ctrl.Manager) error {
54+
log := r.Log.WithName("setupWithManager")
55+
log.Info("Setting up DevWorkspacePrunerReconciler")
56+
57+
// Predicate to filter DevWorkspaceOperatorConfig events. Only reconcile on changes to the
58+
// cleanup cronjob parameters.
59+
configPredicate := predicate.Funcs{
60+
UpdateFunc: func(e event.UpdateEvent) bool {
61+
log.Info("DevWorkspaceOperatorConfig update event received")
62+
oldConfig, ok := e.ObjectOld.(*controllerv1alpha1.DevWorkspaceOperatorConfig)
63+
if !ok {
64+
return false
65+
}
66+
newConfig, ok := e.ObjectNew.(*controllerv1alpha1.DevWorkspaceOperatorConfig)
67+
if !ok {
68+
return false
69+
}
70+
71+
oldCleanup := oldConfig.Config.Workspace.CleanupCronJob
72+
newCleanup := newConfig.Config.Workspace.CleanupCronJob
73+
74+
// Reconcile if any of the cleanup configuration fields have changed.
75+
return oldCleanup.Enable != newCleanup.Enable ||
76+
oldCleanup.RetainTime != newCleanup.RetainTime ||
77+
oldCleanup.DryRun != newCleanup.DryRun ||
78+
oldCleanup.Schedule != newCleanup.Schedule
79+
},
80+
CreateFunc: func(e event.CreateEvent) bool { return true },
81+
DeleteFunc: func(e event.DeleteEvent) bool { return false },
82+
GenericFunc: func(e event.GenericEvent) bool { return false },
83+
}
84+
85+
maxConcurrentReconciles, err := config.GetMaxConcurrentReconciles()
86+
if err != nil {
87+
return err
88+
}
89+
90+
// Initialize cron scheduler
91+
r.cron = cron.New()
92+
93+
return ctrl.NewControllerManagedBy(mgr).
94+
WithOptions(controller.Options{MaxConcurrentReconciles: maxConcurrentReconciles}).
95+
For(&controllerv1alpha1.DevWorkspaceOperatorConfig{}).
96+
WithEventFilter(configPredicate).
97+
Complete(r)
98+
}
99+
100+
// +kubebuilder:rbac:groups=workspace.devfile.io,resources=devworkspaces,verbs=get;list;delete
101+
// +kubebuilder:rbac:groups=controller.devfile.io,resources=devworkspaceoperatorconfigs,verbs=get;list;watch
102+
103+
// Reconcile is the main reconciliation loop for the pruner controller.
104+
func (r *DevWorkspacePrunerReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
105+
log := r.Log
106+
log.Info("Reconciling DevWorkspacePruner", "DWOC", req.NamespacedName)
107+
108+
dwOperatorConfig := &controllerv1alpha1.DevWorkspaceOperatorConfig{}
109+
err := r.Get(ctx, req.NamespacedName, dwOperatorConfig)
110+
if err != nil {
111+
log.Error(err, "Failed to get DevWorkspaceOperatorConfig")
112+
return ctrl.Result{}, client.IgnoreNotFound(err)
113+
}
114+
115+
cleanupConfig := dwOperatorConfig.Config.Workspace.CleanupCronJob
116+
log = log.WithValues("CleanupCronJob", cleanupConfig)
117+
118+
if cleanupConfig == nil {
119+
log.Info("DevWorkspaceOperatorConfig does not have cleanup configuration, stopping cron schedler and skipping reconciliation")
120+
r.stopCron(log)
121+
return ctrl.Result{}, nil
122+
}
123+
if !*cleanupConfig.Enable {
124+
log.Info("DevWorkspace pruning is disabled, stopping cron scheduler and skipping reconciliation")
125+
r.stopCron(log)
126+
return ctrl.Result{}, nil
127+
}
128+
if cleanupConfig.Schedule == "" {
129+
log.Info("DevWorkspace pruning schedule is not defined, stopping cron scheduler and skipping reconciliation")
130+
r.stopCron(log)
131+
return ctrl.Result{}, nil
132+
}
133+
134+
r.startCron(ctx, cleanupConfig, log)
135+
136+
return ctrl.Result{}, nil
137+
}
138+
139+
func (r *DevWorkspacePrunerReconciler) startCron(ctx context.Context, cleanupConfig *controllerv1alpha1.CleanupCronJobConfig, logger logr.Logger) {
140+
log := logger.WithName("cron")
141+
log.Info("Starting cron scheduler")
142+
143+
// remove existing cronjob tasks
144+
// we cannot update the existing tasks, so we need to remove them and add new ones
145+
entries := r.cron.Entries()
146+
for _, entry := range entries {
147+
r.cron.Remove(entry.ID)
148+
}
149+
150+
// add cronjob task
151+
_, err := r.cron.AddFunc(cleanupConfig.Schedule, func() {
152+
taskLog := logger.WithName("cronTask")
153+
154+
// define pruning parameters
155+
retainTime := time.Duration(*cleanupConfig.RetainTime) * time.Second
156+
dryRun := *cleanupConfig.DryRun
157+
158+
taskLog.Info("Starting DevWorkspace pruning job")
159+
if err := r.pruneDevWorkspaces(ctx, retainTime, dryRun, logger); err != nil {
160+
taskLog.Error(err, "Failed to prune DevWorkspaces")
161+
}
162+
taskLog.Info("DevWorkspace pruning job finished")
163+
})
164+
if err != nil {
165+
log.Error(err, "Failed to add cronjob function")
166+
return
167+
}
168+
169+
r.cron.Start()
170+
}
171+
172+
func (r *DevWorkspacePrunerReconciler) stopCron(logger logr.Logger) {
173+
log := logger.WithName("cron")
174+
log.Info("Stopping cron scheduler")
175+
176+
r.cron.Stop()
177+
}
178+
179+
func (r *DevWorkspacePrunerReconciler) pruneDevWorkspaces(ctx context.Context, retainTime time.Duration, dryRun bool, logger logr.Logger) error {
180+
log := logger.WithName("pruner")
181+
182+
// create a prune strategy based on the configuration
183+
var pruneStrategy prune.StrategyFunc
184+
if dryRun {
185+
pruneStrategy = r.dryRunPruneStrategy(retainTime, log)
186+
} else {
187+
pruneStrategy = r.pruneStrategy(retainTime, log)
188+
}
189+
190+
gvk := schema.GroupVersionKind{
191+
Group: dwv2.SchemeGroupVersion.Group,
192+
Version: dwv2.SchemeGroupVersion.Version,
193+
Kind: "DevWorkspace",
194+
}
195+
196+
// create pruner that uses our custom strategy
197+
pruner, err := prune.NewPruner(r.Client, gvk, pruneStrategy)
198+
if err != nil {
199+
return fmt.Errorf("failed to create pruner: %w", err)
200+
}
201+
202+
deletedObjects, err := pruner.Prune(ctx)
203+
if err != nil {
204+
return fmt.Errorf("failed to prune objects: %w", err)
205+
}
206+
log.Info(fmt.Sprintf("Pruned %d DevWorkspaces", len(deletedObjects)))
207+
208+
for _, obj := range deletedObjects {
209+
devWorkspace, ok := obj.(*dwv2.DevWorkspace)
210+
if !ok {
211+
log.Error(err, fmt.Sprintf("failed to convert %v to DevWorkspace", obj))
212+
continue
213+
}
214+
log.Info(fmt.Sprintf("Pruned DevWorkspace '%s' in namespace '%s'", devWorkspace.Name, devWorkspace.Namespace))
215+
}
216+
217+
return nil
218+
}
219+
220+
// pruneStrategy returns a StrategyFunc that will return a list of
221+
// DevWorkspaces to prune based on the lastTransitionTime of the 'Started' condition.
222+
func (r *DevWorkspacePrunerReconciler) pruneStrategy(retainTime time.Duration, logger logr.Logger) prune.StrategyFunc {
223+
log := logger.WithName("pruneStrategy")
224+
225+
return func(ctx context.Context, objs []client.Object) ([]client.Object, error) {
226+
filteredObjs := filterByInactivityTime(objs, retainTime, log)
227+
log.Info(fmt.Sprintf("Found %d DevWorkspaces to prune", len(filteredObjs)))
228+
return filteredObjs, nil
229+
}
230+
}
231+
232+
// dryRunPruneStrategy returns a StrategyFunc that will always return an empty list of DevWorkspaces to prune.
233+
// This is used for dry-run mode.
234+
func (r *DevWorkspacePrunerReconciler) dryRunPruneStrategy(retainTime time.Duration, logger logr.Logger) prune.StrategyFunc {
235+
log := logger.WithName("dryRunPruneStrategy")
236+
237+
return func(ctx context.Context, objs []client.Object) ([]client.Object, error) {
238+
filteredObjs := filterByInactivityTime(objs, retainTime, log)
239+
log.Info(fmt.Sprintf("Found %d DevWorkspaces to prune", len(filteredObjs)))
240+
241+
// Return an empty list of DevWorkspaces because this is a dry-run
242+
return []client.Object{}, nil
243+
}
244+
}
245+
246+
// filterByInactivityTime filters DevWorkspaces based on the lastTransitionTime of the 'Started' condition.
247+
func filterByInactivityTime(objs []client.Object, retainTime time.Duration, log logr.Logger) []client.Object {
248+
var filteredObjs []client.Object
249+
for _, obj := range objs {
250+
devWorkspace, ok := obj.(*dwv2.DevWorkspace)
251+
if !ok {
252+
log.Error(nil, fmt.Sprintf("failed to convert %v to DevWorkspace", obj))
253+
continue
254+
}
255+
256+
if canPrune(*devWorkspace, retainTime, log) {
257+
filteredObjs = append(filteredObjs, devWorkspace)
258+
log.Info(fmt.Sprintf("Adding DevWorkspace '%s/%s' to prune list", devWorkspace.Namespace, devWorkspace.Name))
259+
} else {
260+
log.Info(fmt.Sprintf("Skipping DevWorkspace '%s/%s': not eligible for pruning", devWorkspace.Namespace, devWorkspace.Name))
261+
}
262+
}
263+
return filteredObjs
264+
}
265+
266+
// canPrune returns true if the DevWorkspace is eligible for pruning.
267+
func canPrune(dw dwv2.DevWorkspace, retainTime time.Duration, log logr.Logger) bool {
268+
// Skip started and running DevWorkspaces
269+
if dw.Spec.Started {
270+
log.Info(fmt.Sprintf("Skipping DevWorkspace '%s/%s': already started", dw.Namespace, dw.Name))
271+
return false
272+
}
273+
274+
var startTime *metav1.Time
275+
for _, condition := range dw.Status.Conditions {
276+
if condition.Type == conditions.Started {
277+
startTime = &condition.LastTransitionTime
278+
break
279+
}
280+
}
281+
if startTime == nil {
282+
log.Info(fmt.Sprintf("Skipping DevWorkspace '%s/%s': missing 'Started' condition", dw.Namespace, dw.Name))
283+
return false
284+
}
285+
if time.Since(startTime.Time) <= retainTime {
286+
log.Info(fmt.Sprintf("Skipping DevWorkspace '%s/%s': not eligible for pruning", dw.Namespace, dw.Name))
287+
return false
288+
}
289+
return true
290+
}

go.mod

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,9 @@ require (
1515
github.com/onsi/ginkgo/v2 v2.19.0
1616
github.com/onsi/gomega v1.34.1
1717
github.com/openshift/api v0.0.0-20200205133042-34f0ec8dab87
18+
github.com/operator-framework/operator-lib v0.11.0
1819
github.com/prometheus/client_golang v1.14.0
20+
github.com/robfig/cron/v3 v3.0.0
1921
github.com/stretchr/testify v1.10.0
2022
golang.org/x/crypto v0.31.0
2123
golang.org/x/net v0.33.0

go.sum

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -298,9 +298,13 @@ github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRW
298298
github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
299299
github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw=
300300
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
301+
github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
302+
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
301303
github.com/onsi/ginkgo v0.0.0-20170829012221-11459a886d9c/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
302304
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
303305
github.com/onsi/ginkgo v1.10.1/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
306+
github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE=
307+
github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU=
304308
github.com/onsi/ginkgo/v2 v2.19.0 h1:9Cnnf7UHo57Hy3k6/m5k3dRfGTMXGvxhHFvkDTCTpvA=
305309
github.com/onsi/ginkgo/v2 v2.19.0/go.mod h1:rlwLi9PilAFJ8jCg9UE1QP6VBpd6/xj3SRC0d6TU0To=
306310
github.com/onsi/gomega v0.0.0-20170829124025-dcabb60a477c/go.mod h1:C1qb7wdrVGGVU+Z6iS04AVkA3Q65CEZX59MT0QO5uiA=
@@ -309,6 +313,8 @@ github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k=
309313
github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY=
310314
github.com/openshift/api v0.0.0-20200205133042-34f0ec8dab87 h1:L/fZlWB7DdYCd09r9LvBa44xRH42Dx80ybxfN1h5C8Y=
311315
github.com/openshift/api v0.0.0-20200205133042-34f0ec8dab87/go.mod h1:fT6U/JfG8uZzemTRwZA2kBDJP5nWz7v05UHnty/D+pk=
316+
github.com/operator-framework/operator-lib v0.11.0 h1:eYzqpiOfq9WBI4Trddisiq/X9BwCisZd3rIzmHRC9Z8=
317+
github.com/operator-framework/operator-lib v0.11.0/go.mod h1:RpyKhFAoG6DmKTDIwMuO6pI3LRc8IE9rxEYWy476o6g=
312318
github.com/pjbgf/sha1cd v0.3.0 h1:4D5XXmUUBUl/xQ6IjCkEAbqXskkq/4O7LmGn0AqMDs4=
313319
github.com/pjbgf/sha1cd v0.3.0/go.mod h1:nZ1rrWOcGJ5uZgEEVL1VUM9iRQiZvWdbZjkKyFzPPsI=
314320
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
@@ -345,6 +351,8 @@ github.com/prometheus/procfs v0.7.3/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1
345351
github.com/prometheus/procfs v0.8.0 h1:ODq8ZFEaYeCaZOJlZZdJA2AbQR98dSHSM1KW/You5mo=
346352
github.com/prometheus/procfs v0.8.0/go.mod h1:z7EfXMXOkbkqb9IINtpCn86r/to3BnA0uaxHdg830/4=
347353
github.com/remyoudompheng/bigfft v0.0.0-20170806203942-52369c62f446/go.mod h1:uYEyJGbgTkfkS4+E/PavXkNJcbFIpEtjt2B0KDQ5+9M=
354+
github.com/robfig/cron/v3 v3.0.0 h1:kQ6Cb7aHOHTSzNVNEhmp8EcWKLb4CbiMW9h9VyIhO4E=
355+
github.com/robfig/cron/v3 v3.0.0/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
348356
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
349357
github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
350358
github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
@@ -716,6 +724,7 @@ gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
716724
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
717725
gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=
718726
gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
727+
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
719728
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
720729
gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME=
721730
gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=

0 commit comments

Comments
 (0)