From 510ca16963ea019040ef7197b1008b6c11e6de1a Mon Sep 17 00:00:00 2001 From: Helder Santana Date: Fri, 14 Jul 2023 18:33:06 +0200 Subject: [PATCH] CLOUDP-175080: Support deletion protection for Atlas projects (#1028) --- cmd/manager/main.go | 18 +- helm-charts | 2 +- pkg/api/v1/atlasproject_types.go | 4 +- pkg/api/v1/zz_generated.deepcopy.go | 3 +- .../atlasdatabaseuser_controller.go | 15 +- .../atlasproject/atlasproject_controller.go | 209 ++++++++++-------- pkg/controller/customresource/protection.go | 11 + test/e2e/cli/helm/helm.go | 8 + test/e2e/helm_chart_test.go | 10 +- test/int/databaseuser_protected_test.go | 3 + test/int/integration_suite_test.go | 16 +- test/int/project_protect_test.go | 160 ++++++++++++++ 12 files changed, 325 insertions(+), 134 deletions(-) create mode 100644 test/int/project_protect_test.go diff --git a/cmd/manager/main.go b/cmd/manager/main.go index 940b3eb671..90446db8ad 100644 --- a/cmd/manager/main.go +++ b/cmd/manager/main.go @@ -147,14 +147,16 @@ func main() { } if err = (&atlasproject.AtlasProjectReconciler{ - Client: mgr.GetClient(), - Log: logger.Named("controllers").Named("AtlasProject").Sugar(), - Scheme: mgr.GetScheme(), - AtlasDomain: config.AtlasDomain, - ResourceWatcher: watch.NewResourceWatcher(), - GlobalAPISecret: config.GlobalAPISecret, - GlobalPredicates: globalPredicates, - EventRecorder: mgr.GetEventRecorderFor("AtlasProject"), + Client: mgr.GetClient(), + Log: logger.Named("controllers").Named("AtlasProject").Sugar(), + Scheme: mgr.GetScheme(), + AtlasDomain: config.AtlasDomain, + ResourceWatcher: watch.NewResourceWatcher(), + GlobalAPISecret: config.GlobalAPISecret, + GlobalPredicates: globalPredicates, + EventRecorder: mgr.GetEventRecorderFor("AtlasProject"), + ObjectDeletionProtection: config.ObjectDeletionProtection, + SubObjectDeletionProtection: config.SubObjectDeletionProtection, }).SetupWithManager(mgr); err != nil { setupLog.Error(err, "unable to create controller", "controller", "AtlasProject") os.Exit(1) diff --git a/helm-charts b/helm-charts index 50bd0ec1a6..35843312f5 160000 --- a/helm-charts +++ b/helm-charts @@ -1 +1 @@ -Subproject commit 50bd0ec1a6a148a63cb98e3f4d7377d111a0f1c8 +Subproject commit 35843312f561b4413fb01424c674593270bcb942 diff --git a/pkg/api/v1/atlasproject_types.go b/pkg/api/v1/atlasproject_types.go index fc2feebacb..8265ab6ff0 100644 --- a/pkg/api/v1/atlasproject_types.go +++ b/pkg/api/v1/atlasproject_types.go @@ -226,8 +226,8 @@ func (p *AtlasProject) WithLabels(labels map[string]string) *AtlasProject { return p } -func (p *AtlasProject) WithAnnotations(labels map[string]string) *AtlasProject { - p.Labels = labels +func (p *AtlasProject) WithAnnotations(annotations map[string]string) *AtlasProject { + p.Annotations = annotations return p } diff --git a/pkg/api/v1/zz_generated.deepcopy.go b/pkg/api/v1/zz_generated.deepcopy.go index 70057acb98..e9496821a6 100644 --- a/pkg/api/v1/zz_generated.deepcopy.go +++ b/pkg/api/v1/zz_generated.deepcopy.go @@ -14,10 +14,9 @@ a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 package v1 import ( - "k8s.io/apimachinery/pkg/runtime" - "github.com/mongodb/mongodb-atlas-kubernetes/pkg/api/v1/common" "github.com/mongodb/mongodb-atlas-kubernetes/pkg/api/v1/project" + "k8s.io/apimachinery/pkg/runtime" ) // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. diff --git a/pkg/controller/atlasdatabaseuser/atlasdatabaseuser_controller.go b/pkg/controller/atlasdatabaseuser/atlasdatabaseuser_controller.go index b08431061f..93c9302ceb 100644 --- a/pkg/controller/atlasdatabaseuser/atlasdatabaseuser_controller.go +++ b/pkg/controller/atlasdatabaseuser/atlasdatabaseuser_controller.go @@ -131,7 +131,7 @@ func (r *AtlasDatabaseUserReconciler) Reconcile(ctx context.Context, req ctrl.Re } workflowCtx.Client = atlasClient - owner, err := customresource.IsOwner(databaseUser, r.ObjectDeletionProtection, managedByOperator(), managedByAtlas(ctx, atlasClient, project.ID(), log)) + owner, err := customresource.IsOwner(databaseUser, r.ObjectDeletionProtection, customresource.IsResourceManagedByOperator, managedByAtlas(ctx, atlasClient, project.ID(), log)) if err != nil { result = workflow.Terminate(workflow.Internal, fmt.Sprintf("enable to resolve ownership for deletion protection: %s", err)) workflowCtx.SetConditionFromResult(status.DatabaseUserReadyType, result) @@ -274,16 +274,3 @@ func managedByAtlas(ctx context.Context, atlasClient mongodbatlas.Client, projec return !isSame, nil } } - -func managedByOperator() customresource.OperatorChecker { - return func(resource mdbv1.AtlasCustomResource) (bool, error) { - annotations := resource.GetAnnotations() - if annotations == nil { - return false, nil - } - - _, ok := annotations[customresource.AnnotationLastAppliedConfiguration] - - return ok, nil - } -} diff --git a/pkg/controller/atlasproject/atlasproject_controller.go b/pkg/controller/atlasproject/atlasproject_controller.go index 113b1a3fd7..b8d7a97053 100644 --- a/pkg/controller/atlasproject/atlasproject_controller.go +++ b/pkg/controller/atlasproject/atlasproject_controller.go @@ -21,6 +21,8 @@ import ( "errors" "fmt" + "sigs.k8s.io/controller-runtime/pkg/builder" + "github.com/mongodb/mongodb-atlas-kubernetes/pkg/controller/validate" "go.mongodb.org/atlas/mongodbatlas" @@ -31,8 +33,6 @@ import ( "k8s.io/client-go/tools/record" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/controller" - "sigs.k8s.io/controller-runtime/pkg/handler" "sigs.k8s.io/controller-runtime/pkg/predicate" "sigs.k8s.io/controller-runtime/pkg/source" @@ -51,12 +51,14 @@ import ( type AtlasProjectReconciler struct { Client client.Client watch.ResourceWatcher - Log *zap.SugaredLogger - Scheme *runtime.Scheme - AtlasDomain string - GlobalAPISecret client.ObjectKey - GlobalPredicates []predicate.Predicate - EventRecorder record.EventRecorder + Log *zap.SugaredLogger + Scheme *runtime.Scheme + AtlasDomain string + GlobalAPISecret client.ObjectKey + GlobalPredicates []predicate.Predicate + EventRecorder record.EventRecorder + ObjectDeletionProtection bool + SubObjectDeletionProtection bool } // Dev note: duplicate the permissions in both sections below to generate both Role and ClusterRoles @@ -76,8 +78,7 @@ type AtlasProjectReconciler struct { // +kubebuilder:rbac:groups=atlas.mongodb.com,namespace=default,resources=atlasteams,verbs=get;list;watch;create;update;patch;delete // +kubebuilder:rbac:groups=atlas.mongodb.com,namespace=default,resources=atlasteams/status,verbs=get;update;patch -func (r *AtlasProjectReconciler) Reconcile(context context.Context, req ctrl.Request) (ctrl.Result, error) { - _ = context +func (r *AtlasProjectReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { log := r.Log.With("atlasproject", req.NamespacedName) project := &mdbv1.AtlasProject{} @@ -86,10 +87,10 @@ func (r *AtlasProjectReconciler) Reconcile(context context.Context, req ctrl.Req return result.ReconcileResult(), nil } - if shouldSkip := customresource.ReconciliationShouldBeSkipped(project); shouldSkip { + if customresource.ReconciliationShouldBeSkipped(project) { log.Infow(fmt.Sprintf("-> Skipping AtlasProject reconciliation as annotation %s=%s", customresource.ReconciliationPolicyAnnotation, customresource.ReconciliationPolicySkip), "spec", project.Spec) if !project.GetDeletionTimestamp().IsZero() { - err := r.removeDeletionFinalizer(context, project) + err := customresource.ManageFinalizer(ctx, r.Client, project, customresource.UnsetFinalizer) if err != nil { result = workflow.Terminate(workflow.Internal, err.Error()) log.Errorw("Failed to remove finalizer", "error", err) @@ -99,22 +100,22 @@ func (r *AtlasProjectReconciler) Reconcile(context context.Context, req ctrl.Req return workflow.OK().ReconcileResult(), nil } - ctx := customresource.MarkReconciliationStarted(r.Client, project, log) + workflowCtx := customresource.MarkReconciliationStarted(r.Client, project, log) log.Infow("-> Starting AtlasProject reconciliation", "spec", project.Spec) if project.ConnectionSecretObjectKey() != nil { // Note, that we are not watching the global connection secret - seems there is no point in reconciling all // the projects once that secret is changed - ctx.AddResourcesToWatch(watch.WatchedObject{ResourceKind: "Secret", Resource: *project.ConnectionSecretObjectKey()}) + workflowCtx.AddResourcesToWatch(watch.WatchedObject{ResourceKind: "Secret", Resource: *project.ConnectionSecretObjectKey()}) } // This update will make sure the status is always updated in case of any errors or successful result defer func() { - statushandler.Update(ctx, r.Client, r.EventRecorder, project) - r.EnsureMultiplesResourcesAreWatched(req.NamespacedName, log, ctx.ListResourcesToWatch()...) + statushandler.Update(workflowCtx, r.Client, r.EventRecorder, project) + r.EnsureMultiplesResourcesAreWatched(req.NamespacedName, log, workflowCtx.ListResourcesToWatch()...) }() - resourceVersionIsValid := customresource.ValidateResourceVersion(ctx, project, r.Log) + resourceVersionIsValid := customresource.ValidateResourceVersion(workflowCtx, project, r.Log) if !resourceVersionIsValid.IsOk() { r.Log.Debugf("project validation result: %v", resourceVersionIsValid) return resourceVersionIsValid.ReconcileResult(), nil @@ -122,102 +123,132 @@ func (r *AtlasProjectReconciler) Reconcile(context context.Context, req ctrl.Req if err := validate.Project(project); err != nil { result := workflow.Terminate(workflow.Internal, err.Error()) - setCondition(ctx, status.ValidationSucceeded, result) + setCondition(workflowCtx, status.ValidationSucceeded, result) return result.ReconcileResult(), nil } - ctx.SetConditionTrue(status.ValidationSucceeded) + workflowCtx.SetConditionTrue(status.ValidationSucceeded) connection, err := atlas.ReadConnection(log, r.Client, r.GlobalAPISecret, project.ConnectionSecretObjectKey()) if err != nil { result = workflow.Terminate(workflow.AtlasCredentialsNotProvided, err.Error()) - setCondition(ctx, status.ProjectReadyType, result) - if errRm := r.removeDeletionFinalizer(context, project); errRm != nil { + setCondition(workflowCtx, status.ProjectReadyType, result) + if errRm := customresource.ManageFinalizer(ctx, r.Client, project, customresource.UnsetFinalizer); errRm != nil { result = workflow.Terminate(workflow.Internal, errRm.Error()) return result.ReconcileResult(), nil } return result.ReconcileResult(), nil } - ctx.Connection = connection + workflowCtx.Connection = connection atlasClient, err := atlas.Client(r.AtlasDomain, connection, log) if err != nil { result := workflow.Terminate(workflow.Internal, err.Error()) - setCondition(ctx, status.DeploymentReadyType, result) + setCondition(workflowCtx, status.DeploymentReadyType, result) + return result.ReconcileResult(), nil + } + workflowCtx.Client = atlasClient + + owner, err := customresource.IsOwner(project, r.ObjectDeletionProtection, customresource.IsResourceManagedByOperator, managedByAtlas(ctx, atlasClient)) + if err != nil { + result = workflow.Terminate(workflow.Internal, fmt.Sprintf("enable to resolve ownership for deletion protection: %s", err)) + workflowCtx.SetConditionFromResult(status.DatabaseUserReadyType, result) + log.Error(result.GetMessage()) + + return result.ReconcileResult(), nil + } + + if !owner { + result = workflow.Terminate( + workflow.AtlasDeletionProtection, + "unable to reconcile project: it already exists in Atlas, it was not previously managed by the operator, and the deletion protection is enabled.", + ) + workflowCtx.SetConditionFromResult(status.ProjectReadyType, result) + log.Error(result.GetMessage()) + + return result.ReconcileResult(), nil + } + + err = customresource.ApplyLastConfigApplied(ctx, project, r.Client) + if err != nil { + result = workflow.Terminate(workflow.Internal, err.Error()) + workflowCtx.SetConditionFromResult(status.ProjectReadyType, result) + log.Error(result.GetMessage()) + return result.ReconcileResult(), nil } - ctx.Client = atlasClient var projectID string - if projectID, result = r.ensureProjectExists(ctx, project); !result.IsOk() { - setCondition(ctx, status.ProjectReadyType, result) + if projectID, result = r.ensureProjectExists(workflowCtx, project); !result.IsOk() { + setCondition(workflowCtx, status.ProjectReadyType, result) return result.ReconcileResult(), nil } - ctx.EnsureStatusOption(status.AtlasProjectIDOption(projectID)) + workflowCtx.EnsureStatusOption(status.AtlasProjectIDOption(projectID)) - if result := r.ensureDeletionFinalizer(ctx, atlasClient, projectID, project, context); !result.IsOk() { - setCondition(ctx, status.ProjectReadyType, result) + if result := r.ensureDeletionFinalizer(ctx, workflowCtx, atlasClient, project); !result.IsOk() { + setCondition(workflowCtx, status.ProjectReadyType, result) return result.ReconcileResult(), nil } var authModes authmode.AuthModes - if authModes, result = r.ensureX509(ctx, projectID, project); !result.IsOk() { - setCondition(ctx, status.ProjectReadyType, result) + if authModes, result = r.ensureX509(workflowCtx, projectID, project); !result.IsOk() { + setCondition(workflowCtx, status.ProjectReadyType, result) return result.ReconcileResult(), nil } authModes.AddAuthMode(authmode.Scram) // add the default auth method - ctx.EnsureStatusOption(status.AtlasProjectAuthModesOption(authModes)) + workflowCtx.EnsureStatusOption(status.AtlasProjectAuthModesOption(authModes)) // Updating the status with "projectReady = true" and "IPAccessListReady = false" (not as separate updates!) - ctx.SetConditionTrue(status.ProjectReadyType) + workflowCtx.SetConditionTrue(status.ProjectReadyType) r.EventRecorder.Event(project, "Normal", string(status.ProjectReadyType), "") - results := r.ensureProjectResources(ctx, projectID, project, context) + results := r.ensureProjectResources(workflowCtx, projectID, project, ctx) for i := range results { if !results[i].IsOk() { - logIfWarning(ctx, result) + logIfWarning(workflowCtx, result) return results[i].ReconcileResult(), nil } } - ctx.SetConditionTrue(status.ReadyType) + workflowCtx.SetConditionTrue(status.ReadyType) return workflow.OK().ReconcileResult(), nil } -func (r *AtlasProjectReconciler) ensureDeletionFinalizer(ctx *workflow.Context, atlasClient mongodbatlas.Client, projectID string, project *mdbv1.AtlasProject, context context.Context) (result workflow.Result) { - log := ctx.Log +func (r *AtlasProjectReconciler) ensureDeletionFinalizer(ctx context.Context, workflowCtx *workflow.Context, atlasClient mongodbatlas.Client, project *mdbv1.AtlasProject) (result workflow.Result) { + log := workflowCtx.Log if project.GetDeletionTimestamp().IsZero() { if !customresource.HaveFinalizer(project, customresource.FinalizerLabel) { log.Debugw("Add deletion finalizer", "name", customresource.FinalizerLabel) - if err := r.addDeletionFinalizer(context, project); err != nil { - return workflow.Terminate(workflow.Internal, err.Error()) + if err := customresource.ManageFinalizer(ctx, r.Client, project, customresource.SetFinalizer); err != nil { + return workflow.Terminate(workflow.AtlasFinalizerNotSet, err.Error()) } } } if !project.GetDeletionTimestamp().IsZero() { if customresource.HaveFinalizer(project, customresource.FinalizerLabel) { - if customresource.ResourceShouldBeLeftInAtlas(project) { - log.Infof("Not removing the Atlas Project from Atlas as the '%s' annotation is set", customresource.ResourcePolicyAnnotation) + log.Infow("RESOURCE PROTECTED", r.ObjectDeletionProtection, customresource.IsResourceProtected(project, r.ObjectDeletionProtection)) + if customresource.IsResourceProtected(project, r.ObjectDeletionProtection) { + log.Info("Not removing Atlas database user from Atlas as per configuration") } else { - if result = DeleteAllPrivateEndpoints(ctx, projectID); !result.IsOk() { - setCondition(ctx, status.PrivateEndpointReadyType, result) + if result = DeleteAllPrivateEndpoints(workflowCtx, project.ID()); !result.IsOk() { + setCondition(workflowCtx, status.PrivateEndpointReadyType, result) return result } - if result = DeleteAllNetworkPeers(context, projectID, ctx.Client.Peers, ctx.Log); !result.IsOk() { - setCondition(ctx, status.NetworkPeerReadyType, result) + if result = DeleteAllNetworkPeers(ctx, project.ID(), workflowCtx.Client.Peers, workflowCtx.Log); !result.IsOk() { + setCondition(workflowCtx, status.NetworkPeerReadyType, result) return result } - if err := r.deleteAtlasProject(context, atlasClient, project); err != nil { + if err := r.deleteAtlasProject(ctx, atlasClient, project); err != nil { result = workflow.Terminate(workflow.Internal, err.Error()) - setCondition(ctx, status.DeploymentReadyType, result) + setCondition(workflowCtx, status.DeploymentReadyType, result) return result } } - if err := r.removeDeletionFinalizer(context, project); err != nil { - return workflow.Terminate(workflow.Internal, err.Error()) + if err := customresource.ManageFinalizer(ctx, r.Client, project, customresource.UnsetFinalizer); err != nil { + return workflow.Terminate(workflow.AtlasFinalizerNotRemoved, err.Error()) } } return result @@ -307,53 +338,12 @@ func (r *AtlasProjectReconciler) deleteAtlasProject(ctx context.Context, atlasCl } func (r *AtlasProjectReconciler) SetupWithManager(mgr ctrl.Manager) error { - c, err := controller.New("AtlasProject", mgr, controller.Options{Reconciler: r}) - if err != nil { - return err - } - - // Watch for changes to primary resource AtlasProject & handle delete separately - err = c.Watch(&source.Kind{Type: &mdbv1.AtlasProject{}}, &handler.EnqueueRequestForObject{}, r.GlobalPredicates...) - if err != nil { - return err - } - - // Watch for Connection Secrets - err = c.Watch(&source.Kind{Type: &corev1.Secret{}}, watch.NewSecretHandler(r.WatchedResources)) - if err != nil { - return err - } - - err = c.Watch(&source.Kind{Type: &mdbv1.AtlasTeam{}}, watch.NewAtlasTeamHandler(r.WatchedResources)) - if err != nil { - return err - } - - return nil -} - -func (r *AtlasProjectReconciler) addDeletionFinalizer(ctx context.Context, p *mdbv1.AtlasProject) error { - err := r.Client.Get(ctx, kube.ObjectKeyFromObject(p), p) - if err != nil { - return fmt.Errorf("failed to get project before adding deletion finalizer: %w", err) - } - customresource.SetFinalizer(p, customresource.FinalizerLabel) - if err := r.Client.Update(ctx, p); err != nil { - return fmt.Errorf("failed to add deletion finalizer for %s: %w", p.Name, err) - } - return nil -} - -func (r *AtlasProjectReconciler) removeDeletionFinalizer(ctx context.Context, p *mdbv1.AtlasProject) error { - err := r.Client.Get(ctx, kube.ObjectKeyFromObject(p), p) - if err != nil { - return fmt.Errorf("failed to get project before removing deletion finalizer: %w", err) - } - customresource.UnsetFinalizer(p, customresource.FinalizerLabel) - if err = r.Client.Update(ctx, p); err != nil { - return fmt.Errorf("failed to remove deletion finalizer from %s: %w", p.Name, err) - } - return nil + return ctrl.NewControllerManagedBy(mgr). + Named("AtlasProject"). + For(&mdbv1.AtlasProject{}, builder.WithPredicates(r.GlobalPredicates...)). + Watches(&source.Kind{Type: &corev1.Secret{}}, watch.NewSecretHandler(r.WatchedResources)). + Watches(&source.Kind{Type: &mdbv1.AtlasTeam{}}, watch.NewAtlasTeamHandler(r.WatchedResources)). + Complete(r) } // setCondition sets the condition from the result and logs the warnings @@ -367,3 +357,28 @@ func logIfWarning(ctx *workflow.Context, result workflow.Result) { ctx.Log.Warnw(result.GetMessage()) } } + +func managedByAtlas(ctx context.Context, atlasClient mongodbatlas.Client) customresource.AtlasChecker { + return func(resource mdbv1.AtlasCustomResource) (bool, error) { + project, ok := resource.(*mdbv1.AtlasProject) + if !ok { + return false, errors.New("failed to match resource type as AtlasProject") + } + + if project.ID() == "" { + return false, nil + } + + _, _, err := atlasClient.Projects.GetOneProject(ctx, project.ID()) + if err != nil { + var apiError *mongodbatlas.ErrorResponse + if errors.As(err, &apiError) && (apiError.ErrorCode == atlas.NotInGroup || apiError.ErrorCode == atlas.ResourceNotFound) { + return false, nil + } + + return false, err + } + + return true, nil + } +} diff --git a/pkg/controller/customresource/protection.go b/pkg/controller/customresource/protection.go index d4b861a51c..a0a7f264e6 100644 --- a/pkg/controller/customresource/protection.go +++ b/pkg/controller/customresource/protection.go @@ -68,3 +68,14 @@ func ApplyLastConfigApplied(ctx context.Context, resource mdbv1.AtlasCustomResou return k8sClient.Update(ctx, resource, &client.UpdateOptions{}) } + +func IsResourceManagedByOperator(resource mdbv1.AtlasCustomResource) (bool, error) { + annotations := resource.GetAnnotations() + if annotations == nil { + return false, nil + } + + _, ok := annotations[AnnotationLastAppliedConfiguration] + + return ok, nil +} diff --git a/test/e2e/cli/helm/helm.go b/test/e2e/cli/helm/helm.go index 9c98d78c92..8a1d4cb53a 100644 --- a/test/e2e/cli/helm/helm.go +++ b/test/e2e/cli/helm/helm.go @@ -107,6 +107,8 @@ func InstallOperatorWideSubmodule(input model.UserInputs) { "atlas-operator-"+input.Project.GetProjectName(), config.AtlasOperatorHelmChartPath, "--set-string", fmt.Sprintf("atlasURI=%s", config.AtlasHost), + "--set", "objectDeletionProtection=false", + "--set", "subobjectDeletionProtection=false", "--set-string", fmt.Sprintf("image.repository=%s", repo), "--set-string", fmt.Sprintf("image.tag=%s", tag), "--namespace", input.Namespace, @@ -122,6 +124,8 @@ func InstallOperatorNamespacedFromLatestRelease(input model.UserInputs) { "mongodb/mongodb-atlas-operator", "--set", fmt.Sprintf("watchNamespaces={%s}", input.Namespace), "--set-string", fmt.Sprintf("atlasURI=%s", config.AtlasHost), + "--set", "objectDeletionProtection=false", + "--set", "subobjectDeletionProtection=false", "--namespace="+input.Namespace, "--create-namespace", ) @@ -141,6 +145,8 @@ func InstallOperatorNamespacedSubmodule(input model.UserInputs) { "--set-string", fmt.Sprintf("image.tag=%s", tag), "--set", fmt.Sprintf("watchNamespaces={%s}", input.Namespace), "--set", "mongodb-atlas-operator-crds.enabled=false", + "--set", "objectDeletionProtection=false", + "--set", "subobjectDeletionProtection=false", "--namespace="+input.Namespace, "--create-namespace", ) @@ -188,6 +194,8 @@ func UpgradeOperatorChart(input model.UserInputs) { "atlas-operator-"+input.Project.GetProjectName(), config.AtlasOperatorHelmChartPath, "--set-string", fmt.Sprintf("atlasURI=%s", config.AtlasHost), + "--set", "objectDeletionProtection=false", + "--set", "subobjectDeletionProtection=false", "--set-string", fmt.Sprintf("image.repository=%s", repo), "--set-string", fmt.Sprintf("image.tag=%s", tag), "-n", input.Namespace, diff --git a/test/e2e/helm_chart_test.go b/test/e2e/helm_chart_test.go index ee0b2fd6a5..5d82f70dd8 100644 --- a/test/e2e/helm_chart_test.go +++ b/test/e2e/helm_chart_test.go @@ -1,6 +1,7 @@ package e2e_test import ( + "context" "encoding/json" "fmt" "os" @@ -297,11 +298,14 @@ func deleteDeploymentAndOperator(data *model.TestDataProvider) { By("Check project, deployment does not exist", func() { helm.Uninstall(data.Resources.Deployments[0].Spec.GetDeploymentName(), data.Resources.Namespace) Eventually( - func(g Gomega) bool { - return atlasClient.IsProjectExists(g, data.Resources.ProjectID) + func(g Gomega) { + if atlasClient.IsProjectExists(g, data.Resources.ProjectID) { + _, err := atlasClient.Client.Projects.Delete(context.TODO(), data.Resources.ProjectID) + g.Expect(err).To(BeNil()) + } }, "7m", "20s", - ).Should(BeFalse(), "Project and deployment should be deleted from Atlas") + ).Should(Succeed(), "Project and deployment should be deleted from Atlas") }) By("Delete HELM releases", func() { diff --git a/test/int/databaseuser_protected_test.go b/test/int/databaseuser_protected_test.go index d910da7f14..9b009747a3 100644 --- a/test/int/databaseuser_protected_test.go +++ b/test/int/databaseuser_protected_test.go @@ -321,6 +321,9 @@ var _ = Describe("Atlas Database User", Label("int", "AtlasDatabaseUser", "prote projectID := testProject.ID() Expect(k8sClient.Delete(context.TODO(), testProject)).To(Succeed()) + _, err := atlasClient.Projects.Delete(context.TODO(), projectID) + Expect(err).To(BeNil()) + Eventually(func() bool { _, r, err := atlasClient.Projects.GetOneProject(context.TODO(), projectID) if err != nil { diff --git a/test/int/integration_suite_test.go b/test/int/integration_suite_test.go index 0ad6fe1daa..51a19254c2 100644 --- a/test/int/integration_suite_test.go +++ b/test/int/integration_suite_test.go @@ -219,13 +219,15 @@ func prepareControllers(deletionProtection bool) (*corev1.Namespace, context.Can } err = (&atlasproject.AtlasProjectReconciler{ - Client: k8sManager.GetClient(), - Log: logger.Named("controllers").Named("AtlasProject").Sugar(), - AtlasDomain: atlasDomain, - ResourceWatcher: watch.NewResourceWatcher(), - GlobalAPISecret: kube.ObjectKey(namespace.Name, "atlas-operator-api-key"), - GlobalPredicates: globalPredicates, - EventRecorder: k8sManager.GetEventRecorderFor("AtlasProject"), + Client: k8sManager.GetClient(), + Log: logger.Named("controllers").Named("AtlasProject").Sugar(), + AtlasDomain: atlasDomain, + ResourceWatcher: watch.NewResourceWatcher(), + GlobalAPISecret: kube.ObjectKey(namespace.Name, "atlas-operator-api-key"), + GlobalPredicates: globalPredicates, + EventRecorder: k8sManager.GetEventRecorderFor("AtlasProject"), + ObjectDeletionProtection: deletionProtection, + SubObjectDeletionProtection: deletionProtection, }).SetupWithManager(k8sManager) Expect(err).ToNot(HaveOccurred()) diff --git a/test/int/project_protect_test.go b/test/int/project_protect_test.go new file mode 100644 index 0000000000..a00355f093 --- /dev/null +++ b/test/int/project_protect_test.go @@ -0,0 +1,160 @@ +package int + +import ( + "context" + "fmt" + "net/http" + "time" + + "github.com/mongodb/mongodb-atlas-kubernetes/pkg/controller/customresource" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "go.mongodb.org/atlas/mongodbatlas" + corev1 "k8s.io/api/core/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + + 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/testutil" + "github.com/mongodb/mongodb-atlas-kubernetes/pkg/util/toptr" +) + +var _ = Describe("AtlasProject", Label("int", "AtlasProject", "protection-enabled"), func() { + var testNamespace *corev1.Namespace + var stopManager context.CancelFunc + var connectionSecret corev1.Secret + + BeforeEach(func() { + By("Starting the operator", 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()) + }) + }) + + Describe("Operator is running with deletion protection enabled", func() { + It("Creates a project and protect it to be deleted", func() { + testProject := &mdbv1.AtlasProject{} + projectName := fmt.Sprintf("new-project-%s", testNamespace.Name) + + By("Creating a project in the cluster", func() { + testProject = mdbv1.NewProject(testNamespace.Name, projectName, projectName). + WithConnectionSecret(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(15 * time.Minute).WithPolling(PollingInterval).Should(BeTrue()) + }) + + // nolint:dupl + By("Deleting project in cluster doesn't delete from Atlas", func() { + projectID := testProject.ID() + Expect(k8sClient.Delete(context.TODO(), testProject, &client.DeleteOptions{})).To(Succeed()) + + Eventually(func(g Gomega) { + g.Expect(k8sClient.Get(context.TODO(), client.ObjectKeyFromObject(testProject), testProject, &client.GetOptions{})).ToNot(Succeed()) + + atlasProject, _, err := atlasClient.Projects.GetOneProjectByName(context.TODO(), projectName) + g.Expect(err).To(BeNil()) + g.Expect(atlasProject).ToNot(BeNil()) + }).WithTimeout(5 * time.Minute).WithPolling(PollingInterval).Should(Succeed()) + + _, err := atlasClient.Projects.Delete(context.TODO(), projectID) + Expect(err).To(BeNil()) + }) + }) + + It("Adds an existing Atlas project and protect it to be deleted", func() { + testProject := &mdbv1.AtlasProject{} + projectName := fmt.Sprintf("existing-project-%s", testNamespace.Name) + + By("Creating a project in Atlas", func() { + atlasProject := mongodbatlas.Project{ + OrgID: connection.OrgID, + Name: projectName, + WithDefaultAlertsSettings: toptr.MakePtr(true), + } + _, _, err := atlasClient.Projects.Create(context.TODO(), &atlasProject, &mongodbatlas.CreateProjectOptions{}) + Expect(err).To(BeNil()) + }) + + By("Creating a project in the cluster", func() { + testProject = mdbv1.NewProject(testNamespace.Name, projectName, projectName). + WithConnectionSecret(connectionSecret.Name) + Expect(k8sClient.Create(context.TODO(), testProject, &client.CreateOptions{})).To(Succeed()) + + Eventually(func() bool { + return testutil.CheckCondition(k8sClient, testProject, status.TrueCondition(status.ReadyType)) + }).WithTimeout(15 * time.Minute).WithPolling(PollingInterval).Should(BeTrue()) + }) + + // nolint:dupl + By("Deleting project in cluster doesn't delete from Atlas", func() { + projectID := testProject.ID() + Expect(k8sClient.Delete(context.TODO(), testProject, &client.DeleteOptions{})).To(Succeed()) + + Eventually(func(g Gomega) { + g.Expect(k8sClient.Get(context.TODO(), client.ObjectKeyFromObject(testProject), testProject, &client.GetOptions{})).ToNot(Succeed()) + + atlasProject, _, err := atlasClient.Projects.GetOneProjectByName(context.TODO(), projectName) + g.Expect(err).To(BeNil()) + g.Expect(atlasProject).ToNot(BeNil()) + }).WithTimeout(5 * time.Minute).WithPolling(PollingInterval).Should(Succeed()) + + _, err := atlasClient.Projects.Delete(context.TODO(), projectID) + Expect(err).To(BeNil()) + }) + }) + + It("Creates a project and annotate it to be deleted", func() { + testProject := &mdbv1.AtlasProject{} + projectName := fmt.Sprintf("new-project-%s", testNamespace.Name) + + By("Creating a project in the cluster", func() { + testProject = mdbv1.NewProject(testNamespace.Name, projectName, projectName). + WithAnnotations(map[string]string{customresource.ResourcePolicyAnnotation: customresource.ResourcePolicyDelete}). + WithConnectionSecret(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(15 * time.Minute).WithPolling(PollingInterval).Should(BeTrue()) + }) + + // nolint:dupl + By("Deleting project in cluster should delete it from Atlas", func() { + projectID := testProject.ID() + Expect(k8sClient.Delete(context.TODO(), testProject, &client.DeleteOptions{})).To(Succeed()) + + Eventually(func(g Gomega) { + _, r, err := atlasClient.Projects.GetOneProject(context.TODO(), projectID) + g.Expect(err).ToNot(BeNil()) + g.Expect(r).ToNot(BeNil()) + g.Expect(r.StatusCode).To(Equal(http.StatusNotFound)) + }).WithTimeout(5 * time.Minute).WithPolling(PollingInterval).Should(Succeed()) + }) + }) + }) + + AfterEach(func() { + 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()) + }) + }) +})