From 174197087eb3f873e883f622831acfbe6ec6ceab Mon Sep 17 00:00:00 2001 From: raffis Date: Wed, 28 Jun 2023 12:35:00 +0200 Subject: [PATCH] feat: add garbage collection (#38) --- Makefile | 12 +- api/v1beta1/growthbookinstance_types.go | 4 + api/v1beta1/meta.go | 1 + chart/k8sgrowthbook-controller/Chart.yaml | 2 +- ....infra.doodle.com_growthbookinstances.yaml | 5 + ....infra.doodle.com_growthbookinstances.yaml | 5 + config/tests/base/{ => catalog}/catalog.yaml | 1 + config/tests/base/catalog/kustomization.yaml | 5 + config/tests/base/kustomization.yaml | 8 - .../kustomization.yaml | 5 + .../verify-get-features-clienttoken.yaml | 0 .../post-test}/kustomization.yaml | 6 +- .../{ => pre-test}/kustomization.yaml | 4 +- .../test/kustomization.yaml | 7 + .../post-test/kustomization.yaml | 6 + .../{ => pre-test}/kustomization.yaml | 9 +- .../test/kustomization.yaml | 7 + ...verify-proxy-get-features-clienttoken.yaml | 0 .../post-test/kustomization.yaml | 6 + .../verify-get-features-clienttoken.yaml | 31 ++ .../pre-test/kustomization.yaml | 12 + .../test/kustomization.yaml | 16 + .../post-test/kustomization.yaml | 6 + .../pre-test/kustomization.yaml | 12 + .../test/kustomization.yaml | 7 + internal/controllers/instance_controller.go | 218 ++++++++++--- .../controllers/instance_controller_test.go | 306 ++++++++++++++++++ internal/growthbook/feature.go | 9 + internal/growthbook/feature_test.go | 22 ++ internal/growthbook/organization.go | 9 + internal/growthbook/organization_test.go | 22 ++ internal/growthbook/sdkconnection.go | 9 + internal/growthbook/sdkconnection_test.go | 22 ++ internal/growthbook/storage_mock.go | 9 + internal/growthbook/user.go | 9 + internal/growthbook/user_test.go | 22 ++ internal/storage/mongodb/mongodb.go | 5 + internal/storage/storage.go | 1 + 38 files changed, 782 insertions(+), 58 deletions(-) rename config/tests/base/{ => catalog}/catalog.yaml (98%) create mode 100644 config/tests/base/catalog/kustomization.yaml delete mode 100644 config/tests/base/kustomization.yaml create mode 100644 config/tests/base/verify-get-features-clienttoken/kustomization.yaml rename config/tests/base/{ => verify-get-features-clienttoken}/verify-get-features-clienttoken.yaml (100%) rename config/tests/cases/{growthbook-v2.1.1-mongodb-v6 => growthbook-v2.1.1-mongodb-v5/post-test}/kustomization.yaml (61%) rename config/tests/cases/growthbook-v2.1.1-mongodb-v5/{ => pre-test}/kustomization.yaml (70%) create mode 100644 config/tests/cases/growthbook-v2.1.1-mongodb-v5/test/kustomization.yaml create mode 100644 config/tests/cases/growthbook-v2.1.1-mongodb-v6-with-proxy/post-test/kustomization.yaml rename config/tests/cases/growthbook-v2.1.1-mongodb-v6-with-proxy/{ => pre-test}/kustomization.yaml (74%) create mode 100644 config/tests/cases/growthbook-v2.1.1-mongodb-v6-with-proxy/test/kustomization.yaml rename config/tests/cases/growthbook-v2.1.1-mongodb-v6-with-proxy/{ => test}/verify-proxy-get-features-clienttoken.yaml (100%) create mode 100644 config/tests/cases/growthbook-v2.1.1-mongodb-v6-with-prune/post-test/kustomization.yaml create mode 100644 config/tests/cases/growthbook-v2.1.1-mongodb-v6-with-prune/post-test/verify-get-features-clienttoken.yaml create mode 100644 config/tests/cases/growthbook-v2.1.1-mongodb-v6-with-prune/pre-test/kustomization.yaml create mode 100644 config/tests/cases/growthbook-v2.1.1-mongodb-v6-with-prune/test/kustomization.yaml create mode 100644 config/tests/cases/growthbook-v2.1.1-mongodb-v6/post-test/kustomization.yaml create mode 100644 config/tests/cases/growthbook-v2.1.1-mongodb-v6/pre-test/kustomization.yaml create mode 100644 config/tests/cases/growthbook-v2.1.1-mongodb-v6/test/kustomization.yaml diff --git a/Makefile b/Makefile index 6a20367..749d940 100644 --- a/Makefile +++ b/Makefile @@ -104,8 +104,18 @@ kind-test: docker-build ## Deploy including test kustomize build config/base/crd | kubectl --context kind-${CLUSTER} apply -f - kind load docker-image ${IMG} --name ${CLUSTER} kubectl -n k8sgrowthbook-system delete pods --all - kustomize build config/tests/cases/${TEST_PROFILE} --enable-helm | kubectl --context kind-${CLUSTER} apply -f - + + echo "pre-test" + kustomize build config/tests/cases/${TEST_PROFILE}/pre-test --enable-helm | kubectl --context kind-${CLUSTER} apply -f - kubectl --context kind-${CLUSTER} -n k8sgrowthbook-system wait --for=condition=Ready pods -l control-plane=controller-manager -l app.kubernetes.io/managed-by!=Helm -l verify!=yes --timeout=3m + + echo "test" + kustomize build config/tests/cases/${TEST_PROFILE}/test --enable-helm | kubectl --context kind-${CLUSTER} apply -f - + kubectl --context kind-${CLUSTER} -n k8sgrowthbook-system wait --for=jsonpath='{.status.conditions[1].reason}'=PodCompleted pods -l control-plane=controller-manager -l app.kubernetes.io/managed-by!=Helm -l verify=yes --timeout=3m + + echo "post-test" + kustomize build config/tests/cases/${TEST_PROFILE}/test --enable-helm | kubectl --context kind-${CLUSTER} delete -f - + kustomize build config/tests/cases/${TEST_PROFILE}/post-test --enable-helm | kubectl --context kind-${CLUSTER} apply -f - kubectl --context kind-${CLUSTER} -n k8sgrowthbook-system wait --for=jsonpath='{.status.conditions[1].reason}'=PodCompleted pods -l control-plane=controller-manager -l app.kubernetes.io/managed-by!=Helm -l verify=yes --timeout=3m ##@ Deployment diff --git a/api/v1beta1/growthbookinstance_types.go b/api/v1beta1/growthbookinstance_types.go index 3deaa36..b7d5a56 100644 --- a/api/v1beta1/growthbookinstance_types.go +++ b/api/v1beta1/growthbookinstance_types.go @@ -28,6 +28,10 @@ type GrowthbookInstanceSpec struct { // Interval reconciliation Interval *metav1.Duration `json:"interval,omitempty"` + // Prune + // +kubebuilder:validation:Required + Prune bool `json:"prune"` + // Timeout while reconciling the instance // +kubebuilder:default:="5m" Timeout *metav1.Duration `json:"timeout,omitempty"` diff --git a/api/v1beta1/meta.go b/api/v1beta1/meta.go index fdad4bd..5a8401f 100644 --- a/api/v1beta1/meta.go +++ b/api/v1beta1/meta.go @@ -26,6 +26,7 @@ const ( SynchronizedReason = "Synchronized" ProgressingReason = "Progressing" FailedReason = "Failed" + Finalizer = "finalizers.doodle.com" ) // ConditionalResource is a resource with conditions diff --git a/chart/k8sgrowthbook-controller/Chart.yaml b/chart/k8sgrowthbook-controller/Chart.yaml index 58e1066..eef15f2 100644 --- a/chart/k8sgrowthbook-controller/Chart.yaml +++ b/chart/k8sgrowthbook-controller/Chart.yaml @@ -15,4 +15,4 @@ keywords: name: k8sgrowthbook-controller sources: - https://github.com/DoodleScheduling/k8sgrowthbook-controller -version: 0.1.0 +version: 0.1.1 diff --git a/chart/k8sgrowthbook-controller/crds/growthbook.infra.doodle.com_growthbookinstances.yaml b/chart/k8sgrowthbook-controller/crds/growthbook.infra.doodle.com_growthbookinstances.yaml index 2c25465..72e3a4d 100644 --- a/chart/k8sgrowthbook-controller/crds/growthbook.infra.doodle.com_growthbookinstances.yaml +++ b/chart/k8sgrowthbook-controller/crds/growthbook.infra.doodle.com_growthbookinstances.yaml @@ -71,6 +71,9 @@ spec: description: Address is a MongoDB comptaible URI `mongodb://xxx` type: string type: object + prune: + description: Prune + type: boolean resourceSelector: description: ResourceSelector defines a selector to select Growthbook resources associated with this instance @@ -124,6 +127,8 @@ spec: default: 5m description: Timeout while reconciling the instance type: string + required: + - prune type: object status: description: GrowthbookInstanceStatus defines the observed state of GrowthbookInstance diff --git a/config/base/crd/bases/growthbook.infra.doodle.com_growthbookinstances.yaml b/config/base/crd/bases/growthbook.infra.doodle.com_growthbookinstances.yaml index 2c25465..72e3a4d 100644 --- a/config/base/crd/bases/growthbook.infra.doodle.com_growthbookinstances.yaml +++ b/config/base/crd/bases/growthbook.infra.doodle.com_growthbookinstances.yaml @@ -71,6 +71,9 @@ spec: description: Address is a MongoDB comptaible URI `mongodb://xxx` type: string type: object + prune: + description: Prune + type: boolean resourceSelector: description: ResourceSelector defines a selector to select Growthbook resources associated with this instance @@ -124,6 +127,8 @@ spec: default: 5m description: Timeout while reconciling the instance type: string + required: + - prune type: object status: description: GrowthbookInstanceStatus defines the observed state of GrowthbookInstance diff --git a/config/tests/base/catalog.yaml b/config/tests/base/catalog/catalog.yaml similarity index 98% rename from config/tests/base/catalog.yaml rename to config/tests/base/catalog/catalog.yaml index f316fec..38173ce 100644 --- a/config/tests/base/catalog.yaml +++ b/config/tests/base/catalog/catalog.yaml @@ -5,6 +5,7 @@ metadata: spec: interval: 5m suspend: false + prune: false mongodb: uri: mongodb://mongodb.k8sgrowthbook-system:27017/growthbook rootSecret: diff --git a/config/tests/base/catalog/kustomization.yaml b/config/tests/base/catalog/kustomization.yaml new file mode 100644 index 0000000..820d64d --- /dev/null +++ b/config/tests/base/catalog/kustomization.yaml @@ -0,0 +1,5 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +resources: +- catalog.yaml \ No newline at end of file diff --git a/config/tests/base/kustomization.yaml b/config/tests/base/kustomization.yaml deleted file mode 100644 index 8901e8a..0000000 --- a/config/tests/base/kustomization.yaml +++ /dev/null @@ -1,8 +0,0 @@ -apiVersion: kustomize.config.k8s.io/v1beta1 -kind: Kustomization -resources: -- growthbook-controller -- growthbook/backend -- mongodb -- catalog.yaml -- verify-get-features-clienttoken.yaml \ No newline at end of file diff --git a/config/tests/base/verify-get-features-clienttoken/kustomization.yaml b/config/tests/base/verify-get-features-clienttoken/kustomization.yaml new file mode 100644 index 0000000..d16a963 --- /dev/null +++ b/config/tests/base/verify-get-features-clienttoken/kustomization.yaml @@ -0,0 +1,5 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +resources: +- verify-get-features-clienttoken.yaml \ No newline at end of file diff --git a/config/tests/base/verify-get-features-clienttoken.yaml b/config/tests/base/verify-get-features-clienttoken/verify-get-features-clienttoken.yaml similarity index 100% rename from config/tests/base/verify-get-features-clienttoken.yaml rename to config/tests/base/verify-get-features-clienttoken/verify-get-features-clienttoken.yaml diff --git a/config/tests/cases/growthbook-v2.1.1-mongodb-v6/kustomization.yaml b/config/tests/cases/growthbook-v2.1.1-mongodb-v5/post-test/kustomization.yaml similarity index 61% rename from config/tests/cases/growthbook-v2.1.1-mongodb-v6/kustomization.yaml rename to config/tests/cases/growthbook-v2.1.1-mongodb-v5/post-test/kustomization.yaml index 884e476..d758917 100644 --- a/config/tests/cases/growthbook-v2.1.1-mongodb-v6/kustomization.yaml +++ b/config/tests/cases/growthbook-v2.1.1-mongodb-v5/post-test/kustomization.yaml @@ -3,8 +3,4 @@ kind: Kustomization namespace: k8sgrowthbook-system resources: -- ../../base - -images: -- name: growthbook/growthbook - newTag: 2.1.1 \ No newline at end of file +- ../../../base/verify-get-features-clienttoken \ No newline at end of file diff --git a/config/tests/cases/growthbook-v2.1.1-mongodb-v5/kustomization.yaml b/config/tests/cases/growthbook-v2.1.1-mongodb-v5/pre-test/kustomization.yaml similarity index 70% rename from config/tests/cases/growthbook-v2.1.1-mongodb-v5/kustomization.yaml rename to config/tests/cases/growthbook-v2.1.1-mongodb-v5/pre-test/kustomization.yaml index b61d304..15e8d88 100644 --- a/config/tests/cases/growthbook-v2.1.1-mongodb-v5/kustomization.yaml +++ b/config/tests/cases/growthbook-v2.1.1-mongodb-v5/pre-test/kustomization.yaml @@ -3,7 +3,9 @@ kind: Kustomization namespace: k8sgrowthbook-system resources: -- ../../base +- ../../../base/mongodb +- ../../../base/growthbook/backend +- ../../../base/growthbook-controller images: - name: growthbook/growthbook diff --git a/config/tests/cases/growthbook-v2.1.1-mongodb-v5/test/kustomization.yaml b/config/tests/cases/growthbook-v2.1.1-mongodb-v5/test/kustomization.yaml new file mode 100644 index 0000000..967b08e --- /dev/null +++ b/config/tests/cases/growthbook-v2.1.1-mongodb-v5/test/kustomization.yaml @@ -0,0 +1,7 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +namespace: k8sgrowthbook-system + +resources: +- ../../../base/catalog +- ../../../base/verify-get-features-clienttoken \ No newline at end of file diff --git a/config/tests/cases/growthbook-v2.1.1-mongodb-v6-with-proxy/post-test/kustomization.yaml b/config/tests/cases/growthbook-v2.1.1-mongodb-v6-with-proxy/post-test/kustomization.yaml new file mode 100644 index 0000000..d758917 --- /dev/null +++ b/config/tests/cases/growthbook-v2.1.1-mongodb-v6-with-proxy/post-test/kustomization.yaml @@ -0,0 +1,6 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +namespace: k8sgrowthbook-system + +resources: +- ../../../base/verify-get-features-clienttoken \ No newline at end of file diff --git a/config/tests/cases/growthbook-v2.1.1-mongodb-v6-with-proxy/kustomization.yaml b/config/tests/cases/growthbook-v2.1.1-mongodb-v6-with-proxy/pre-test/kustomization.yaml similarity index 74% rename from config/tests/cases/growthbook-v2.1.1-mongodb-v6-with-proxy/kustomization.yaml rename to config/tests/cases/growthbook-v2.1.1-mongodb-v6-with-proxy/pre-test/kustomization.yaml index 2851e44..0a297d1 100644 --- a/config/tests/cases/growthbook-v2.1.1-mongodb-v6-with-proxy/kustomization.yaml +++ b/config/tests/cases/growthbook-v2.1.1-mongodb-v6-with-proxy/pre-test/kustomization.yaml @@ -3,9 +3,10 @@ kind: Kustomization namespace: k8sgrowthbook-system resources: -- ../../base -- ../../base/growthbook/proxy -- verify-proxy-get-features-clienttoken.yaml +- ../../../base/mongodb +- ../../../base/growthbook/backend +- ../../../base/growthbook/proxy +- ../../../base/growthbook-controller images: - name: growthbook/growthbook @@ -26,4 +27,4 @@ patches: value: "http://growthbook-proxy" - op: replace path: /data/PROXY_HOST_PUBLIC - value: "http://growthbook-proxy" \ No newline at end of file + value: "http://growthbook-proxy" diff --git a/config/tests/cases/growthbook-v2.1.1-mongodb-v6-with-proxy/test/kustomization.yaml b/config/tests/cases/growthbook-v2.1.1-mongodb-v6-with-proxy/test/kustomization.yaml new file mode 100644 index 0000000..4873bbe --- /dev/null +++ b/config/tests/cases/growthbook-v2.1.1-mongodb-v6-with-proxy/test/kustomization.yaml @@ -0,0 +1,7 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +namespace: k8sgrowthbook-system + +resources: +- ../../../base/catalog +- verify-proxy-get-features-clienttoken.yaml \ No newline at end of file diff --git a/config/tests/cases/growthbook-v2.1.1-mongodb-v6-with-proxy/verify-proxy-get-features-clienttoken.yaml b/config/tests/cases/growthbook-v2.1.1-mongodb-v6-with-proxy/test/verify-proxy-get-features-clienttoken.yaml similarity index 100% rename from config/tests/cases/growthbook-v2.1.1-mongodb-v6-with-proxy/verify-proxy-get-features-clienttoken.yaml rename to config/tests/cases/growthbook-v2.1.1-mongodb-v6-with-proxy/test/verify-proxy-get-features-clienttoken.yaml diff --git a/config/tests/cases/growthbook-v2.1.1-mongodb-v6-with-prune/post-test/kustomization.yaml b/config/tests/cases/growthbook-v2.1.1-mongodb-v6-with-prune/post-test/kustomization.yaml new file mode 100644 index 0000000..8a92500 --- /dev/null +++ b/config/tests/cases/growthbook-v2.1.1-mongodb-v6-with-prune/post-test/kustomization.yaml @@ -0,0 +1,6 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +namespace: k8sgrowthbook-system + +resources: +- verify-get-features-clienttoken.yaml \ No newline at end of file diff --git a/config/tests/cases/growthbook-v2.1.1-mongodb-v6-with-prune/post-test/verify-get-features-clienttoken.yaml b/config/tests/cases/growthbook-v2.1.1-mongodb-v6-with-prune/post-test/verify-get-features-clienttoken.yaml new file mode 100644 index 0000000..07fbc7d --- /dev/null +++ b/config/tests/cases/growthbook-v2.1.1-mongodb-v6-with-prune/post-test/verify-get-features-clienttoken.yaml @@ -0,0 +1,31 @@ +apiVersion: v1 +kind: Pod +metadata: + name: verify-get-features-clienttoken + labels: + verify: yes +spec: + restartPolicy: OnFailure + containers: + - image: curlimages/curl:8.1.2 + imagePullPolicy: IfNotPresent + name: verify + command: + - /bin/sh + - "-c" + - | + code=$(curl -s -o /dev/null -I -w "%{http_code}" http://growthbook-api/api/features/sdk-token) + if [ "$code" = "400" ]; then + exit 0 + else + exit 1 + fi + resources: {} + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: false + runAsGroup: 1000 + runAsNonRoot: true + runAsUser: 1000 + terminationMessagePath: /dev/termination-log + terminationMessagePolicy: File diff --git a/config/tests/cases/growthbook-v2.1.1-mongodb-v6-with-prune/pre-test/kustomization.yaml b/config/tests/cases/growthbook-v2.1.1-mongodb-v6-with-prune/pre-test/kustomization.yaml new file mode 100644 index 0000000..d5a86d7 --- /dev/null +++ b/config/tests/cases/growthbook-v2.1.1-mongodb-v6-with-prune/pre-test/kustomization.yaml @@ -0,0 +1,12 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +namespace: k8sgrowthbook-system + +resources: +- ../../../base/mongodb +- ../../../base/growthbook/backend +- ../../../base/growthbook-controller + +images: +- name: growthbook/growthbook + newTag: 2.1.1 \ No newline at end of file diff --git a/config/tests/cases/growthbook-v2.1.1-mongodb-v6-with-prune/test/kustomization.yaml b/config/tests/cases/growthbook-v2.1.1-mongodb-v6-with-prune/test/kustomization.yaml new file mode 100644 index 0000000..90e9071 --- /dev/null +++ b/config/tests/cases/growthbook-v2.1.1-mongodb-v6-with-prune/test/kustomization.yaml @@ -0,0 +1,16 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +namespace: k8sgrowthbook-system + +resources: +- ../../../base/catalog +- ../../../base/verify-get-features-clienttoken + +patches: +- target: + kind: GrowthbookInstance + name: my-instance + patch: |- + - op: replace + path: /spec/prune + value: true \ No newline at end of file diff --git a/config/tests/cases/growthbook-v2.1.1-mongodb-v6/post-test/kustomization.yaml b/config/tests/cases/growthbook-v2.1.1-mongodb-v6/post-test/kustomization.yaml new file mode 100644 index 0000000..d758917 --- /dev/null +++ b/config/tests/cases/growthbook-v2.1.1-mongodb-v6/post-test/kustomization.yaml @@ -0,0 +1,6 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +namespace: k8sgrowthbook-system + +resources: +- ../../../base/verify-get-features-clienttoken \ No newline at end of file diff --git a/config/tests/cases/growthbook-v2.1.1-mongodb-v6/pre-test/kustomization.yaml b/config/tests/cases/growthbook-v2.1.1-mongodb-v6/pre-test/kustomization.yaml new file mode 100644 index 0000000..d5a86d7 --- /dev/null +++ b/config/tests/cases/growthbook-v2.1.1-mongodb-v6/pre-test/kustomization.yaml @@ -0,0 +1,12 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +namespace: k8sgrowthbook-system + +resources: +- ../../../base/mongodb +- ../../../base/growthbook/backend +- ../../../base/growthbook-controller + +images: +- name: growthbook/growthbook + newTag: 2.1.1 \ No newline at end of file diff --git a/config/tests/cases/growthbook-v2.1.1-mongodb-v6/test/kustomization.yaml b/config/tests/cases/growthbook-v2.1.1-mongodb-v6/test/kustomization.yaml new file mode 100644 index 0000000..967b08e --- /dev/null +++ b/config/tests/cases/growthbook-v2.1.1-mongodb-v6/test/kustomization.yaml @@ -0,0 +1,7 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +namespace: k8sgrowthbook-system + +resources: +- ../../../base/catalog +- ../../../base/verify-get-features-clienttoken \ No newline at end of file diff --git a/internal/controllers/instance_controller.go b/internal/controllers/instance_controller.go index 29a5b40..db564ad 100644 --- a/internal/controllers/instance_controller.go +++ b/internal/controllers/instance_controller.go @@ -38,6 +38,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/builder" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/controller" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" "sigs.k8s.io/controller-runtime/pkg/handler" "sigs.k8s.io/controller-runtime/pkg/predicate" "sigs.k8s.io/controller-runtime/pkg/reconcile" @@ -62,6 +63,7 @@ const ( secretIndexKey = ".metadata.secret" usersIndexKey = ".metadata.users" orgsIndexKey = ".metadata.orgs" + owner = "k8sgrowthbook-controller" ) // MongoDBProvider returns a storage.Database for MongoDB @@ -257,6 +259,11 @@ func (r *GrowthbookInstanceReconciler) Reconcile(ctx context.Context, req ctrl.R return ctrl.Result{}, nil } + // examine DeletionTimestamp to determine if object is under deletion + if err := r.addFinalizer(ctx, v1beta1.Finalizer, metav1.PartialObjectMetadata{TypeMeta: instance.TypeMeta, ObjectMeta: instance.ObjectMeta}); err != nil { + return ctrl.Result{}, err + } + start := time.Now() reconcileContext := ctx @@ -282,6 +289,14 @@ func (r *GrowthbookInstanceReconciler) Reconcile(ctx context.Context, req ctrl.R res = ctrl.Result{Requeue: true} instance = v1beta1.GrowthbookInstanceNotReady(instance, v1beta1.FailedReason, err.Error()) } else { + if !instance.DeletionTimestamp.IsZero() { + if err := r.removeFinalizer(ctx, v1beta1.Finalizer, metav1.PartialObjectMetadata{TypeMeta: instance.TypeMeta, ObjectMeta: instance.ObjectMeta}); err != nil { + return res, err + } else { + return ctrl.Result{}, nil + } + } + if instance.Spec.Interval != nil { res = ctrl.Result{ RequeueAfter: instance.Spec.Interval.Duration, @@ -303,6 +318,7 @@ func (r *GrowthbookInstanceReconciler) Reconcile(ctx context.Context, req ctrl.R } func (r *GrowthbookInstanceReconciler) reconcile(ctx context.Context, instance v1beta1.GrowthbookInstance, logger logr.Logger) (v1beta1.GrowthbookInstance, error) { + //TODO there is a test race condition with this one, leaving for now /*msg := "reconcile instance progressing" r.Recorder.Event(&instance, "Normal", "info", msg) instance = v1beta1.GrowthbookInstanceNotReady(instance, v1beta1.ProgressingReason, msg) @@ -353,6 +369,8 @@ func (r *GrowthbookInstanceReconciler) reconcile(ctx context.Context, instance v func (r *GrowthbookInstanceReconciler) reconcileOrganizations(ctx context.Context, instance v1beta1.GrowthbookInstance, db storage.Database, logger logr.Logger) (v1beta1.GrowthbookInstance, []v1beta1.GrowthbookOrganization, error) { var orgs v1beta1.GrowthbookOrganizationList + finalizerName := fmt.Sprintf("%s/%s.%s", v1beta1.Finalizer, instance.Name, instance.Namespace) + selector, err := metav1.LabelSelectorAsSelector(instance.Spec.ResourceSelector) if err != nil { return instance, nil, err @@ -363,8 +381,16 @@ func (r *GrowthbookInstanceReconciler) reconcileOrganizations(ctx context.Contex return instance, nil, err } - for _, org := range orgs.Items { - instance = updateResourceCatalog(instance, &org) + if instance.DeletionTimestamp.IsZero() { + for _, org := range orgs.Items { + if err := r.addFinalizer(ctx, finalizerName, metav1.PartialObjectMetadata{TypeMeta: org.TypeMeta, ObjectMeta: org.ObjectMeta}); err != nil { + return instance, nil, err + } + + if org.DeletionTimestamp.IsZero() { + instance = updateResourceCatalog(instance, &org) + } + } } for _, org := range orgs.Items { @@ -391,8 +417,20 @@ func (r *GrowthbookInstanceReconciler) reconcileOrganizations(ctx context.Contex } } - if err := growthbook.UpdateOrganization(ctx, o, db); err != nil { - return instance, nil, err + if org.DeletionTimestamp.IsZero() && instance.DeletionTimestamp.IsZero() { + if err := growthbook.UpdateOrganization(ctx, o, db); err != nil { + return instance, nil, err + } + } else { + if instance.Spec.Prune { + if err := growthbook.DeleteOrganization(ctx, o, db); err != nil { + return instance, nil, err + } + } + + if err := r.removeFinalizer(ctx, finalizerName, metav1.PartialObjectMetadata{TypeMeta: org.TypeMeta, ObjectMeta: org.ObjectMeta}); err != nil { + return instance, nil, err + } } } @@ -406,6 +444,7 @@ func (r *GrowthbookInstanceReconciler) reconcileFeatures(ctx context.Context, in return instance, err } + finalizerName := fmt.Sprintf("%s/%s.%s", v1beta1.Finalizer, instance.Name, instance.Namespace) instanceSelector, err := metav1.LabelSelectorAsSelector(instance.Spec.ResourceSelector) if err != nil { return instance, err @@ -419,28 +458,72 @@ func (r *GrowthbookInstanceReconciler) reconcileFeatures(ctx context.Context, in return instance, err } - for _, feature := range features.Items { - instance = updateResourceCatalog(instance, &feature) + if instance.DeletionTimestamp.IsZero() { + for _, feature := range features.Items { + if err := r.addFinalizer(ctx, finalizerName, metav1.PartialObjectMetadata{TypeMeta: feature.TypeMeta, ObjectMeta: feature.ObjectMeta}); err != nil { + return instance, err + } + + if feature.DeletionTimestamp.IsZero() { + instance = updateResourceCatalog(instance, &feature) + } + } } for _, feature := range features.Items { f := growthbook.Feature{ - Owner: "k8sgrowthbook-controller", + Owner: owner, Organization: org.GetID(), } f.FromV1beta1(feature) - if err := growthbook.UpdateFeature(ctx, f, db); err != nil { - return instance, err + if feature.DeletionTimestamp.IsZero() && instance.DeletionTimestamp.IsZero() { + if err := growthbook.UpdateFeature(ctx, f, db); err != nil { + return instance, err + } + } else { + if instance.Spec.Prune { + if err := growthbook.DeleteFeature(ctx, f, db); err != nil { + return instance, err + } + } + + if err := r.removeFinalizer(ctx, finalizerName, metav1.PartialObjectMetadata{TypeMeta: feature.TypeMeta, ObjectMeta: feature.ObjectMeta}); err != nil { + return instance, err + } } } return instance, nil } +func (r *GrowthbookInstanceReconciler) addFinalizer(ctx context.Context, finalizerName string, obj metav1.PartialObjectMetadata) error { + if !obj.GetDeletionTimestamp().IsZero() { + return nil + } + + controllerutil.AddFinalizer(&obj, finalizerName) + if err := r.patch(ctx, &obj); err != nil { + return err + } + + return nil +} + +func (r *GrowthbookInstanceReconciler) removeFinalizer(ctx context.Context, finalizerName string, obj metav1.PartialObjectMetadata) error { + controllerutil.RemoveFinalizer(&obj, finalizerName) + if err := r.patch(ctx, &obj); err != nil { + return err + } + + return nil +} + func (r *GrowthbookInstanceReconciler) reconcileUsers(ctx context.Context, instance v1beta1.GrowthbookInstance, db storage.Database, logger logr.Logger) (v1beta1.GrowthbookInstance, error) { var users v1beta1.GrowthbookUserList + finalizerName := fmt.Sprintf("%s/%s.%s", v1beta1.Finalizer, instance.Name, instance.Namespace) + selector, err := metav1.LabelSelectorAsSelector(instance.Spec.ResourceSelector) if err != nil { return instance, err @@ -451,27 +534,49 @@ func (r *GrowthbookInstanceReconciler) reconcileUsers(ctx context.Context, insta return instance, err } - for _, user := range users.Items { - instance = updateResourceCatalog(instance, &user) - } + if instance.DeletionTimestamp.IsZero() { + for _, user := range users.Items { + if err := r.addFinalizer(ctx, finalizerName, metav1.PartialObjectMetadata{TypeMeta: user.TypeMeta, ObjectMeta: user.ObjectMeta}); err != nil { + return instance, err + } - for _, user := range users.Items { - username, password, err := r.getOptionalUsernamePassword(ctx, instance, user.Spec.Secret) - if err != nil { - return instance, err + if user.DeletionTimestamp.IsZero() { + instance = updateResourceCatalog(instance, &user) + } } + } + for _, user := range users.Items { u := growthbook.User{} - if username != "" { - u.Email = username - } + u.FromV1beta1(user) - if err := u.FromV1beta1(user).SetPassword(ctx, db, password); err != nil { - return instance, err - } + if user.DeletionTimestamp.IsZero() && instance.DeletionTimestamp.IsZero() { + username, password, err := r.getOptionalUsernamePassword(ctx, instance, user.Spec.Secret) + if err != nil { + return instance, err + } - if err := growthbook.UpdateUser(ctx, u, db); err != nil { - return instance, err + if username != "" { + u.Email = username + } + + if err := u.SetPassword(ctx, db, password); err != nil { + return instance, err + } + + if err := growthbook.UpdateUser(ctx, u, db); err != nil { + return instance, err + } + } else { + if instance.Spec.Prune { + if err := growthbook.DeleteUser(ctx, u, db); err != nil { + return instance, err + } + } + + if err := r.removeFinalizer(ctx, finalizerName, metav1.PartialObjectMetadata{TypeMeta: user.TypeMeta, ObjectMeta: user.ObjectMeta}); err != nil { + return instance, err + } } } @@ -480,6 +585,8 @@ func (r *GrowthbookInstanceReconciler) reconcileUsers(ctx context.Context, insta func (r *GrowthbookInstanceReconciler) reconcileClients(ctx context.Context, instance v1beta1.GrowthbookInstance, org v1beta1.GrowthbookOrganization, db storage.Database, logger logr.Logger) (v1beta1.GrowthbookInstance, error) { var clients v1beta1.GrowthbookClientList + finalizerName := fmt.Sprintf("%s/%s.%s", v1beta1.Finalizer, instance.Name, instance.Namespace) + selector, err := metav1.LabelSelectorAsSelector(org.Spec.ResourceSelector) if err != nil { return instance, err @@ -498,30 +605,52 @@ func (r *GrowthbookInstanceReconciler) reconcileClients(ctx context.Context, ins return instance, err } - for _, client := range clients.Items { - instance = updateResourceCatalog(instance, &client) - } - - for _, client := range clients.Items { - token, err := r.getClientToken(ctx, client) - if err != nil { - return instance, err - } + if instance.DeletionTimestamp.IsZero() { + for _, client := range clients.Items { + if err := r.addFinalizer(ctx, finalizerName, metav1.PartialObjectMetadata{TypeMeta: client.TypeMeta, ObjectMeta: client.ObjectMeta}); err != nil { + return instance, err + } - if token[:4] != "sdk-" { - token = fmt.Sprintf("sdk-%s", token) + if client.DeletionTimestamp.IsZero() { + instance = updateResourceCatalog(instance, &client) + } } + } + for _, client := range clients.Items { s := growthbook.SDKConnection{ Organization: org.GetID(), - Key: token, } s.FromV1beta1(client) - if err := growthbook.UpdateSDKConnection(ctx, s, db); err != nil { - return instance, err + if client.DeletionTimestamp.IsZero() && instance.DeletionTimestamp.IsZero() { + token, err := r.getClientToken(ctx, client) + if err != nil { + return instance, err + } + + if token[:4] != "sdk-" { + token = fmt.Sprintf("sdk-%s", token) + } + + s.Key = token + + if err := growthbook.UpdateSDKConnection(ctx, s, db); err != nil { + return instance, err + } + } else { + if instance.Spec.Prune { + if err := growthbook.DeleteSDKConnection(ctx, s, db); err != nil { + return instance, err + } + } + + if err := r.removeFinalizer(ctx, finalizerName, metav1.PartialObjectMetadata{TypeMeta: client.TypeMeta, ObjectMeta: client.ObjectMeta}); err != nil { + return instance, err + } } + } return instance, nil @@ -644,6 +773,19 @@ func (r *GrowthbookInstanceReconciler) getOptionalUsernamePassword(ctx context.C return user, pw, nil } +func (r *GrowthbookInstanceReconciler) patch(ctx context.Context, obj *metav1.PartialObjectMetadata) error { + key := client.ObjectKeyFromObject(obj) + latest := &metav1.PartialObjectMetadata{ + TypeMeta: obj.TypeMeta, + } + + if err := r.Client.Get(ctx, key, latest); err != nil { + return err + } + + return r.Client.Patch(ctx, obj, client.MergeFrom(latest)) +} + func (r *GrowthbookInstanceReconciler) patchStatus(ctx context.Context, instance *v1beta1.GrowthbookInstance) error { key := client.ObjectKeyFromObject(instance) latest := &v1beta1.GrowthbookInstance{} diff --git a/internal/controllers/instance_controller_test.go b/internal/controllers/instance_controller_test.go index a13d57a..4760a6d 100644 --- a/internal/controllers/instance_controller_test.go +++ b/internal/controllers/instance_controller_test.go @@ -38,6 +38,9 @@ func MockProvider(ctx context.Context, instance v1beta1.GrowthbookInstance, user DeleteMany: func(ctx context.Context, filter interface{}) error { return nil }, + DeleteOne: func(ctx context.Context, filter interface{}) error { + return nil + }, }, nil } @@ -686,6 +689,309 @@ var _ = Describe("GrowthbookInstance controller", func() { Expect(reconciledInstance.Status.SubResourceCatalog).To(Equal(expectedStatus.SubResourceCatalog)) }) }) + + When("garbae collecting resources other than GrowthbookInstance", func() { + name := fmt.Sprintf("growthbookinstance-%s", randStringRunes(5)) + nameOrg := fmt.Sprintf("growthbookorganization-%s", randStringRunes(5)) + + It("should delete a GrowthbookOrganization without pruning", func() { + By("By creating a new GrowthbookInstance") + ctx := context.Background() + + gi := &v1beta1.GrowthbookInstance{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: "default", + }, + Spec: v1beta1.GrowthbookInstanceSpec{ + MongoDB: v1beta1.GrowthbookInstanceMongoDB{}, + ResourceSelector: &metav1.LabelSelector{ + MatchLabels: map[string]string{ + "instance": name, + }, + }, + }, + } + Expect(k8sClient.Create(ctx, gi)).Should(Succeed()) + + By("By creating a new GrowthbookOrganization matching instance=test-instance") + gorg := &v1beta1.GrowthbookOrganization{ + ObjectMeta: metav1.ObjectMeta{ + Name: nameOrg, + Namespace: "default", + Labels: map[string]string{ + "instance": name, + }, + }, + Spec: v1beta1.GrowthbookOrganizationSpec{ + OwnerEmail: "admin@org.com", + ResourceSelector: &metav1.LabelSelector{ + MatchLabels: map[string]string{ + "org": nameOrg, + }, + }, + }, + } + Expect(k8sClient.Create(ctx, gorg)).Should(Succeed()) + + orgLookupKey := types.NamespacedName{Name: nameOrg, Namespace: "default"} + reconciledOrganization := &v1beta1.GrowthbookOrganization{} + + Eventually(func() bool { + err := k8sClient.Get(ctx, orgLookupKey, reconciledOrganization) + if err != nil { + return false + } + + return len(reconciledOrganization.Finalizers) == 1 && + reconciledOrganization.Finalizers[0] == fmt.Sprintf("finalizers.doodle.com/%s.default", name) + }, timeout, interval).Should(BeTrue()) + + By("By deleting the GrowthbookOrganization") + Expect(k8sClient.Delete(ctx, gorg)).Should(Succeed()) + + Eventually(func() bool { + err := k8sClient.Get(ctx, orgLookupKey, reconciledOrganization) + return err != nil + }, timeout, interval).Should(BeTrue()) + + By("By creating the same GrowthbookOrganization matching instance=test-instance again but with a different id") + gorg2 := &v1beta1.GrowthbookOrganization{ + ObjectMeta: metav1.ObjectMeta{ + Name: nameOrg, + Namespace: "default", + Labels: map[string]string{ + "instance": name, + }, + }, + Spec: v1beta1.GrowthbookOrganizationSpec{ + ID: "another-id", + OwnerEmail: "admin@org.com", + ResourceSelector: &metav1.LabelSelector{ + MatchLabels: map[string]string{ + "org": nameOrg, + }, + }, + }, + } + Expect(k8sClient.Create(ctx, gorg2)).Should(Succeed()) + + instanceLookupKey := types.NamespacedName{Name: name, Namespace: "default"} + reconciledInstance := &v1beta1.GrowthbookInstance{} + expectedStatus := v1beta1.GrowthbookInstanceStatus{ + ObservedGeneration: int64(1), + SubResourceCatalog: []v1beta1.ResourceReference{ + { + Kind: "GrowthbookOrganization", + APIVersion: "growthbook.infra.doodle.com/v1beta1", + Name: nameOrg, + }, + }, + Conditions: []metav1.Condition{ + { + Type: v1beta1.ReadyCondition, + Status: "True", + ObservedGeneration: 0, + Reason: "Synchronized", + Message: "instance successfully reconciled", + }, + }, + } + + Eventually(func() bool { + err := k8sClient.Get(ctx, instanceLookupKey, reconciledInstance) + if err != nil { + return false + } + + return needStatus(reconciledInstance, &expectedStatus) && + len(reconciledInstance.Finalizers) == 1 && + reconciledInstance.Finalizers[0] == "finalizers.doodle.com" + }, timeout, interval).Should(BeTrue()) + + }) + + It("should delete a GrowthbookOrganization with pruning", func() { + By("By setting spec.prune=true on the GrowtbookInstance") + instanceLookupKey := types.NamespacedName{Name: name, Namespace: "default"} + reconciledInstance := &v1beta1.GrowthbookInstance{} + Expect(k8sClient.Get(ctx, instanceLookupKey, reconciledInstance)).Should(Succeed()) + + reconciledInstance.Spec.Prune = true + Expect(k8sClient.Update(ctx, reconciledInstance)).Should(Succeed()) + + orgLookupKey := types.NamespacedName{Name: nameOrg, Namespace: "default"} + reconciledOrganization := &v1beta1.GrowthbookOrganization{} + Expect(k8sClient.Get(ctx, orgLookupKey, reconciledOrganization)).Should(Succeed()) + + By("By deleting the GrowthbookOrganization") + Expect(k8sClient.Delete(ctx, reconciledOrganization)).Should(Succeed()) + + Eventually(func() bool { + err := k8sClient.Get(ctx, orgLookupKey, reconciledOrganization) + return err != nil + }, timeout, interval).Should(BeTrue()) + }) + }) + + When("garbage collecting GrowthbookInstance", func() { + name := fmt.Sprintf("growthbookinstance-%s", randStringRunes(5)) + nameOrg := fmt.Sprintf("growthbookorganization-%s", randStringRunes(5)) + + It("should remove the growthbooks finalizer from all related resources with pruning", func() { + By("By creating a new GrowthbookInstance") + ctx := context.Background() + + gi := &v1beta1.GrowthbookInstance{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: "default", + }, + Spec: v1beta1.GrowthbookInstanceSpec{ + Prune: true, + MongoDB: v1beta1.GrowthbookInstanceMongoDB{}, + ResourceSelector: &metav1.LabelSelector{ + MatchLabels: map[string]string{ + "instance": name, + }, + }, + }, + } + Expect(k8sClient.Create(ctx, gi)).Should(Succeed()) + + By("By creating a new GrowthbookOrganization matching instance=test-instance") + gorg := &v1beta1.GrowthbookOrganization{ + ObjectMeta: metav1.ObjectMeta{ + Name: nameOrg, + Namespace: "default", + Labels: map[string]string{ + "instance": name, + }, + }, + Spec: v1beta1.GrowthbookOrganizationSpec{ + OwnerEmail: "admin@org.com", + ResourceSelector: &metav1.LabelSelector{ + MatchLabels: map[string]string{ + "org": nameOrg, + }, + }, + }, + } + Expect(k8sClient.Create(ctx, gorg)).Should(Succeed()) + + orgLookupKey := types.NamespacedName{Name: nameOrg, Namespace: "default"} + reconciledOrganization := &v1beta1.GrowthbookOrganization{} + + Eventually(func() bool { + err := k8sClient.Get(ctx, orgLookupKey, reconciledOrganization) + if err != nil { + return false + } + + return len(reconciledOrganization.Finalizers) == 1 && + reconciledOrganization.Finalizers[0] == fmt.Sprintf("finalizers.doodle.com/%s.default", name) + }, timeout, interval).Should(BeTrue()) + + By("By deleting the GrowthbookInstance") + Expect(k8sClient.Delete(ctx, gi)).Should(Succeed()) + + instanceLookupKey := types.NamespacedName{Name: name, Namespace: "default"} + reconciledInstance := &v1beta1.GrowthbookInstance{} + + Eventually(func() bool { + err := k8sClient.Get(ctx, instanceLookupKey, reconciledInstance) + return err != nil + }, timeout, interval).Should(BeTrue()) + + By("By making sure the finalizer from the GrowthbookOrganization is removed") + Eventually(func() bool { + err := k8sClient.Get(ctx, orgLookupKey, reconciledOrganization) + if err != nil { + return false + } + + return len(reconciledOrganization.Finalizers) == 0 + }, timeout, interval).Should(BeTrue()) + }) + }) + + It("should remove the growthbooks finalizer from all related resources without pruning", func() { + name := fmt.Sprintf("growthbookinstance-%s", randStringRunes(5)) + nameOrg := fmt.Sprintf("growthbookorganization-%s", randStringRunes(5)) + + By("By creating a new GrowthbookInstance") + ctx := context.Background() + + gi := &v1beta1.GrowthbookInstance{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: "default", + }, + Spec: v1beta1.GrowthbookInstanceSpec{ + MongoDB: v1beta1.GrowthbookInstanceMongoDB{}, + ResourceSelector: &metav1.LabelSelector{ + MatchLabels: map[string]string{ + "instance": name, + }, + }, + }, + } + Expect(k8sClient.Create(ctx, gi)).Should(Succeed()) + + By("By creating a new GrowthbookOrganization matching instance=test-instance") + gorg := &v1beta1.GrowthbookOrganization{ + ObjectMeta: metav1.ObjectMeta{ + Name: nameOrg, + Namespace: "default", + Labels: map[string]string{ + "instance": name, + }, + }, + Spec: v1beta1.GrowthbookOrganizationSpec{ + OwnerEmail: "admin@org.com", + ResourceSelector: &metav1.LabelSelector{ + MatchLabels: map[string]string{ + "org": nameOrg, + }, + }, + }, + } + Expect(k8sClient.Create(ctx, gorg)).Should(Succeed()) + + orgLookupKey := types.NamespacedName{Name: nameOrg, Namespace: "default"} + reconciledOrganization := &v1beta1.GrowthbookOrganization{} + + Eventually(func() bool { + err := k8sClient.Get(ctx, orgLookupKey, reconciledOrganization) + if err != nil { + return false + } + + return len(reconciledOrganization.Finalizers) == 1 && + reconciledOrganization.Finalizers[0] == fmt.Sprintf("finalizers.doodle.com/%s.default", name) + }, timeout, interval).Should(BeTrue()) + + By("By deleting the GrowthbookInstance") + Expect(k8sClient.Delete(ctx, gi)).Should(Succeed()) + + instanceLookupKey := types.NamespacedName{Name: name, Namespace: "default"} + reconciledInstance := &v1beta1.GrowthbookInstance{} + + Eventually(func() bool { + err := k8sClient.Get(ctx, instanceLookupKey, reconciledInstance) + return err != nil + }, timeout, interval).Should(BeTrue()) + + By("By making sure the finalizer from the GrowthbookOrganization is removed") + Eventually(func() bool { + err := k8sClient.Get(ctx, orgLookupKey, reconciledOrganization) + if err != nil { + return false + } + + return len(reconciledOrganization.Finalizers) == 0 + }, timeout, interval).Should(BeTrue()) + }) }) func needStatus(reconciledInstance *v1beta1.GrowthbookInstance, expectedStatus *v1beta1.GrowthbookInstanceStatus) bool { diff --git a/internal/growthbook/feature.go b/internal/growthbook/feature.go index fb73a1a..6c89c87 100644 --- a/internal/growthbook/feature.go +++ b/internal/growthbook/feature.go @@ -65,6 +65,15 @@ func (f *Feature) FromV1beta1(feature v1beta1.GrowthbookFeature) *Feature { return f } +func DeleteFeature(ctx context.Context, feature Feature, db storage.Database) error { + col := db.Collection("features") + filter := bson.M{ + "id": feature.ID, + } + + return col.DeleteOne(ctx, filter) +} + func UpdateFeature(ctx context.Context, feature Feature, db storage.Database) error { col := db.Collection("features") filter := bson.M{ diff --git a/internal/growthbook/feature_test.go b/internal/growthbook/feature_test.go index 5e165f8..dc182c3 100644 --- a/internal/growthbook/feature_test.go +++ b/internal/growthbook/feature_test.go @@ -53,6 +53,28 @@ func TestFeatureFromV1beta1(t *testing.T) { g.Expect(f.ID).To(Equal(apiSpec.Spec.ID)) } +func TestFeatureDelete(t *testing.T) { + g := NewWithT(t) + + var deleteFilter bson.M + db := &MockDatabase{ + DeleteOne: func(ctx context.Context, filter interface{}) error { + deleteFilter = filter.(bson.M) + return nil + }, + } + + feature := Feature{ + ID: "feature", + } + + err := DeleteFeature(context.TODO(), feature, db) + g.Expect(err).To(BeNil()) + g.Expect(deleteFilter).To(Equal(bson.M{ + "id": "feature", + })) +} + func TestFeatureCreateIfNotExists(t *testing.T) { g := NewWithT(t) diff --git a/internal/growthbook/organization.go b/internal/growthbook/organization.go index 02928cf..1a24298 100644 --- a/internal/growthbook/organization.go +++ b/internal/growthbook/organization.go @@ -31,6 +31,15 @@ func (o *Organization) FromV1beta1(org v1beta1.GrowthbookOrganization) *Organiza return o } +func DeleteOrganization(ctx context.Context, org Organization, db storage.Database) error { + col := db.Collection("organizations") + filter := bson.M{ + "id": org.ID, + } + + return col.DeleteOne(ctx, filter) +} + func UpdateOrganization(ctx context.Context, org Organization, db storage.Database) error { col := db.Collection("organizations") filter := bson.M{ diff --git a/internal/growthbook/organization_test.go b/internal/growthbook/organization_test.go index 4089e54..642fe95 100644 --- a/internal/growthbook/organization_test.go +++ b/internal/growthbook/organization_test.go @@ -38,6 +38,28 @@ func TestOrganizationFromV1beta1(t *testing.T) { g.Expect(f.Name).To(Equal(apiSpec.Spec.Name)) } +func TestOrganizationDelete(t *testing.T) { + g := NewWithT(t) + + var deleteFilter bson.M + db := &MockDatabase{ + DeleteOne: func(ctx context.Context, filter interface{}) error { + deleteFilter = filter.(bson.M) + return nil + }, + } + + org := Organization{ + ID: "org", + } + + err := DeleteOrganization(context.TODO(), org, db) + g.Expect(err).To(BeNil()) + g.Expect(deleteFilter).To(Equal(bson.M{ + "id": "org", + })) +} + func TestOrganizationCreateIfNotExists(t *testing.T) { g := NewWithT(t) diff --git a/internal/growthbook/sdkconnection.go b/internal/growthbook/sdkconnection.go index e013a9b..9601518 100644 --- a/internal/growthbook/sdkconnection.go +++ b/internal/growthbook/sdkconnection.go @@ -54,6 +54,15 @@ func (s *SDKConnection) FromV1beta1(client v1beta1.GrowthbookClient) *SDKConnect return s } +func DeleteSDKConnection(ctx context.Context, sdkconnection SDKConnection, db storage.Database) error { + col := db.Collection("sdkconnections") + filter := bson.M{ + "id": sdkconnection.ID, + } + + return col.DeleteOne(ctx, filter) +} + func UpdateSDKConnection(ctx context.Context, sdkconnection SDKConnection, db storage.Database) error { col := db.Collection("sdkconnections") filter := bson.M{ diff --git a/internal/growthbook/sdkconnection_test.go b/internal/growthbook/sdkconnection_test.go index 599f4c3..17a6b83 100644 --- a/internal/growthbook/sdkconnection_test.go +++ b/internal/growthbook/sdkconnection_test.go @@ -51,6 +51,28 @@ func TestSDKConnectionFromV1beta1(t *testing.T) { g.Expect(f.Name).To(Equal(apiSpec.Spec.Name)) } +func TestSDKConnectionDelete(t *testing.T) { + g := NewWithT(t) + + var deleteFilter bson.M + db := &MockDatabase{ + DeleteOne: func(ctx context.Context, filter interface{}) error { + deleteFilter = filter.(bson.M) + return nil + }, + } + + sdkconnection := SDKConnection{ + ID: "sdkconnection", + } + + err := DeleteSDKConnection(context.TODO(), sdkconnection, db) + g.Expect(err).To(BeNil()) + g.Expect(deleteFilter).To(Equal(bson.M{ + "id": "sdkconnection", + })) +} + func TestSDKConnectionCreateIfNotExists(t *testing.T) { g := NewWithT(t) diff --git a/internal/growthbook/storage_mock.go b/internal/growthbook/storage_mock.go index 6fb0677..8f3ec4c 100644 --- a/internal/growthbook/storage_mock.go +++ b/internal/growthbook/storage_mock.go @@ -14,6 +14,7 @@ var ( type MockDatabase struct { FindOne func(ctx context.Context, filter interface{}, dst interface{}) error + DeleteOne func(ctx context.Context, filter interface{}) error InsertOne func(ctx context.Context, doc interface{}) error UpdateOne func(ctx context.Context, filter interface{}, doc interface{}) error DeleteMany func(ctx context.Context, filter interface{}) error @@ -37,6 +38,14 @@ func (c *MockCollection) FindOne(ctx context.Context, filter interface{}, dst in return c.db.FindOne(ctx, filter, dst) } +func (c *MockCollection) DeleteOne(ctx context.Context, filter interface{}) error { + if c.db.DeleteOne == nil { + return errors.New("no mock func for deleteOne provided") + } + + return c.db.DeleteOne(ctx, filter) +} + func (c *MockCollection) InsertOne(ctx context.Context, doc interface{}) error { if c.db.InsertOne == nil { return errors.New("no mock func for insertOne provided") diff --git a/internal/growthbook/user.go b/internal/growthbook/user.go index d06795c..ffd020d 100644 --- a/internal/growthbook/user.go +++ b/internal/growthbook/user.go @@ -67,6 +67,15 @@ func (u *User) SetPassword(ctx context.Context, db storage.Database, password st return nil } +func DeleteUser(ctx context.Context, user User, db storage.Database) error { + col := db.Collection("users") + filter := bson.M{ + "id": user.ID, + } + + return col.DeleteOne(ctx, filter) +} + func UpdateUser(ctx context.Context, user User, db storage.Database) error { col := db.Collection("users") filter := bson.M{ diff --git a/internal/growthbook/user_test.go b/internal/growthbook/user_test.go index 1a49548..adb4b68 100644 --- a/internal/growthbook/user_test.go +++ b/internal/growthbook/user_test.go @@ -38,6 +38,28 @@ func TestUserFromV1beta1(t *testing.T) { g.Expect(f.Name).To(Equal(apiSpec.Spec.Name)) } +func TestUserDelete(t *testing.T) { + g := NewWithT(t) + + var deleteFilter bson.M + db := &MockDatabase{ + DeleteOne: func(ctx context.Context, filter interface{}) error { + deleteFilter = filter.(bson.M) + return nil + }, + } + + user := User{ + ID: "user", + } + + err := DeleteUser(context.TODO(), user, db) + g.Expect(err).To(BeNil()) + g.Expect(deleteFilter).To(Equal(bson.M{ + "id": "user", + })) +} + func TestUserCreateIfNotExists(t *testing.T) { g := NewWithT(t) diff --git a/internal/storage/mongodb/mongodb.go b/internal/storage/mongodb/mongodb.go index 66eca49..012c80a 100644 --- a/internal/storage/mongodb/mongodb.go +++ b/internal/storage/mongodb/mongodb.go @@ -36,6 +36,11 @@ func (c *Collection) FindOne(ctx context.Context, filter interface{}, dst interf return c.collection.FindOne(ctx, filter).Decode(dst) } +func (c *Collection) DeleteOne(ctx context.Context, filter interface{}) error { + _, err := c.collection.DeleteOne(ctx, filter) + return err +} + func (c *Collection) InsertOne(ctx context.Context, doc interface{}) error { _, err := c.collection.InsertOne(ctx, doc) return err diff --git a/internal/storage/storage.go b/internal/storage/storage.go index 65a8439..71aea20 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -8,6 +8,7 @@ type Database interface { type Collection interface { FindOne(ctx context.Context, filter interface{}, dst interface{}) error + DeleteOne(ctx context.Context, filter interface{}) error InsertOne(ctx context.Context, doc interface{}) error UpdateOne(ctx context.Context, filter interface{}, doc interface{}) error DeleteMany(ctx context.Context, filter interface{}) error