• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

DoodleScheduling / growthbook-controller / 17493232892

05 Sep 2025 12:32PM UTC coverage: 42.63% (-0.2%) from 42.857%
17493232892

push

github

web-flow
chore(deps): update module github.com/spf13/pflag to v1.0.10 (#396)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>

752 of 1764 relevant lines covered (42.63%)

12.83 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

69.62
/internal/controllers/instance_controller.go
1
/*
2

3

4
Licensed under the Apache License, Version 2.0 (the "License");
5
you may not use this file except in compliance with the License.
6
You may obtain a copy of the License at
7

8
    http://www.apache.org/licenses/LICENSE-2.0
9

10
Unless required by applicable law or agreed to in writing, software
11
distributed under the License is distributed on an "AS IS" BASIS,
12
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
See the License for the specific language governing permissions and
14
limitations under the License.
15
*/
16

17
package controllers
18

19
import (
20
        "context"
21
        "errors"
22
        "fmt"
23
        "net/url"
24
        "strings"
25
        "time"
26

27
        "github.com/go-logr/logr"
28
        "go.mongodb.org/mongo-driver/mongo"
29
        "go.mongodb.org/mongo-driver/mongo/options"
30
        "golang.org/x/exp/slices"
31
        corev1 "k8s.io/api/core/v1"
32
        apierrors "k8s.io/apimachinery/pkg/api/errors"
33
        metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
34
        "k8s.io/apimachinery/pkg/runtime"
35
        "k8s.io/apimachinery/pkg/types"
36
        "k8s.io/client-go/tools/record"
37
        ctrl "sigs.k8s.io/controller-runtime"
38
        "sigs.k8s.io/controller-runtime/pkg/builder"
39
        "sigs.k8s.io/controller-runtime/pkg/client"
40
        "sigs.k8s.io/controller-runtime/pkg/controller"
41
        "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
42
        "sigs.k8s.io/controller-runtime/pkg/handler"
43
        "sigs.k8s.io/controller-runtime/pkg/predicate"
44
        "sigs.k8s.io/controller-runtime/pkg/reconcile"
45

46
        v1beta1 "github.com/DoodleScheduling/growthbook-controller/api/v1beta1"
47
        "github.com/DoodleScheduling/growthbook-controller/internal/growthbook"
48
        "github.com/DoodleScheduling/growthbook-controller/internal/storage"
49
        "github.com/DoodleScheduling/growthbook-controller/internal/storage/mongodb"
50
)
51

52
// +kubebuilder:rbac:groups=growthbook.infra.doodle.com,resources=growthbookinstances,verbs=get;list;watch;create;update;patch;delete
53
// +kubebuilder:rbac:groups=growthbook.infra.doodle.com,resources=growthbookinstances/status,verbs=get;update;patch
54
// +kubebuilder:rbac:groups=growthbook.infra.doodle.com,resources=growthbookorganizations,verbs=get;list;watch;create;update;patch;delete
55
// +kubebuilder:rbac:groups=growthbook.infra.doodle.com,resources=growthbookusers,verbs=get;list;watch;create;update;patch;delete
56
// +kubebuilder:rbac:groups=growthbook.infra.doodle.com,resources=growthbookfeatures,verbs=get;list;watch;create;update;patch;delete
57
// +kubebuilder:rbac:groups=growthbook.infra.doodle.com,resources=growthbookclients,verbs=get;list;watch;create;update;patch;delete
58
// +kubebuilder:rbac:groups="",resources=secrets,verbs=get;list;watch
59
// +kubebuilder:rbac:groups="",resources=events,verbs=create;patch
60

61
const (
62
        secretIndexKey = ".metadata.secret"
63
        usersIndexKey  = ".metadata.users"
64
        orgsIndexKey   = ".metadata.orgs"
65
        owner          = "growthbook-controller"
66
)
67

68
// MongoDBProvider returns a storage.Database for MongoDB
69
func MongoDBProvider(ctx context.Context, instance v1beta1.GrowthbookInstance, username, password string) (storage.Disconnector, storage.Database, error) {
10✔
70
        opts := options.Client().ApplyURI(instance.Spec.MongoDB.URI)
10✔
71
        if username != "" || password != "" {
10✔
72
                opts.SetAuth(options.Credential{
×
73
                        Username: username,
×
74
                        Password: password,
×
75
                })
×
76
        }
×
77

78
        opts.SetAppName("growthbook-controller")
10✔
79
        mongoClient, err := mongo.Connect(ctx, opts)
10✔
80
        if err != nil {
20✔
81
                return nil, nil, fmt.Errorf("failed connecting to mongodb: %w", err)
10✔
82
        }
10✔
83

84
        u, err := url.Parse(instance.Spec.MongoDB.URI)
×
85
        if err != nil {
×
86
                return nil, nil, err
×
87
        }
×
88

89
        dbName := strings.TrimLeft(u.Path, "/")
×
90
        db := mongoClient.Database(dbName)
×
91

×
92
        return mongoClient, mongodb.New(db), nil
×
93
}
94

95
// GrowthbookInstance reconciles a GrowthbookInstance object
96
type GrowthbookInstanceReconciler struct {
97
        client.Client
98
        Log              logr.Logger
99
        Scheme           *runtime.Scheme
100
        Recorder         record.EventRecorder
101
        DatabaseProvider func(ctx context.Context, instance v1beta1.GrowthbookInstance, username, password string) (storage.Disconnector, storage.Database, error)
102
}
103

104
type GrowthbookInstanceReconcilerOptions struct {
105
        MaxConcurrentReconciles int
106
}
107

108
// SetupWithManager adding controllers
109
func (r *GrowthbookInstanceReconciler) SetupWithManager(mgr ctrl.Manager, opts GrowthbookInstanceReconcilerOptions) error {
1✔
110
        // Index the GrowthbookInstance by the Secret references they point at
1✔
111
        if err := mgr.GetFieldIndexer().IndexField(context.TODO(), &v1beta1.GrowthbookInstance{}, secretIndexKey,
1✔
112
                func(o client.Object) []string {
152✔
113
                        // The referenced admin secret gets indexed
151✔
114
                        instance := o.(*v1beta1.GrowthbookInstance)
151✔
115
                        keys := []string{}
151✔
116

151✔
117
                        if instance.Spec.MongoDB.Secret != nil {
151✔
118
                                keys = []string{
×
119
                                        fmt.Sprintf("%s/%s", instance.GetNamespace(), instance.Spec.MongoDB.Secret.Name),
×
120
                                }
×
121
                        }
×
122

123
                        var users v1beta1.GrowthbookUserList
151✔
124
                        selector, err := metav1.LabelSelectorAsSelector(instance.Spec.ResourceSelector)
151✔
125
                        if err != nil {
151✔
126
                                return keys
×
127
                        }
×
128

129
                        err = r.List(context.TODO(), &users, client.InNamespace(instance.Namespace), client.MatchingLabelsSelector{Selector: selector})
151✔
130
                        if err != nil {
151✔
131
                                return keys
×
132
                        }
×
133

134
                        for _, user := range users.Items {
217✔
135
                                if user.Spec.Secret == nil {
66✔
136
                                        continue
×
137
                                }
138

139
                                keys = append(keys, fmt.Sprintf("%s/%s", instance.GetNamespace(), user.Spec.Secret.Name))
66✔
140
                        }
141

142
                        var clients v1beta1.GrowthbookClientList
151✔
143
                        selector, err = metav1.LabelSelectorAsSelector(instance.Spec.ResourceSelector)
151✔
144
                        if err != nil {
151✔
145
                                return keys
×
146
                        }
×
147

148
                        err = r.List(context.TODO(), &clients, client.InNamespace(instance.Namespace), client.MatchingLabelsSelector{Selector: selector})
151✔
149
                        if err != nil {
151✔
150
                                return keys
×
151
                        }
×
152

153
                        for _, client := range clients.Items {
231✔
154
                                if client.Spec.TokenSecret == nil {
80✔
155
                                        continue
×
156
                                }
157

158
                                keys = append(keys, fmt.Sprintf("%s/%s", instance.GetNamespace(), client.Spec.TokenSecret.Name))
80✔
159
                        }
160

161
                        return keys
151✔
162
                },
163
        ); err != nil {
×
164
                return err
×
165
        }
×
166

167
        return ctrl.NewControllerManagedBy(mgr).
1✔
168
                For(&v1beta1.GrowthbookInstance{}, builder.WithPredicates(
1✔
169
                        predicate.GenerationChangedPredicate{},
1✔
170
                )).
1✔
171
                Watches(
1✔
172
                        &corev1.Secret{},
1✔
173
                        handler.EnqueueRequestsFromMapFunc(r.requestsForChangeByField(secretIndexKey)),
1✔
174
                ).
1✔
175
                Watches(
1✔
176
                        &v1beta1.GrowthbookUser{},
1✔
177
                        handler.EnqueueRequestsFromMapFunc(r.requestsForChangeBySelector),
1✔
178
                ).
1✔
179
                Watches(
1✔
180
                        &v1beta1.GrowthbookOrganization{},
1✔
181
                        handler.EnqueueRequestsFromMapFunc(r.requestsForChangeBySelector),
1✔
182
                ).
1✔
183
                Watches(
1✔
184
                        &v1beta1.GrowthbookClient{},
1✔
185
                        handler.EnqueueRequestsFromMapFunc(r.requestsForChangeBySelector),
1✔
186
                ).
1✔
187
                Watches(
1✔
188
                        &v1beta1.GrowthbookFeature{},
1✔
189
                        handler.EnqueueRequestsFromMapFunc(r.requestsForChangeBySelector),
1✔
190
                ).
1✔
191
                WithOptions(controller.Options{MaxConcurrentReconciles: opts.MaxConcurrentReconciles}).
1✔
192
                Complete(r)
1✔
193
}
194

195
func (r *GrowthbookInstanceReconciler) requestsForChangeByField(field string) handler.MapFunc {
1✔
196
        return func(ctx context.Context, o client.Object) []reconcile.Request {
3✔
197
                var list v1beta1.GrowthbookInstanceList
2✔
198
                if err := r.List(ctx, &list, client.MatchingFields{
2✔
199
                        field: objectKey(o).String(),
2✔
200
                }); err != nil {
2✔
201
                        return nil
×
202
                }
×
203

204
                var reqs []reconcile.Request
2✔
205
                for _, instance := range list.Items {
4✔
206
                        r.Log.Info("change of referenced resource detected", "namespace", instance.GetNamespace(), "instance-name", instance.GetName(), "resource-kind", o.GetObjectKind().GroupVersionKind().Kind, "resource-name", o.GetName())
2✔
207
                        reqs = append(reqs, reconcile.Request{NamespacedName: objectKey(&instance)})
2✔
208
                }
2✔
209

210
                return reqs
2✔
211
        }
212
}
213

214
func (r *GrowthbookInstanceReconciler) requestsForChangeBySelector(ctx context.Context, o client.Object) []reconcile.Request {
54✔
215
        var list v1beta1.GrowthbookInstanceList
54✔
216
        if err := r.List(ctx, &list, client.InNamespace(o.GetNamespace())); err != nil {
54✔
217
                return nil
×
218
        }
×
219

220
        var reqs []reconcile.Request
54✔
221
        for _, instance := range list.Items {
282✔
222
                if matches(o.GetLabels(), instance.Spec.ResourceSelector) {
388✔
223
                        r.Log.Info("change of referenced resource detected", "namespace", o.GetNamespace(), "name", o.GetName(), "kind", o.GetObjectKind().GroupVersionKind().Kind, "instance-name", instance.GetName())
160✔
224
                        reqs = append(reqs, reconcile.Request{NamespacedName: objectKey(&instance)})
160✔
225
                }
160✔
226
        }
227

228
        return reqs
54✔
229
}
230

231
// Reconcile GrowthbookInstances
232
func (r *GrowthbookInstanceReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
83✔
233
        logger := r.Log.WithValues("Namespace", req.Namespace, "Name", req.NamespacedName, "req", req)
83✔
234
        logger.Info("reconciling GrowthbookInstance")
83✔
235

83✔
236
        // Fetch the GrowthbookInstance instance
83✔
237
        instance := v1beta1.GrowthbookInstance{}
83✔
238

83✔
239
        err := r.Get(ctx, req.NamespacedName, &instance)
83✔
240
        if err != nil {
85✔
241
                if apierrors.IsNotFound(err) {
4✔
242
                        // Request object not found, could have been deleted after reconcile request.
2✔
243
                        // Owned objects are automatically garbage collected. For additional cleanup logic use finalizers.
2✔
244
                        // Return and don't requeue
2✔
245
                        return reconcile.Result{}, nil
2✔
246
                }
2✔
247
                // Error reading the object - requeue the request.
248
                return reconcile.Result{}, err
×
249
        }
250

251
        if instance.Spec.Suspend {
90✔
252
                return ctrl.Result{}, nil
9✔
253
        }
9✔
254

255
        // examine DeletionTimestamp to determine if object is under deletion
256
        if err := r.addFinalizer(ctx, v1beta1.Finalizer, metav1.PartialObjectMetadata{TypeMeta: instance.TypeMeta, ObjectMeta: instance.ObjectMeta}); err != nil {
75✔
257
                return ctrl.Result{}, err
3✔
258
        }
3✔
259

260
        start := time.Now()
69✔
261

69✔
262
        reconcileContext := ctx
69✔
263
        if instance.Spec.Timeout != nil {
138✔
264
                c, cancel := context.WithTimeout(ctx, instance.Spec.Timeout.Duration)
69✔
265
                defer cancel()
69✔
266
                reconcileContext = c
69✔
267
        }
69✔
268

269
        instance, err = r.reconcile(reconcileContext, instance, logger)
69✔
270
        res := ctrl.Result{}
69✔
271

69✔
272
        done := time.Now()
69✔
273

69✔
274
        instance.Status.LastReconcileDuration = metav1.Duration{
69✔
275
                Duration: done.Sub(start),
69✔
276
        }
69✔
277

69✔
278
        instance.Status.ObservedGeneration = instance.GetGeneration()
69✔
279

69✔
280
        if err != nil {
117✔
281
                r.Recorder.Event(&instance, "Normal", "error", err.Error())
48✔
282
                res = ctrl.Result{Requeue: true}
48✔
283
                instance = v1beta1.GrowthbookInstanceNotReady(instance, v1beta1.FailedReason, err.Error())
48✔
284
        } else {
69✔
285
                if !instance.DeletionTimestamp.IsZero() {
23✔
286
                        if err := r.removeFinalizer(ctx, v1beta1.Finalizer, metav1.PartialObjectMetadata{TypeMeta: instance.TypeMeta, ObjectMeta: instance.ObjectMeta}); err != nil {
2✔
287
                                return res, err
×
288
                        } else {
2✔
289
                                return ctrl.Result{}, nil
2✔
290
                        }
2✔
291
                }
292

293
                if instance.Spec.Interval != nil {
19✔
294
                        res = ctrl.Result{
×
295
                                RequeueAfter: instance.Spec.Interval.Duration,
×
296
                        }
×
297
                }
×
298

299
                msg := "instance successfully reconciled"
19✔
300
                r.Recorder.Event(&instance, "Normal", "info", msg)
19✔
301
                instance = v1beta1.GrowthbookInstanceReady(instance, v1beta1.SynchronizedReason, msg)
19✔
302
        }
303

304
        // Update status after reconciliation.
305
        if err := r.patchStatus(ctx, &instance); err != nil {
75✔
306
                logger.Error(err, "unable to update status after reconciliation")
8✔
307
                return ctrl.Result{Requeue: true}, err
8✔
308
        }
8✔
309

310
        return res, err
59✔
311
}
312

313
func (r *GrowthbookInstanceReconciler) reconcile(ctx context.Context, instance v1beta1.GrowthbookInstance, logger logr.Logger) (v1beta1.GrowthbookInstance, error) {
69✔
314
        //TODO there is a test race condition with this one, leaving for now
69✔
315
        /*msg := "reconcile instance progressing"
69✔
316
        r.Recorder.Event(&instance, "Normal", "info", msg)
69✔
317
        instance = v1beta1.GrowthbookInstanceNotReady(instance, v1beta1.ProgressingReason, msg)
69✔
318
        if err := r.patchStatus(ctx, &instance); err != nil {
69✔
319
                return instance, err
69✔
320
        }*/
69✔
321

69✔
322
        var err error
69✔
323
        var usr, pw string
69✔
324
        if instance.Spec.MongoDB.Secret != nil {
69✔
325
                usr, pw, err = r.getUsernamePassword(ctx, instance, instance.Spec.MongoDB.Secret)
×
326
                if err != nil {
×
327
                        return instance, err
×
328
                }
×
329
        }
330

331
        disconnector, db, err := r.DatabaseProvider(ctx, instance, usr, pw)
69✔
332
        if err != nil {
91✔
333
                return instance, err
22✔
334
        }
22✔
335

336
        defer func() {
94✔
337
                ctx, cancel := context.WithTimeout(context.TODO(), time.Second*10)
47✔
338
                defer cancel()
47✔
339
                if err := disconnector.Disconnect(ctx); err != nil {
47✔
340
                        logger.Error(err, "failed disconnecting mongodb")
×
341
                }
×
342
        }()
343

344
        instance.Status.SubResourceCatalog = []v1beta1.ResourceReference{}
47✔
345

47✔
346
        instance, err = r.reconcileUsers(ctx, instance, db)
47✔
347
        if err != nil {
67✔
348
                return instance, fmt.Errorf("failed reconciling users: %w", err)
20✔
349
        }
20✔
350

351
        instance, orgs, err := r.reconcileOrganizations(ctx, instance, db)
27✔
352
        if err != nil {
27✔
353
                return instance, fmt.Errorf("failed reconciling organizations: %w", err)
×
354
        }
×
355

356
        for _, org := range orgs {
59✔
357
                instance, err = r.reconcileFeatures(ctx, instance, org, db)
32✔
358
                if err != nil {
32✔
359
                        return instance, fmt.Errorf("failed reconciling features: %w", err)
×
360
                }
×
361

362
                instance, err = r.reconcileClients(ctx, instance, org, db)
32✔
363
                if err != nil {
38✔
364
                        return instance, fmt.Errorf("failed reconciling clients: %w", err)
6✔
365
                }
6✔
366
        }
367

368
        return instance, err
21✔
369
}
370

371
func (r *GrowthbookInstanceReconciler) reconcileOrganizations(ctx context.Context, instance v1beta1.GrowthbookInstance, db storage.Database) (v1beta1.GrowthbookInstance, []v1beta1.GrowthbookOrganization, error) {
27✔
372
        var orgs v1beta1.GrowthbookOrganizationList
27✔
373
        finalizerName := fmt.Sprintf("%s/%s.%s", v1beta1.Finalizer, instance.Name, instance.Namespace)
27✔
374

27✔
375
        selector, err := metav1.LabelSelectorAsSelector(instance.Spec.ResourceSelector)
27✔
376
        if err != nil {
27✔
377
                return instance, nil, err
×
378
        }
×
379

380
        err = r.List(ctx, &orgs, client.InNamespace(instance.Namespace), client.MatchingLabelsSelector{Selector: selector})
27✔
381
        if err != nil {
27✔
382
                return instance, nil, err
×
383
        }
×
384

385
        if instance.DeletionTimestamp.IsZero() {
52✔
386
                for _, org := range orgs.Items {
55✔
387
                        if err := r.addFinalizer(ctx, finalizerName, metav1.PartialObjectMetadata{TypeMeta: org.TypeMeta, ObjectMeta: org.ObjectMeta}); err != nil {
30✔
388
                                return instance, nil, err
×
389
                        }
×
390

391
                        if org.DeletionTimestamp.IsZero() {
58✔
392
                                instance = updateResourceCatalog(instance, &org)
28✔
393
                        }
28✔
394
                }
395
        }
396

397
        for _, org := range orgs.Items {
59✔
398
                o := growthbook.Organization{}
32✔
399
                o.FromV1beta1(org)
32✔
400

32✔
401
                for _, binding := range org.Spec.Users {
32✔
402
                        var users v1beta1.GrowthbookUserList
×
403
                        selector, err := metav1.LabelSelectorAsSelector(binding.Selector)
×
404
                        if err != nil {
×
405
                                return instance, nil, err
×
406
                        }
×
407

408
                        err = r.List(ctx, &users, client.InNamespace(instance.Namespace), client.MatchingLabelsSelector{Selector: selector})
×
409
                        if err != nil {
×
410
                                return instance, nil, err
×
411
                        }
×
412

413
                        for _, user := range users.Items {
×
414
                                o.Members = append(o.Members, growthbook.OrganizationMember{
×
415
                                        ID:   user.GetID(),
×
416
                                        Role: binding.Role,
×
417
                                })
×
418
                        }
×
419
                }
420

421
                if org.DeletionTimestamp.IsZero() && instance.DeletionTimestamp.IsZero() {
60✔
422
                        if err := growthbook.UpdateOrganization(ctx, o, db); err != nil {
28✔
423
                                return instance, nil, err
×
424
                        }
×
425
                } else {
4✔
426
                        if instance.Spec.Prune {
6✔
427
                                if err := growthbook.DeleteOrganization(ctx, o, db); err != nil {
2✔
428
                                        return instance, nil, err
×
429
                                }
×
430
                        }
431

432
                        if err := r.removeFinalizer(ctx, finalizerName, metav1.PartialObjectMetadata{TypeMeta: org.TypeMeta, ObjectMeta: org.ObjectMeta}); err != nil {
4✔
433
                                return instance, nil, err
×
434
                        }
×
435
                }
436
        }
437

438
        return instance, orgs.Items, nil
27✔
439
}
440

441
func (r *GrowthbookInstanceReconciler) reconcileFeatures(ctx context.Context, instance v1beta1.GrowthbookInstance, org v1beta1.GrowthbookOrganization, db storage.Database) (v1beta1.GrowthbookInstance, error) {
32✔
442
        var features v1beta1.GrowthbookFeatureList
32✔
443
        selector, err := metav1.LabelSelectorAsSelector(org.Spec.ResourceSelector)
32✔
444
        if err != nil {
32✔
445
                return instance, err
×
446
        }
×
447

448
        finalizerName := fmt.Sprintf("%s/%s.%s", v1beta1.Finalizer, instance.Name, instance.Namespace)
32✔
449
        instanceSelector, err := metav1.LabelSelectorAsSelector(instance.Spec.ResourceSelector)
32✔
450
        if err != nil {
32✔
451
                return instance, err
×
452
        }
×
453

454
        req, _ := instanceSelector.Requirements()
32✔
455
        selector.Add(req...)
32✔
456

32✔
457
        err = r.List(ctx, &features, client.InNamespace(instance.Namespace), client.MatchingLabelsSelector{Selector: selector})
32✔
458
        if err != nil {
32✔
459
                return instance, err
×
460
        }
×
461

462
        if instance.DeletionTimestamp.IsZero() {
62✔
463
                for _, feature := range features.Items {
59✔
464
                        if err := r.addFinalizer(ctx, finalizerName, metav1.PartialObjectMetadata{TypeMeta: feature.TypeMeta, ObjectMeta: feature.ObjectMeta}); err != nil {
29✔
465
                                return instance, err
×
466
                        }
×
467

468
                        if feature.DeletionTimestamp.IsZero() {
58✔
469
                                instance = updateResourceCatalog(instance, &feature)
29✔
470
                        }
29✔
471
                }
472
        }
473

474
        for _, feature := range features.Items {
61✔
475
                f := growthbook.Feature{
29✔
476
                        Owner:        owner,
29✔
477
                        Organization: org.GetID(),
29✔
478
                }
29✔
479

29✔
480
                f.FromV1beta1(feature)
29✔
481

29✔
482
                if feature.DeletionTimestamp.IsZero() && instance.DeletionTimestamp.IsZero() {
58✔
483
                        if err := growthbook.UpdateFeature(ctx, f, db); err != nil {
29✔
484
                                return instance, err
×
485
                        }
×
486
                } else {
×
487
                        if instance.Spec.Prune {
×
488
                                if err := growthbook.DeleteFeature(ctx, f, db); err != nil {
×
489
                                        return instance, err
×
490
                                }
×
491
                        }
492

493
                        if err := r.removeFinalizer(ctx, finalizerName, metav1.PartialObjectMetadata{TypeMeta: feature.TypeMeta, ObjectMeta: feature.ObjectMeta}); err != nil {
×
494
                                return instance, err
×
495
                        }
×
496
                }
497
        }
498

499
        return instance, nil
32✔
500
}
501

502
func (r *GrowthbookInstanceReconciler) addFinalizer(ctx context.Context, finalizerName string, obj metav1.PartialObjectMetadata) error {
186✔
503
        if !obj.GetDeletionTimestamp().IsZero() {
190✔
504
                return nil
4✔
505
        }
4✔
506

507
        controllerutil.AddFinalizer(&obj, finalizerName)
182✔
508
        if err := r.patch(ctx, &obj); err != nil {
185✔
509
                return err
3✔
510
        }
3✔
511

512
        return nil
179✔
513
}
514

515
func (r *GrowthbookInstanceReconciler) removeFinalizer(ctx context.Context, finalizerName string, obj metav1.PartialObjectMetadata) error {
6✔
516
        controllerutil.RemoveFinalizer(&obj, finalizerName)
6✔
517
        if err := r.patch(ctx, &obj); err != nil {
6✔
518
                return err
×
519
        }
×
520

521
        return nil
6✔
522
}
523

524
func (r *GrowthbookInstanceReconciler) reconcileUsers(ctx context.Context, instance v1beta1.GrowthbookInstance, db storage.Database) (v1beta1.GrowthbookInstance, error) {
47✔
525
        var users v1beta1.GrowthbookUserList
47✔
526
        finalizerName := fmt.Sprintf("%s/%s.%s", v1beta1.Finalizer, instance.Name, instance.Namespace)
47✔
527

47✔
528
        selector, err := metav1.LabelSelectorAsSelector(instance.Spec.ResourceSelector)
47✔
529
        if err != nil {
47✔
530
                return instance, err
×
531
        }
×
532

533
        err = r.List(ctx, &users, client.InNamespace(instance.Namespace), client.MatchingLabelsSelector{Selector: selector})
47✔
534
        if err != nil {
47✔
535
                return instance, err
×
536
        }
×
537

538
        if instance.DeletionTimestamp.IsZero() {
92✔
539
                for _, user := range users.Items {
80✔
540
                        if err := r.addFinalizer(ctx, finalizerName, metav1.PartialObjectMetadata{TypeMeta: user.TypeMeta, ObjectMeta: user.ObjectMeta}); err != nil {
35✔
541
                                return instance, err
×
542
                        }
×
543

544
                        if user.DeletionTimestamp.IsZero() {
70✔
545
                                instance = updateResourceCatalog(instance, &user)
35✔
546
                        }
35✔
547
                }
548
        }
549

550
        for _, user := range users.Items {
78✔
551
                u := growthbook.User{}
31✔
552
                u.FromV1beta1(user)
31✔
553

31✔
554
                if user.DeletionTimestamp.IsZero() && instance.DeletionTimestamp.IsZero() {
62✔
555
                        username, password, err := r.getOptionalUsernamePassword(ctx, instance, user.Spec.Secret)
31✔
556
                        if err != nil {
51✔
557
                                return instance, err
20✔
558
                        }
20✔
559

560
                        if username != "" {
11✔
561
                                u.Name = username
×
562
                        }
×
563

564
                        if err := u.SetPassword(ctx, db, password); err != nil {
11✔
565
                                return instance, err
×
566
                        }
×
567

568
                        if err := growthbook.UpdateUser(ctx, u, db); err != nil {
11✔
569
                                return instance, err
×
570
                        }
×
571
                } else {
×
572
                        if instance.Spec.Prune {
×
573
                                if err := growthbook.DeleteUser(ctx, u, db); err != nil {
×
574
                                        return instance, err
×
575
                                }
×
576
                        }
577

578
                        if err := r.removeFinalizer(ctx, finalizerName, metav1.PartialObjectMetadata{TypeMeta: user.TypeMeta, ObjectMeta: user.ObjectMeta}); err != nil {
×
579
                                return instance, err
×
580
                        }
×
581
                }
582
        }
583

584
        return instance, nil
27✔
585
}
586

587
func (r *GrowthbookInstanceReconciler) reconcileClients(ctx context.Context, instance v1beta1.GrowthbookInstance, org v1beta1.GrowthbookOrganization, db storage.Database) (v1beta1.GrowthbookInstance, error) {
32✔
588
        var clients v1beta1.GrowthbookClientList
32✔
589
        finalizerName := fmt.Sprintf("%s/%s.%s", v1beta1.Finalizer, instance.Name, instance.Namespace)
32✔
590

32✔
591
        selector, err := metav1.LabelSelectorAsSelector(org.Spec.ResourceSelector)
32✔
592
        if err != nil {
32✔
593
                return instance, err
×
594
        }
×
595

596
        instanceSelector, err := metav1.LabelSelectorAsSelector(instance.Spec.ResourceSelector)
32✔
597
        if err != nil {
32✔
598
                return instance, err
×
599
        }
×
600

601
        req, _ := instanceSelector.Requirements()
32✔
602
        selector.Add(req...)
32✔
603

32✔
604
        err = r.List(ctx, &clients, client.InNamespace(instance.Namespace), client.MatchingLabelsSelector{Selector: selector})
32✔
605
        if err != nil {
32✔
606
                return instance, err
×
607
        }
×
608

609
        if instance.DeletionTimestamp.IsZero() {
62✔
610
                for _, client := range clients.Items {
50✔
611
                        if err := r.addFinalizer(ctx, finalizerName, metav1.PartialObjectMetadata{TypeMeta: client.TypeMeta, ObjectMeta: client.ObjectMeta}); err != nil {
20✔
612
                                return instance, err
×
613
                        }
×
614

615
                        if client.DeletionTimestamp.IsZero() {
40✔
616
                                instance = updateResourceCatalog(instance, &client)
20✔
617
                        }
20✔
618
                }
619
        }
620

621
        for _, client := range clients.Items {
52✔
622
                s := growthbook.SDKConnection{
20✔
623
                        Organization: org.GetID(),
20✔
624
                }
20✔
625

20✔
626
                s.FromV1beta1(client)
20✔
627

20✔
628
                if client.DeletionTimestamp.IsZero() && instance.DeletionTimestamp.IsZero() {
40✔
629
                        token, err := r.getClientToken(ctx, client)
20✔
630
                        if err != nil {
26✔
631
                                return instance, err
6✔
632
                        }
6✔
633

634
                        if token[:4] != "sdk-" {
28✔
635
                                token = fmt.Sprintf("sdk-%s", token)
14✔
636
                        }
14✔
637

638
                        s.Key = token
14✔
639

14✔
640
                        if err := growthbook.UpdateSDKConnection(ctx, s, db); err != nil {
14✔
641
                                return instance, err
×
642
                        }
×
643
                } else {
×
644
                        if instance.Spec.Prune {
×
645
                                if err := growthbook.DeleteSDKConnection(ctx, s, db); err != nil {
×
646
                                        return instance, err
×
647
                                }
×
648
                        }
649

650
                        if err := r.removeFinalizer(ctx, finalizerName, metav1.PartialObjectMetadata{TypeMeta: client.TypeMeta, ObjectMeta: client.ObjectMeta}); err != nil {
×
651
                                return instance, err
×
652
                        }
×
653
                }
654

655
        }
656

657
        return instance, nil
26✔
658
}
659

660
func (r *GrowthbookInstanceReconciler) getSecret(ctx context.Context, ref types.NamespacedName) (*corev1.Secret, error) {
51✔
661
        secret := &corev1.Secret{}
51✔
662
        err := r.Get(ctx, ref, secret)
51✔
663

51✔
664
        if err != nil {
77✔
665
                return nil, fmt.Errorf("referencing secret was not found: %w", err)
26✔
666
        }
26✔
667

668
        return secret, nil
25✔
669
}
670

671
func (r *GrowthbookInstanceReconciler) getClientToken(ctx context.Context, client v1beta1.GrowthbookClient) (string, error) {
20✔
672
        if client.Spec.TokenSecret == nil {
20✔
673
                return "", errors.New("no secret reference provided")
×
674
        }
×
675

676
        secret, err := r.getSecret(ctx, types.NamespacedName{
20✔
677
                Namespace: client.Namespace,
20✔
678
                Name:      client.Spec.TokenSecret.Name,
20✔
679
        })
20✔
680

20✔
681
        if err != nil {
26✔
682
                return "", err
6✔
683
        }
6✔
684

685
        tokenFieldName := "token"
14✔
686
        if client.Spec.TokenSecret.TokenField != "" {
28✔
687
                tokenFieldName = client.Spec.TokenSecret.TokenField
14✔
688
        }
14✔
689

690
        if val, ok := secret.Data[tokenFieldName]; !ok {
14✔
691
                return "", errors.New("defined token field not found in secret")
×
692
        } else {
14✔
693
                return string(val), nil
14✔
694
        }
14✔
695
}
696

697
func updateResourceCatalog(instance v1beta1.GrowthbookInstance, resource client.Object) v1beta1.GrowthbookInstance {
112✔
698
        resRef := v1beta1.ResourceReference{
112✔
699
                Kind:       resource.GetObjectKind().GroupVersionKind().Kind,
112✔
700
                Name:       resource.GetName(),
112✔
701
                APIVersion: fmt.Sprintf("%s/%s", resource.GetObjectKind().GroupVersionKind().Group, resource.GetObjectKind().GroupVersionKind().Version),
112✔
702
        }
112✔
703

112✔
704
        if !slices.Contains(instance.Status.SubResourceCatalog, resRef) {
210✔
705
                instance.Status.SubResourceCatalog = append(instance.Status.SubResourceCatalog, resRef)
98✔
706
        }
98✔
707

708
        return instance
112✔
709
}
710

711
func (r *GrowthbookInstanceReconciler) getUsernamePassword(ctx context.Context, instance v1beta1.GrowthbookInstance, secretReference *v1beta1.SecretReference) (string, string, error) {
×
712
        if secretReference == nil {
×
713
                return "", "", errors.New("no secret reference provided")
×
714
        }
×
715

716
        secret, err := r.getSecret(ctx, types.NamespacedName{
×
717
                Namespace: instance.Namespace,
×
718
                Name:      secretReference.Name,
×
719
        })
×
720

×
721
        if err != nil {
×
722
                return "", "", err
×
723
        }
×
724

725
        var (
×
726
                user string
×
727
                pw   string
×
728
        )
×
729

×
730
        if val, ok := secret.Data[secretReference.UserField]; !ok {
×
731
                return "", "", errors.New("defined username field not found in secret")
×
732
        } else {
×
733
                user = string(val)
×
734
        }
×
735

736
        if val, ok := secret.Data[secretReference.PasswordField]; !ok {
×
737
                return "", "", errors.New("defined password field not found in secret")
×
738
        } else {
×
739
                pw = string(val)
×
740
        }
×
741

742
        return user, pw, nil
×
743
}
744

745
func (r *GrowthbookInstanceReconciler) getOptionalUsernamePassword(ctx context.Context, instance v1beta1.GrowthbookInstance, secretReference *v1beta1.SecretReference) (string, string, error) {
31✔
746
        if secretReference == nil {
31✔
747
                return "", "", errors.New("no secret reference provided")
×
748
        }
×
749

750
        secret, err := r.getSecret(ctx, types.NamespacedName{
31✔
751
                Namespace: instance.Namespace,
31✔
752
                Name:      secretReference.Name,
31✔
753
        })
31✔
754

31✔
755
        if err != nil {
51✔
756
                return "", "", err
20✔
757
        }
20✔
758

759
        var (
11✔
760
                user string
11✔
761
                pw   string
11✔
762
        )
11✔
763

11✔
764
        if val, ok := secret.Data[secretReference.UserField]; ok {
11✔
765
                user = string(val)
×
766
        }
×
767

768
        if val, ok := secret.Data[secretReference.PasswordField]; !ok {
11✔
769
                return "", "", errors.New("defined password field not found in secret")
×
770
        } else {
11✔
771
                pw = string(val)
11✔
772
        }
11✔
773

774
        return user, pw, nil
11✔
775
}
776

777
func (r *GrowthbookInstanceReconciler) patch(ctx context.Context, obj *metav1.PartialObjectMetadata) error {
188✔
778
        key := client.ObjectKeyFromObject(obj)
188✔
779
        latest := &metav1.PartialObjectMetadata{
188✔
780
                TypeMeta: obj.TypeMeta,
188✔
781
        }
188✔
782

188✔
783
        if err := r.Get(ctx, key, latest); err != nil {
188✔
784
                return err
×
785
        }
×
786

787
        return r.Patch(ctx, obj, client.MergeFrom(latest))
188✔
788
}
789

790
func (r *GrowthbookInstanceReconciler) patchStatus(ctx context.Context, instance *v1beta1.GrowthbookInstance) error {
67✔
791
        key := client.ObjectKeyFromObject(instance)
67✔
792
        latest := &v1beta1.GrowthbookInstance{}
67✔
793
        if err := r.Get(ctx, key, latest); err != nil {
67✔
794
                return err
×
795
        }
×
796

797
        return r.Status().Patch(ctx, instance, client.MergeFrom(latest))
67✔
798
}
799

800
// objectKey returns client.ObjectKey for the object.
801
func objectKey(object metav1.Object) client.ObjectKey {
164✔
802
        return client.ObjectKey{
164✔
803
                Namespace: object.GetNamespace(),
164✔
804
                Name:      object.GetName(),
164✔
805
        }
164✔
806
}
164✔
807

808
func matches(labels map[string]string, selector *metav1.LabelSelector) bool {
228✔
809
        if selector == nil {
294✔
810
                return true
66✔
811
        }
66✔
812

813
        for kS, vS := range selector.MatchLabels {
278✔
814
                var match bool
116✔
815
                for kL, vL := range labels {
233✔
816
                        if kS == kL && vS == vL {
165✔
817
                                match = true
48✔
818
                        }
48✔
819
                }
820

821
                if !match {
184✔
822
                        return false
68✔
823
                }
68✔
824
        }
825

826
        return true
94✔
827
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc