From db8f6b7650074bb2a582a15394996dadc3f90a1f Mon Sep 17 00:00:00 2001 From: Jose Vazquez Date: Wed, 5 Jul 2023 12:27:53 +0200 Subject: [PATCH] CLOUDP-186825: Add deletion protection to deployments Signed-off-by: Jose Vazquez --- cmd/manager/main.go | 18 +- .../atlasdeployment_controller.go | 216 +++++++------- .../atlasdeployment_controller_test.go | 268 ++++++++++++++++++ .../customresource/customresource.go | 10 + .../customresource/protection_test.go | 149 ++++++++++ test/int/databaseuser_protected_test.go | 5 + test/int/deployment_protected_test.go | 104 +++++++ test/int/deployment_unprotected_test.go | 101 +++++++ test/int/integration_suite_test.go | 16 +- 9 files changed, 773 insertions(+), 114 deletions(-) create mode 100644 pkg/controller/atlasdeployment/atlasdeployment_controller_test.go create mode 100644 pkg/controller/customresource/protection_test.go create mode 100644 test/int/deployment_protected_test.go create mode 100644 test/int/deployment_unprotected_test.go diff --git a/cmd/manager/main.go b/cmd/manager/main.go index 940b3eb671..f5308e66c0 100644 --- a/cmd/manager/main.go +++ b/cmd/manager/main.go @@ -133,14 +133,16 @@ func main() { } if err = (&atlasdeployment.AtlasDeploymentReconciler{ - Client: mgr.GetClient(), - Log: logger.Named("controllers").Named("AtlasDeployment").Sugar(), - Scheme: mgr.GetScheme(), - AtlasDomain: config.AtlasDomain, - GlobalAPISecret: config.GlobalAPISecret, - ResourceWatcher: watch.NewResourceWatcher(), - GlobalPredicates: globalPredicates, - EventRecorder: mgr.GetEventRecorderFor("AtlasDeployment"), + Client: mgr.GetClient(), + Log: logger.Named("controllers").Named("AtlasDeployment").Sugar(), + Scheme: mgr.GetScheme(), + AtlasDomain: config.AtlasDomain, + GlobalAPISecret: config.GlobalAPISecret, + ResourceWatcher: watch.NewResourceWatcher(), + GlobalPredicates: globalPredicates, + EventRecorder: mgr.GetEventRecorderFor("AtlasDeployment"), + ObjectDeletionProtection: config.ObjectDeletionProtection, + SubObjectDeletionProtection: config.SubObjectDeletionProtection, }).SetupWithManager(mgr); err != nil { setupLog.Error(err, "unable to create controller", "controller", "AtlasDeployment") os.Exit(1) diff --git a/pkg/controller/atlasdeployment/atlasdeployment_controller.go b/pkg/controller/atlasdeployment/atlasdeployment_controller.go index 3c3d2e292a..076c9677bc 100644 --- a/pkg/controller/atlasdeployment/atlasdeployment_controller.go +++ b/pkg/controller/atlasdeployment/atlasdeployment_controller.go @@ -52,13 +52,23 @@ import ( // AtlasDeploymentReconciler reconciles an AtlasDeployment object type AtlasDeploymentReconciler struct { watch.ResourceWatcher - Client client.Client - Log *zap.SugaredLogger - Scheme *runtime.Scheme - AtlasDomain string - GlobalAPISecret client.ObjectKey - GlobalPredicates []predicate.Predicate - EventRecorder record.EventRecorder + Client client.Client + Log *zap.SugaredLogger + Scheme *runtime.Scheme + AtlasDomain string + GlobalAPISecret client.ObjectKey + GlobalPredicates []predicate.Predicate + EventRecorder record.EventRecorder + ObjectDeletionProtection bool + SubObjectDeletionProtection bool +} + +type reconciliation struct { + reconciler *AtlasDeploymentReconciler + log *zap.SugaredLogger + context context.Context + workflowCtx *workflow.Context + prevResult workflow.Result } // +kubebuilder:rbac:groups=atlas.mongodb.com,resources=atlasdeployments,verbs=get;list;watch;create;update;patch;delete @@ -80,32 +90,39 @@ type AtlasDeploymentReconciler struct { // +kubebuilder:rbac:groups="",namespace=default,resources=events,verbs=create;patch func (r *AtlasDeploymentReconciler) Reconcile(context context.Context, req ctrl.Request) (ctrl.Result, error) { - log := r.Log.With("atlasdeployment", req.NamespacedName) + rc := reconciliation{ + reconciler: r, + context: context, + log: r.Log.With("atlasdeployment", req.NamespacedName), + } deployment := &mdbv1.AtlasDeployment{} - result := customresource.PrepareResource(r.Client, req, deployment, log) - if !result.IsOk() { - return result.ReconcileResult(), nil + { + result := customresource.PrepareResource(r.Client, req, deployment, rc.log) + if !result.IsOk() { + return result.ReconcileResult(), nil + } + rc.prevResult = result } if shouldSkip := customresource.ReconciliationShouldBeSkipped(deployment); shouldSkip { - log.Infow(fmt.Sprintf("-> Skipping AtlasDeployment reconciliation as annotation %s=%s", customresource.ReconciliationPolicyAnnotation, customresource.ReconciliationPolicySkip), "spec", deployment.Spec) + rc.log.Infow(fmt.Sprintf("-> Skipping AtlasDeployment reconciliation as annotation %s=%s", customresource.ReconciliationPolicyAnnotation, customresource.ReconciliationPolicySkip), "spec", deployment.Spec) if !deployment.GetDeletionTimestamp().IsZero() { - err := r.removeDeletionFinalizer(context, deployment) + err := rc.removeDeletionFinalizer(deployment) if err != nil { - result = workflow.Terminate(workflow.Internal, err.Error()) - log.Errorw("failed to remove finalizer", "error", err) + result := workflow.Terminate(workflow.Internal, err.Error()) + rc.log.Errorw("failed to remove finalizer", "error", err) return result.ReconcileResult(), nil } } return workflow.OK().ReconcileResult(), nil } - ctx := customresource.MarkReconciliationStarted(r.Client, deployment, log) - log.Infow("-> Starting AtlasDeployment reconciliation", "spec", deployment.Spec, "status", deployment.Status) - defer statushandler.Update(ctx, r.Client, r.EventRecorder, deployment) + rc.workflowCtx = customresource.MarkReconciliationStarted(r.Client, deployment, rc.log) + rc.log.Infow("-> Starting AtlasDeployment reconciliation", "spec", deployment.Spec, "status", deployment.Status) + defer statushandler.Update(rc.workflowCtx, r.Client, r.EventRecorder, deployment) - resourceVersionIsValid := customresource.ValidateResourceVersion(ctx, deployment, r.Log) + resourceVersionIsValid := customresource.ValidateResourceVersion(rc.workflowCtx, deployment, r.Log) if !resourceVersionIsValid.IsOk() { r.Log.Debugf("deployment validation result: %v", resourceVersionIsValid) return resourceVersionIsValid.ReconcileResult(), nil @@ -113,32 +130,32 @@ func (r *AtlasDeploymentReconciler) Reconcile(context context.Context, req ctrl. if err := validate.DeploymentSpec(deployment.Spec); err != nil { result := workflow.Terminate(workflow.Internal, err.Error()) - ctx.SetConditionFromResult(status.ValidationSucceeded, result) + rc.workflowCtx.SetConditionFromResult(status.ValidationSucceeded, result) return result.ReconcileResult(), nil } - ctx.SetConditionTrue(status.ValidationSucceeded) + rc.workflowCtx.SetConditionTrue(status.ValidationSucceeded) project := &mdbv1.AtlasProject{} if result := r.readProjectResource(context, deployment, project); !result.IsOk() { - ctx.SetConditionFromResult(status.DeploymentReadyType, result) + rc.workflowCtx.SetConditionFromResult(status.DeploymentReadyType, result) return result.ReconcileResult(), nil } - connection, err := atlas.ReadConnection(log, r.Client, r.GlobalAPISecret, project.ConnectionSecretObjectKey()) + connection, err := atlas.ReadConnection(rc.log, r.Client, r.GlobalAPISecret, project.ConnectionSecretObjectKey()) if err != nil { result := workflow.Terminate(workflow.AtlasCredentialsNotProvided, err.Error()) - ctx.SetConditionFromResult(status.DeploymentReadyType, result) + rc.workflowCtx.SetConditionFromResult(status.DeploymentReadyType, result) return result.ReconcileResult(), nil } - ctx.Connection = connection + rc.workflowCtx.Connection = connection - atlasClient, err := atlas.Client(r.AtlasDomain, connection, log) + atlasClient, err := atlas.Client(r.AtlasDomain, connection, rc.log) if err != nil { result := workflow.Terminate(workflow.Internal, err.Error()) - ctx.SetConditionFromResult(status.DeploymentReadyType, result) + rc.workflowCtx.SetConditionFromResult(status.DeploymentReadyType, result) return result.ReconcileResult(), nil } - ctx.Client = atlasClient + rc.workflowCtx.Client = atlasClient // Allow users to specify M0/M2/M5 deployments without providing TENANT for Normal and Serverless deployments r.verifyNonTenantCase(deployment) @@ -147,58 +164,40 @@ func (r *AtlasDeploymentReconciler) Reconcile(context context.Context, req ctrl. if !customresource.HaveFinalizer(deployment, customresource.FinalizerLabel) { err = r.Client.Get(context, kube.ObjectKeyFromObject(deployment), deployment) if err != nil { - result = workflow.Terminate(workflow.Internal, err.Error()) + result := workflow.Terminate(workflow.Internal, err.Error()) return result.ReconcileResult(), nil } customresource.SetFinalizer(deployment, customresource.FinalizerLabel) if err = r.Client.Update(context, deployment); err != nil { - result = workflow.Terminate(workflow.Internal, err.Error()) - log.Errorw("failed to add finalizer", "error", err) + result := workflow.Terminate(workflow.Internal, err.Error()) + rc.log.Errorw("failed to add finalizer", "error", err) return result.ReconcileResult(), nil } } } if !deployment.GetDeletionTimestamp().IsZero() { - if customresource.HaveFinalizer(deployment, customresource.FinalizerLabel) { - if customresource.ResourceShouldBeLeftInAtlas(deployment) { - log.Infof("Not removing Atlas Deployment from Atlas as the '%s' annotation is set", customresource.ResourcePolicyAnnotation) - } else { - if err = r.deleteDeploymentFromAtlas(context, project, deployment, atlasClient, log); err != nil { - log.Errorf("failed to remove deployment from Atlas: %s", err) - result = workflow.Terminate(workflow.Internal, err.Error()) - ctx.SetConditionFromResult(status.DeploymentReadyType, result) - return result.ReconcileResult(), nil - } - } - err = r.removeDeletionFinalizer(context, deployment) - if err != nil { - result = workflow.Terminate(workflow.Internal, err.Error()) - log.Errorw("failed to remove finalizer", "error", err) - return result.ReconcileResult(), nil - } - } - return result.ReconcileResult(), nil + return rc.checkAndHandleRemoval(project, deployment) } if deployment.IsLegacyDeployment() { if err := ConvertLegacyDeployment(&deployment.Spec); err != nil { - result = workflow.Terminate(workflow.Internal, err.Error()) - log.Errorw("failed to convert legacy deployment", "error", err) + result := workflow.Terminate(workflow.Internal, err.Error()) + rc.log.Errorw("failed to convert legacy deployment", "error", err) return result.ReconcileResult(), nil } deployment.Spec.DeploymentSpec = nil } - handleDeployment := r.selectDeploymentHandler(deployment) - if result, _ := handleDeployment(ctx, project, deployment, req); !result.IsOk() { - ctx.SetConditionFromResult(status.DeploymentReadyType, result) + handleDeployment := rc.selectDeploymentHandler(deployment) + if result, _ := handleDeployment(project, deployment, req); !result.IsOk() { + rc.workflowCtx.SetConditionFromResult(status.DeploymentReadyType, result) return result.ReconcileResult(), nil } if !deployment.IsServerless() { - if result := r.handleAdvancedOptions(ctx, project, deployment); !result.IsOk() { - ctx.SetConditionFromResult(status.DeploymentReadyType, result) + if result := r.handleAdvancedOptions(rc.workflowCtx, project, deployment); !result.IsOk() { + rc.workflowCtx.SetConditionFromResult(status.DeploymentReadyType, result) return result.ReconcileResult(), nil } } @@ -228,6 +227,35 @@ func (r *AtlasDeploymentReconciler) verifyNonTenantCase(deployment *mdbv1.AtlasD modifyProviderSettings(pSettings, deploymentType) } +func (rc *reconciliation) checkAndHandleRemoval(project *mdbv1.AtlasProject, deployment *mdbv1.AtlasDeployment) (reconcile.Result, error) { + if customresource.HaveFinalizer(deployment, customresource.FinalizerLabel) { + isProtected := customresource.IsResourceProtected(deployment, rc.reconciler.ObjectDeletionProtection) + rc.log.Infow("RESOURCE PROTECTED", + "reconciler-object-deletion-protection", rc.reconciler.ObjectDeletionProtection, "protected", isProtected) + if isProtected { + rc.log.Info("Not removing Atlas deployment from Atlas as per configuration") + } else { + if customresource.ResourceShouldBeLeftInAtlas(deployment) { + rc.log.Infof("Not removing Atlas Deployment from Atlas as the '%s' annotation is set", customresource.ResourcePolicyAnnotation) + } else { + if err := rc.deleteDeploymentFromAtlas(project, deployment); err != nil { + rc.log.Errorf("failed to remove deployment from Atlas: %s", err) + result := workflow.Terminate(workflow.Internal, err.Error()) + rc.workflowCtx.SetConditionFromResult(status.DeploymentReadyType, result) + return result.ReconcileResult(), nil + } + } + } + err := customresource.ManageFinalizer(rc.context, rc.reconciler.Client, deployment, customresource.UnsetFinalizer) + if err != nil { + result := workflow.Terminate(workflow.Internal, err.Error()) + rc.log.Errorw("failed to remove finalizer", "error", err) + return result.ReconcileResult(), nil + } + } + return rc.prevResult.ReconcileResult(), nil +} + func modifyProviderSettings(pSettings *mdbv1.ProviderSettingsSpec, deploymentType string) { if pSettings == nil || string(pSettings.ProviderName) == deploymentType { return @@ -246,18 +274,18 @@ func modifyProviderSettings(pSettings *mdbv1.ProviderSettingsSpec, deploymentTyp } } -func (r *AtlasDeploymentReconciler) selectDeploymentHandler(deployment *mdbv1.AtlasDeployment) deploymentHandlerFunc { +func (rc *reconciliation) selectDeploymentHandler(deployment *mdbv1.AtlasDeployment) deploymentHandlerFunc { if deployment.IsServerless() { - return r.handleServerlessInstance + return rc.handleServerlessInstance } - return r.handleAdvancedDeployment + return rc.handleAdvancedDeployment } // handleAdvancedDeployment ensures the state of the deployment using the Advanced Deployment API -func (r *AtlasDeploymentReconciler) handleAdvancedDeployment(ctx *workflow.Context, project *mdbv1.AtlasProject, deployment *mdbv1.AtlasDeployment, req reconcile.Request) (workflow.Result, error) { - c, result := r.ensureAdvancedDeploymentState(ctx, project, deployment) +func (rc *reconciliation) handleAdvancedDeployment(project *mdbv1.AtlasProject, deployment *mdbv1.AtlasDeployment, req reconcile.Request) (workflow.Result, error) { + c, result := rc.reconciler.ensureAdvancedDeploymentState(rc.workflowCtx, project, deployment) if c != nil && c.StateName != "" { - ctx.EnsureStatusOption(status.AtlasDeploymentStateNameOption(c.StateName)) + rc.workflowCtx.EnsureStatusOption(status.AtlasDeploymentStateNameOption(c.StateName)) } if !result.IsOk() { @@ -275,42 +303,42 @@ func (r *AtlasDeploymentReconciler) handleAdvancedDeployment(ctx *workflow.Conte ) } - ctx.EnsureStatusOption(status.AtlasDeploymentReplicaSet(replicaSetStatus)) + rc.workflowCtx.EnsureStatusOption(status.AtlasDeploymentReplicaSet(replicaSetStatus)) backupEnabled := false if c.BackupEnabled != nil { backupEnabled = *c.BackupEnabled } - if err := r.ensureBackupScheduleAndPolicy( + if err := rc.reconciler.ensureBackupScheduleAndPolicy( context.Background(), - ctx, project.ID(), + rc.workflowCtx, project.ID(), deployment, backupEnabled, req.NamespacedName, ); err != nil { result := workflow.Terminate(workflow.Internal, err.Error()) - ctx.SetConditionFromResult(status.DeploymentReadyType, result) + rc.workflowCtx.SetConditionFromResult(status.DeploymentReadyType, result) return result, nil } - if csResult := r.ensureConnectionSecrets(ctx, project, c.Name, c.ConnectionStrings, deployment); !csResult.IsOk() { + if csResult := rc.reconciler.ensureConnectionSecrets(rc.workflowCtx, project, c.Name, c.ConnectionStrings, deployment); !csResult.IsOk() { return csResult, nil } - ctx. + rc.workflowCtx. SetConditionTrue(status.DeploymentReadyType). EnsureStatusOption(status.AtlasDeploymentMongoDBVersionOption(c.MongoDBVersion)). EnsureStatusOption(status.AtlasDeploymentConnectionStringsOption(c.ConnectionStrings)) - ctx.SetConditionTrue(status.ReadyType) + rc.workflowCtx.SetConditionTrue(status.ReadyType) return result, nil } // handleServerlessInstance ensures the state of the serverless instance using the serverless API -func (r *AtlasDeploymentReconciler) handleServerlessInstance(ctx *workflow.Context, project *mdbv1.AtlasProject, deployment *mdbv1.AtlasDeployment, req reconcile.Request) (workflow.Result, error) { - c, result := ensureServerlessInstanceState(ctx, project, deployment.Spec.ServerlessSpec) - return r.ensureConnectionSecretsAndSetStatusOptions(ctx, project, deployment, result, c) +func (rc *reconciliation) handleServerlessInstance(project *mdbv1.AtlasProject, deployment *mdbv1.AtlasDeployment, req reconcile.Request) (workflow.Result, error) { + c, result := ensureServerlessInstanceState(rc.workflowCtx, project, deployment.Spec.ServerlessSpec) + return rc.reconciler.ensureConnectionSecretsAndSetStatusOptions(rc.workflowCtx, project, deployment, result, c) } // ensureConnectionSecretsAndSetStatusOptions creates the relevant connection secrets and sets @@ -404,75 +432,65 @@ func (r *AtlasDeploymentReconciler) SetupWithManager(mgr ctrl.Manager) error { } // Delete implements a handler for the Delete event. -func (r *AtlasDeploymentReconciler) deleteConnectionStrings( - ctx context.Context, - project *mdbv1.AtlasProject, - deployment *mdbv1.AtlasDeployment, - log *zap.SugaredLogger, -) error { +func (rc *reconciliation) deleteConnectionStrings(project *mdbv1.AtlasProject, deployment *mdbv1.AtlasDeployment) error { // We always remove the connection secrets even if the deployment is not removed from Atlas - secrets, err := connectionsecret.ListByDeploymentName(r.Client, "", project.ID(), deployment.GetDeploymentName()) + secrets, err := connectionsecret.ListByDeploymentName(rc.reconciler.Client, "", project.ID(), deployment.GetDeploymentName()) if err != nil { return fmt.Errorf("failed to find connection secrets for the user: %w", err) } for i := range secrets { - if err := r.Client.Delete(ctx, &secrets[i]); err != nil { + if err := rc.reconciler.Client.Delete(rc.context, &secrets[i]); err != nil { if k8serrors.IsNotFound(err) { continue } - log.Errorw("Failed to delete secret", "secretName", secrets[i].Name, "error", err) + rc.log.Errorw("Failed to delete secret", "secretName", secrets[i].Name, "error", err) } } return nil } -func (r *AtlasDeploymentReconciler) deleteDeploymentFromAtlas( - ctx context.Context, - project *mdbv1.AtlasProject, - deployment *mdbv1.AtlasDeployment, - atlasClient mongodbatlas.Client, - log *zap.SugaredLogger, -) error { - log.Infow("-> Starting AtlasDeployment deletion", "spec", deployment.Spec) +func (rc *reconciliation) deleteDeploymentFromAtlas(project *mdbv1.AtlasProject, deployment *mdbv1.AtlasDeployment) error { + rc.log.Infow("-> Starting AtlasDeployment deletion", "spec", deployment.Spec) - err := r.deleteConnectionStrings(ctx, project, deployment, log) + err := rc.deleteConnectionStrings(project, deployment) if err != nil { return err } + atlasClient := rc.workflowCtx.Client if deployment.IsServerless() { - _, err = atlasClient.ServerlessInstances.Delete(ctx, project.Status.ID, deployment.GetDeploymentName()) + _, err = atlasClient.ServerlessInstances.Delete(rc.context, project.Status.ID, deployment.GetDeploymentName()) } else { - _, err = atlasClient.AdvancedClusters.Delete(ctx, project.Status.ID, deployment.GetDeploymentName(), nil) + _, err = atlasClient.AdvancedClusters.Delete(rc.context, project.Status.ID, deployment.GetDeploymentName(), nil) } var apiError *mongodbatlas.ErrorResponse if errors.As(err, &apiError) && apiError.ErrorCode == atlas.ClusterNotFound { - log.Info("Deployment doesn't exist or is already deleted") + rc.log.Info("Deployment doesn't exist or is already deleted") return nil } if err != nil { - log.Errorw("Cannot delete Atlas deployment", "error", err) + rc.log.Errorw("Cannot delete Atlas deployment", "error", err) return err } return nil } -func (r *AtlasDeploymentReconciler) removeDeletionFinalizer(context context.Context, deployment *mdbv1.AtlasDeployment) error { - err := r.Client.Get(context, kube.ObjectKeyFromObject(deployment), deployment) +func (rc *reconciliation) removeDeletionFinalizer(deployment *mdbv1.AtlasDeployment) error { + err := rc.reconciler.Client.Get(rc.context, kube.ObjectKeyFromObject(deployment), deployment) if err != nil { return fmt.Errorf("cannot get AtlasDeployment while adding finalizer: %w", err) } customresource.UnsetFinalizer(deployment, customresource.FinalizerLabel) - if err = r.Client.Update(context, deployment); err != nil { + if err = rc.reconciler.Client.Update(rc.context, deployment); err != nil { return fmt.Errorf("failed to remove deletion finalizer from %s: %w", deployment.GetDeploymentName(), err) } return nil } -type deploymentHandlerFunc func(ctx *workflow.Context, project *mdbv1.AtlasProject, deployment *mdbv1.AtlasDeployment, req reconcile.Request) (workflow.Result, error) +type deploymentHandlerFunc func(project *mdbv1.AtlasProject, deployment *mdbv1.AtlasDeployment, req reconcile.Request) (workflow.Result, error) diff --git a/pkg/controller/atlasdeployment/atlasdeployment_controller_test.go b/pkg/controller/atlasdeployment/atlasdeployment_controller_test.go new file mode 100644 index 0000000000..fe12734c1b --- /dev/null +++ b/pkg/controller/atlasdeployment/atlasdeployment_controller_test.go @@ -0,0 +1,268 @@ +/* +Copyright 2020 MongoDB. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package atlasdeployment + +import ( + "context" + "fmt" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.mongodb.org/atlas/mongodbatlas" + "go.uber.org/zap" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + v1 "github.com/mongodb/mongodb-atlas-kubernetes/pkg/api/v1" + "github.com/mongodb/mongodb-atlas-kubernetes/pkg/api/v1/common" + "github.com/mongodb/mongodb-atlas-kubernetes/pkg/api/v1/status" + "github.com/mongodb/mongodb-atlas-kubernetes/pkg/controller/atlas" + "github.com/mongodb/mongodb-atlas-kubernetes/pkg/controller/customresource" + "github.com/mongodb/mongodb-atlas-kubernetes/pkg/controller/workflow" + "github.com/mongodb/mongodb-atlas-kubernetes/pkg/util/httputil" +) + +const ( + fakeDomain = "atlas-unit-test.local" + fakeProjectID = "fake-test-project-id" + fakeNamespace = "fake-namespace" +) + +func TestDeploymentRemovalCases(t *testing.T) { + testCases := []struct { + title string + protected bool + annotation string + expectRemoval bool + }{ + { + title: "Deployment with protection ON and no annotations is kept", + protected: true, + expectRemoval: false, + }, + { + title: "Deployment with protection OFF and no annotations is removed", + protected: false, + expectRemoval: true, + }, + { + title: "Deployment with protection ON and 'keep' annotation is kept", + protected: true, + annotation: customresource.ResourcePolicyKeep, + expectRemoval: false, + }, + { + title: "Deployment with protection OFF but 'keep' annotation is kept", + protected: false, + annotation: customresource.ResourcePolicyKeep, + expectRemoval: false, + }, + { + title: "Deployment with protection ON but 'delete' annotation is removed", + protected: true, + annotation: customresource.ResourcePolicyDelete, + expectRemoval: true, + }, + { + title: "Deployment with protection OFF and 'delete' annotation is removed", + protected: false, + annotation: customresource.ResourcePolicyDelete, + expectRemoval: true, + }, + } + for _, tc := range testCases { + t.Run(tc.title, func(t *testing.T) { + rt := testDeploymentDeletionRoundTripper() + te := newTestDeploymentEnv(t, rt, tc.protected, tc.annotation) + + _, err := te.reconciliation.checkAndHandleRemoval(te.project, te.deployment) + + require.NoError(t, err) + assert.Equal(t, tc.expectRemoval, rt.called) + }) + } +} + +func newTestDeploymentEnv(t *testing.T, rt http.RoundTripper, protected bool, annotation string) *testDeploymentEnv { + t.Helper() + + sch := runtime.NewScheme() + addSecretsListSchema(sch) + addDeploymentSchema(sch) + k8sclient := testK8sClient(sch) + + log := testLog(t) + r := testDeploymentReconciler(log, k8sclient, protected) + + project := testProject(fakeNamespace) + deployment := testDeployment(project) + if annotation != "" { + customresource.SetAnnotation(deployment, customresource.ResourcePolicyAnnotation, annotation) + } + customresource.SetFinalizer(deployment, customresource.FinalizerLabel) + require.NoError(t, k8sclient.Create(context.Background(), deployment)) + + prevResult := testPrevResult() + conn := testConnection() + atlasClient := testAtlasClient(t, conn, rt) + return &testDeploymentEnv{ + reconciler: r, + reconciliation: testReconciliation(r, atlasClient, deployment, prevResult), + deployment: deployment, + project: project, + } +} + +type testDeploymentEnv struct { + reconciler *AtlasDeploymentReconciler + reconciliation *reconciliation + project *v1.AtlasProject + deployment *v1.AtlasDeployment +} + +type deploymentDeletionRoundTripper struct { + called bool +} + +func testDeploymentDeletionRoundTripper() *deploymentDeletionRoundTripper { + return &deploymentDeletionRoundTripper{} +} + +func (rt *deploymentDeletionRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + expectedPath := fmt.Sprintf("/%s/api/atlas/v1.5/groups/%s/clusters/cluster-basics", fakeDomain, fakeProjectID) + if req.Method == http.MethodDelete && req.URL.Path == expectedPath { + rt.called = true + rsp := responseFor(req) + rsp.StatusCode = http.StatusNoContent + rsp.Status = http.StatusText(rsp.StatusCode) + return rsp, nil + } + panic(fmt.Sprintf("not implemented for %s path=%q", req.Method, req.URL.Path)) +} + +func responseFor(req *http.Request) *http.Response { + rsp := &http.Response{ + Proto: req.Proto, + ProtoMajor: req.ProtoMajor, + ProtoMinor: req.ProtoMinor, + Request: req, + Header: make(http.Header), + } + return rsp +} + +func addSecretsListSchema(s *runtime.Scheme) { + s.AddKnownTypes(corev1.SchemeGroupVersion, &corev1.SecretList{}) +} + +func addDeploymentSchema(s *runtime.Scheme) { + s.AddKnownTypes(v1.GroupVersion, &v1.AtlasDeployment{}) +} + +func testK8sClient(s *runtime.Scheme) client.Client { + return fake.NewClientBuilder().WithScheme(s).Build() +} + +func testConnection() atlas.Connection { + return atlas.Connection{ + OrgID: "unit-test-org", + PublicKey: "publickey", + PrivateKey: "sshhh-secret-unit-test-key", + } +} + +func testLog(t *testing.T) *zap.SugaredLogger { + t.Helper() + + plog, err := zap.NewDevelopment() + require.NoError(t, err) + return plog.Sugar() +} + +func testPrevResult() workflow.Result { + return workflow.Result{}.WithMessage("unchanged") +} + +func testDeploymentReconciler(log *zap.SugaredLogger, k8sclient client.Client, protected bool) *AtlasDeploymentReconciler { + return &AtlasDeploymentReconciler{ + Client: k8sclient, + Log: log, + ObjectDeletionProtection: protected, + } +} + +func testProject(ns string) *v1.AtlasProject { + return &v1.AtlasProject{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-project", + Namespace: ns, + }, + Status: status.AtlasProjectStatus{ + ID: fakeProjectID, + }, + } +} + +func testDeployment(project *v1.AtlasProject) *v1.AtlasDeployment { + return &v1.AtlasDeployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: "TestDeployment", + Namespace: project.Namespace, + }, + Spec: v1.AtlasDeploymentSpec{ + Project: common.ResourceRefNamespaced{ + Name: project.Name, + Namespace: project.Namespace, + }, + DeploymentSpec: &v1.DeploymentSpec{ + Name: "cluster-basics", + ProviderSettings: &v1.ProviderSettingsSpec{ + InstanceSizeName: "M2", + ProviderName: "TENANT", + RegionName: "US_EAST_1", + BackingProviderName: "AWS", + }, + }, + }, + } +} + +func testReconciliation(r *AtlasDeploymentReconciler, atlasClient mongodbatlas.Client, deployment *v1.AtlasDeployment, prevResult workflow.Result) *reconciliation { + log := r.Log.With("atlasdeployment", "test-namespace") + rc := &reconciliation{ + reconciler: r, + log: log, + context: context.Background(), + workflowCtx: customresource.MarkReconciliationStarted(r.Client, deployment, log), + prevResult: prevResult, + } + rc.workflowCtx.Client = atlasClient + return rc +} + +func testAtlasClient(t *testing.T, connection atlas.Connection, rt http.RoundTripper) mongodbatlas.Client { + t.Helper() + + client, err := atlas.Client(fakeDomain, connection, nil, httputil.CustomTransport(rt)) + require.NoError(t, err) + return client +} diff --git a/pkg/controller/customresource/customresource.go b/pkg/controller/customresource/customresource.go index c23ff42acd..76b6722316 100644 --- a/pkg/controller/customresource/customresource.go +++ b/pkg/controller/customresource/customresource.go @@ -135,3 +135,13 @@ func ReconciliationShouldBeSkipped(resource mdbv1.AtlasCustomResource) bool { } return false } + +// SetAnnotation sets an annotation in resource while respecting the rest of annotations. +func SetAnnotation(resource mdbv1.AtlasCustomResource, key, value string) { + annot := resource.GetAnnotations() + if annot == nil { + annot = map[string]string{} + } + annot[key] = value + resource.SetAnnotations(annot) +} diff --git a/pkg/controller/customresource/protection_test.go b/pkg/controller/customresource/protection_test.go new file mode 100644 index 0000000000..77eb564aad --- /dev/null +++ b/pkg/controller/customresource/protection_test.go @@ -0,0 +1,149 @@ +package customresource_test + +import ( + "context" + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + mdbv1 "github.com/mongodb/mongodb-atlas-kubernetes/pkg/api/v1" + "github.com/mongodb/mongodb-atlas-kubernetes/pkg/controller/customresource" +) + +func sampleResource() *mdbv1.AtlasDatabaseUser { + return &mdbv1.AtlasDatabaseUser{ + Spec: mdbv1.AtlasDatabaseUserSpec{}, + } +} + +func taggedResource(tag, value string) *mdbv1.AtlasDatabaseUser { + dbUser := sampleResource() + annot := map[string]string{} + annot[tag] = value + dbUser.SetAnnotations(annot) + return dbUser +} + +func testOpChecker(reply bool) customresource.OperatorChecker { + return func(resource mdbv1.AtlasCustomResource) (bool, error) { + return reply, nil + } +} + +func testAtlasChecker(reply bool) customresource.AtlasChecker { + return func(resource mdbv1.AtlasCustomResource) (bool, error) { + return reply, nil + } +} + +var ErrOpChecker = fmt.Errorf("operator checker failed") + +func failedOpChecker(err error) customresource.OperatorChecker { + return func(resource mdbv1.AtlasCustomResource) (bool, error) { + return false, err + } +} + +var ErrAtlasChecker = fmt.Errorf("atlas checker failed") + +func failedAtlasChecker(err error) customresource.AtlasChecker { + return func(resource mdbv1.AtlasCustomResource) (bool, error) { + return false, err + } +} + +func TestWithoutProtectionIsOwned(t *testing.T) { + owned, err := customresource.IsOwner(sampleResource(), false, nil, nil) + assert.NoError(t, err) + assert.Equal(t, owned, true) +} + +func TestProtected(t *testing.T) { + tests := []struct { + title string + opChecker customresource.OperatorChecker + atlasChecker customresource.AtlasChecker + expectedOwned bool + }{ + {"managed is owned", testOpChecker(true), nil, true}, + {"unmanaged but not in Atlas is owned", testOpChecker(false), testAtlasChecker(false), true}, + {"unmanaged but in Atlas is NOT owned", testOpChecker(false), testAtlasChecker(true), false}, + } + for _, tc := range tests { + t.Run(fmt.Sprintf("Protected and %s", tc.title), func(t *testing.T) { + owned, err := customresource.IsOwner(sampleResource(), true, tc.opChecker, tc.atlasChecker) + assert.NoError(t, err) + assert.Equal(t, tc.expectedOwned, owned) + }) + } +} + +func TestProtectedFailures(t *testing.T) { + tests := []struct { + title string + opChecker customresource.OperatorChecker + atlasChecker customresource.AtlasChecker + expectedFailure error + }{ + {"When all checkers fail, operator checker fails first", failedOpChecker(ErrOpChecker), failedAtlasChecker(ErrAtlasChecker), ErrOpChecker}, + {"When unamanaged and atlas checker fails we get that its failure", testOpChecker(false), failedAtlasChecker(ErrAtlasChecker), ErrAtlasChecker}, + } + for _, tc := range tests { + t.Run(tc.title, func(t *testing.T) { + _, err := customresource.IsOwner(sampleResource(), true, tc.opChecker, tc.atlasChecker) + assert.Equal(t, tc.expectedFailure, err) + }) + } +} + +func TestIsResourceProtected(t *testing.T) { + tests := []struct { + title string + protectionFlag bool + resource mdbv1.AtlasCustomResource + expectedProtected bool + }{ + {"Resource without tags with the flag set is protected", true, sampleResource(), true}, + {"Resource without tags with the flag unset isn't protected", false, sampleResource(), false}, + { + "Resource with keep tag is protected", + false, + taggedResource(customresource.ResourcePolicyAnnotation, customresource.ResourcePolicyKeep), + true, + }, + { + "Resource with delete tag and protected flag set is NOT protected", + true, + taggedResource(customresource.ResourcePolicyAnnotation, customresource.ResourcePolicyDelete), + false, + }, + { + "Resource with delete tag and protected flag unset isn't protected", + false, + taggedResource(customresource.ResourcePolicyAnnotation, customresource.ResourcePolicyDelete), + false, + }, + } + for _, tc := range tests { + t.Run(tc.title, func(t *testing.T) { + assert.Equal(t, tc.expectedProtected, customresource.IsResourceProtected(tc.resource, tc.protectionFlag)) + }) + } +} + +func TestApplyLastConfigApplied(t *testing.T) { + resource := sampleResource() + resource.Spec.Username = "test-user" + + // ignore the error due to not configuring the fake client + // we are not checking that, we are only interested on a new annotation in resource + _ = customresource.ApplyLastConfigApplied(context.Background(), resource, fake.NewClientBuilder().Build()) + + annot := resource.GetAnnotations() + assert.NotEmpty(t, annot) + expectedConfig := `{"projectRef":{"name":"","namespace":""},"roles":null,"username":"test-user"}` + assert.Equal(t, annot[customresource.AnnotationLastAppliedConfiguration], expectedConfig) +} diff --git a/test/int/databaseuser_protected_test.go b/test/int/databaseuser_protected_test.go index d910da7f14..a51981f31f 100644 --- a/test/int/databaseuser_protected_test.go +++ b/test/int/databaseuser_protected_test.go @@ -58,6 +58,11 @@ var _ = Describe("Atlas Database User", Label("int", "AtlasDatabaseUser", "prote By("Creating a deployment", func() { testDeployment = mdbv1.DefaultAWSDeployment(testNamespace.Name, projectName).Lightweight() + customresource.SetAnnotation( // this test deployment must be deleted + testDeployment, + customresource.ResourcePolicyAnnotation, + customresource.ResourcePolicyDelete, + ) Expect(k8sClient.Create(context.TODO(), testDeployment)).To(Succeed()) Eventually(func() bool { diff --git a/test/int/deployment_protected_test.go b/test/int/deployment_protected_test.go new file mode 100644 index 0000000000..8db1825ebe --- /dev/null +++ b/test/int/deployment_protected_test.go @@ -0,0 +1,104 @@ +package int + +import ( + "context" + "fmt" + "time" + + mdbv1 "github.com/mongodb/mongodb-atlas-kubernetes/pkg/api/v1" + "github.com/mongodb/mongodb-atlas-kubernetes/pkg/api/v1/project" + "github.com/mongodb/mongodb-atlas-kubernetes/pkg/api/v1/status" + "github.com/mongodb/mongodb-atlas-kubernetes/pkg/util/kube" + "github.com/mongodb/mongodb-atlas-kubernetes/pkg/util/testutil" + + k8serrors "k8s.io/apimachinery/pkg/api/errors" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +var _ = Describe("AtlasDeployment Deletion", Label("AtlasDeployment", "deletion-protection", "deployment-deletion-protected"), func() { + var testNamespace *corev1.Namespace + var stopManager context.CancelFunc + var connectionSecret corev1.Secret + var testProject *mdbv1.AtlasProject + + // nolint:dupl + BeforeEach(func() { + By("Starting the operator with protection ON", func() { + testNamespace, stopManager = prepareControllers(true) + Expect(testNamespace).ToNot(BeNil()) + Expect(stopManager).ToNot(BeNil()) + }) + + By("Creating project connection secret", func() { + connectionSecret = buildConnectionSecret(fmt.Sprintf("%s-atlas-key", testNamespace.Name)) + Expect(k8sClient.Create(context.Background(), &connectionSecret)).To(Succeed()) + }) + + By("Creating a project", func() { + testProject = mdbv1.DefaultProject(testNamespace.Name, connectionSecret.Name).WithIPAccessList(project.NewIPAccessList().WithCIDR("0.0.0.0/0")) + Expect(k8sClient.Create(context.TODO(), testProject, &client.CreateOptions{})).To(Succeed()) + + Eventually(func() bool { + return testutil.CheckCondition(k8sClient, testProject, status.TrueCondition(status.ReadyType)) + }).WithTimeout(3 * time.Minute).WithPolling(PollingInterval).Should(BeTrue()) + }) + }) + + // nolint:dupl + AfterEach(func() { + By("Deleting project from k8s and atlas", func() { + Expect(k8sClient.Delete(context.TODO(), testProject, &client.DeleteOptions{})).To(Succeed()) + Eventually( + checkAtlasProjectRemoved(testProject.Status.ID), + ).WithTimeout(3 * time.Minute).WithPolling(PollingInterval).Should(BeTrue()) + }) + + By("Deleting project connection secret", func() { + Expect(k8sClient.Delete(context.Background(), &connectionSecret)).To(Succeed()) + }) + + By("Stopping the operator", func() { + stopManager() + err := k8sClient.Delete(context.Background(), testNamespace) + Expect(err).ToNot(HaveOccurred()) + }) + }) + + It("removing from Kubernetes when protection is ON leaves the deployment in Atlas", func() { + testDeployment := &mdbv1.AtlasDeployment{} + + By("Creating a deployment in the cluster with annotation set to delete", func() { + testDeployment = mdbv1.DefaultAWSDeployment(testNamespace.Name, testProject.Name).Lightweight() + Expect(k8sClient.Create(context.TODO(), testDeployment, &client.CreateOptions{})).To(Succeed()) + }) + + By("Waiting the deployment to settle in kubernetes", func() { + Eventually(func(g Gomega) bool { + return testutil.CheckCondition(k8sClient, testDeployment, status.TrueCondition(status.ReadyType), validateDeploymentUpdatingFunc(g)) + }).WithTimeout(30 * time.Minute).WithPolling(PollingInterval).Should(BeTrue()) + }) + + By("Deleting the deployment from Kubernetes", func() { + Expect(k8sClient.Delete(context.TODO(), testDeployment, &client.DeleteOptions{})).To(Succeed()) + Eventually(func() bool { + deployment := mdbv1.AtlasDeployment{} + err := k8sClient.Get(context.TODO(), kube.ObjectKey(testNamespace.Name, testDeployment.Name), &deployment, &client.GetOptions{}) + return k8serrors.IsNotFound(err) + }).WithTimeout(5 * time.Minute).WithPolling(PollingInterval).Should(BeTrue()) + }) + + By("Checking the Atlas deployment was NOT removed", func() { + deploymentAtlasName := testDeployment.Spec.DeploymentSpec.Name + Expect(checkAtlasDeploymentRemoved(testProject.Status.ID, deploymentAtlasName)()).To(BeFalse()) + }) + + By("Making sure deployment gets removed from Atlas manually", func() { + deploymentAtlasName := testDeployment.Spec.DeploymentSpec.Name + Expect(deleteAtlasDeployment(testProject.Status.ID, deploymentAtlasName)).ToNot(HaveOccurred()) + }) + }) +}) diff --git a/test/int/deployment_unprotected_test.go b/test/int/deployment_unprotected_test.go new file mode 100644 index 0000000000..e2c34d4947 --- /dev/null +++ b/test/int/deployment_unprotected_test.go @@ -0,0 +1,101 @@ +package int + +import ( + "context" + "fmt" + "time" + + mdbv1 "github.com/mongodb/mongodb-atlas-kubernetes/pkg/api/v1" + "github.com/mongodb/mongodb-atlas-kubernetes/pkg/api/v1/project" + "github.com/mongodb/mongodb-atlas-kubernetes/pkg/api/v1/status" + "github.com/mongodb/mongodb-atlas-kubernetes/pkg/util/kube" + "github.com/mongodb/mongodb-atlas-kubernetes/pkg/util/testutil" + + k8serrors "k8s.io/apimachinery/pkg/api/errors" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +var _ = Describe("AtlasDeployment Deletion", Label("AtlasDeployment", "deletion-protection", "deployment-deletion-unprotected"), func() { + var testNamespace *corev1.Namespace + var stopManager context.CancelFunc + var connectionSecret corev1.Secret + var testProject *mdbv1.AtlasProject + + // nolint:dupl + BeforeEach(func() { + By("Starting the operator with protection OFF", func() { + testNamespace, stopManager = prepareControllers(false) + Expect(testNamespace).ToNot(BeNil()) + Expect(stopManager).ToNot(BeNil()) + }) + + By("Creating project connection secret", func() { + connectionSecret = buildConnectionSecret(fmt.Sprintf("%s-atlas-key", testNamespace.Name)) + Expect(k8sClient.Create(context.Background(), &connectionSecret)).To(Succeed()) + }) + + By("Creating a project", func() { + testProject = mdbv1.DefaultProject(testNamespace.Name, connectionSecret.Name).WithIPAccessList(project.NewIPAccessList().WithCIDR("0.0.0.0/0")) + Expect(k8sClient.Create(context.TODO(), testProject, &client.CreateOptions{})).To(Succeed()) + + Eventually(func() bool { + return testutil.CheckCondition(k8sClient, testProject, status.TrueCondition(status.ReadyType)) + }).WithTimeout(3 * time.Minute).WithPolling(PollingInterval).Should(BeTrue()) + }) + }) + + // nolint:dupl + AfterEach(func() { + By("Deleting project from k8s and atlas", func() { + Expect(k8sClient.Delete(context.TODO(), testProject, &client.DeleteOptions{})).To(Succeed()) + Eventually( + checkAtlasProjectRemoved(testProject.Status.ID), + ).WithTimeout(3 * time.Minute).WithPolling(PollingInterval).Should(BeTrue()) + }) + + By("Deleting project connection secret", func() { + Expect(k8sClient.Delete(context.Background(), &connectionSecret)).To(Succeed()) + }) + + By("Stopping the operator", func() { + stopManager() + err := k8sClient.Delete(context.Background(), testNamespace) + Expect(err).ToNot(HaveOccurred()) + }) + }) + + It("removing from Kubernetes when protection is ON leaves the deployment in Atlas", func() { + testDeployment := &mdbv1.AtlasDeployment{} + + By("Creating a deployment in the cluster with annotation set to delete", func() { + testDeployment = mdbv1.DefaultAWSDeployment(testNamespace.Name, testProject.Name).Lightweight() + Expect(k8sClient.Create(context.TODO(), testDeployment, &client.CreateOptions{})).To(Succeed()) + }) + + By("Waiting the deployment to settle in kubernetes", func() { + Eventually(func(g Gomega) bool { + return testutil.CheckCondition(k8sClient, testDeployment, status.TrueCondition(status.ReadyType), validateDeploymentUpdatingFunc(g)) + }).WithTimeout(30 * time.Minute).WithPolling(PollingInterval).Should(BeTrue()) + }) + + By("Deleting the deployment from Kubernetes", func() { + Expect(k8sClient.Delete(context.TODO(), testDeployment, &client.DeleteOptions{})).To(Succeed()) + Eventually(func() bool { + deployment := mdbv1.AtlasDeployment{} + err := k8sClient.Get(context.TODO(), kube.ObjectKey(testNamespace.Name, testDeployment.Name), &deployment, &client.GetOptions{}) + return k8serrors.IsNotFound(err) + }).WithTimeout(2 * time.Minute).WithPolling(PollingInterval).Should(BeTrue()) + }) + + By("Checking whether the Atlas deployment got also removed", func() { + deploymentAtlasName := testDeployment.Spec.DeploymentSpec.Name + Eventually( + checkAtlasDeploymentRemoved(testProject.Status.ID, deploymentAtlasName), + ).WithTimeout(5 * time.Minute).WithPolling(PollingInterval).Should(BeTrue()) + }) + }) +}) diff --git a/test/int/integration_suite_test.go b/test/int/integration_suite_test.go index 0ad6fe1daa..76fe33eb96 100644 --- a/test/int/integration_suite_test.go +++ b/test/int/integration_suite_test.go @@ -230,13 +230,15 @@ func prepareControllers(deletionProtection bool) (*corev1.Namespace, context.Can Expect(err).ToNot(HaveOccurred()) err = (&atlasdeployment.AtlasDeploymentReconciler{ - Client: k8sManager.GetClient(), - Log: logger.Named("controllers").Named("AtlasDeployment").Sugar(), - AtlasDomain: atlasDomain, - ResourceWatcher: watch.NewResourceWatcher(), - GlobalAPISecret: kube.ObjectKey(namespace.Name, "atlas-operator-api-key"), - GlobalPredicates: globalPredicates, - EventRecorder: k8sManager.GetEventRecorderFor("AtlasDeployment"), + Client: k8sManager.GetClient(), + Log: logger.Named("controllers").Named("AtlasDeployment").Sugar(), + AtlasDomain: atlasDomain, + ResourceWatcher: watch.NewResourceWatcher(), + GlobalAPISecret: kube.ObjectKey(namespace.Name, "atlas-operator-api-key"), + GlobalPredicates: globalPredicates, + EventRecorder: k8sManager.GetEventRecorderFor("AtlasDeployment"), + ObjectDeletionProtection: deletionProtection, + SubObjectDeletionProtection: deletionProtection, }).SetupWithManager(k8sManager) Expect(err).ToNot(HaveOccurred())