diff --git a/Makefile b/Makefile index c12e64a668..4a8daa0cf4 100644 --- a/Makefile +++ b/Makefile @@ -573,7 +573,7 @@ install-credentials: set-namespace ## Install the Atlas credentials for the Oper .PHONY: prepare-run prepare-run: generate vet manifests run-kind install-crds install-credentials - rm bin/manager + rm -rf bin/manager $(MAKE) manager VERSION=$(NEXT_VERSION) .PHONY: run diff --git a/api/condition.go b/api/condition.go index 87a7ace9a2..586e942f40 100644 --- a/api/condition.go +++ b/api/condition.go @@ -177,6 +177,15 @@ func HasConditionType(typ ConditionType, source []Condition) bool { return false } +func HasReadyCondition(conditions []Condition) bool { + for _, c := range conditions { + if c.Type == ReadyType && c.Status == corev1.ConditionTrue { + return true + } + } + return false +} + // EnsureConditionExists adds or updates the condition in the copy of a 'source' slice func EnsureConditionExists(condition Condition, source []Condition) []Condition { condition.LastTransitionTime = metav1.Now() diff --git a/api/v1/atlasdatabaseuser_types.go b/api/v1/atlasdatabaseuser_types.go index b3b9ada586..ced4b7c93e 100644 --- a/api/v1/atlasdatabaseuser_types.go +++ b/api/v1/atlasdatabaseuser_types.go @@ -197,6 +197,11 @@ func (p *AtlasDatabaseUser) ProjectDualRef() *ProjectDualReference { return &p.Spec.ProjectDualReference } +// IsDatabaseUserReady checks if the Ready condition is available +func (p *AtlasDatabaseUser) IsDatabaseUserReady() bool { + return api.HasReadyCondition(p.Status.Conditions) +} + func (p *AtlasDatabaseUser) UpdateStatus(conditions []api.Condition, options ...api.Option) { p.Status.Conditions = conditions p.Status.ObservedGeneration = p.ObjectMeta.Generation diff --git a/api/v1/atlasdeployment_types.go b/api/v1/atlasdeployment_types.go index ce31148f0c..d3bf75bb22 100644 --- a/api/v1/atlasdeployment_types.go +++ b/api/v1/atlasdeployment_types.go @@ -473,6 +473,10 @@ func (c *AtlasDeployment) GetReplicationSetID() string { return "" } +func (c *AtlasDeployment) IsDeploymentReady() bool { + return api.HasReadyCondition(c.Status.Conditions) +} + // +kubebuilder:object:root=true // AtlasDeploymentList contains a list of AtlasDeployment diff --git a/internal/controller/atlasdatabaseuser/atlasdatabaseuser_controller.go b/internal/controller/atlasdatabaseuser/atlasdatabaseuser_controller.go index 4edd432b06..88e71b32fb 100644 --- a/internal/controller/atlasdatabaseuser/atlasdatabaseuser_controller.go +++ b/internal/controller/atlasdatabaseuser/atlasdatabaseuser_controller.go @@ -39,7 +39,6 @@ import ( akov2 "github.com/mongodb/mongodb-atlas-kubernetes/v2/api/v1" "github.com/mongodb/mongodb-atlas-kubernetes/v2/api/v1/status" "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/controller/atlas" - "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/controller/connectionsecret" "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/controller/customresource" "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/controller/reconciler" "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/controller/statushandler" @@ -141,12 +140,7 @@ func (r *AtlasDatabaseUserReconciler) terminate( } // unmanage remove finalizer and release resource -func (r *AtlasDatabaseUserReconciler) unmanage(ctx *workflow.Context, projectID string, atlasDatabaseUser *akov2.AtlasDatabaseUser) (ctrl.Result, error) { - err := connectionsecret.RemoveStaleSecretsByUserName(ctx.Context, r.Client, projectID, atlasDatabaseUser.Spec.Username, *atlasDatabaseUser, r.Log) - if err != nil { - return r.terminate(ctx, atlasDatabaseUser, api.DatabaseUserReadyType, workflow.DatabaseUserConnectionSecretsNotDeleted, true, err) - } - +func (r *AtlasDatabaseUserReconciler) unmanage(ctx *workflow.Context, atlasDatabaseUser *akov2.AtlasDatabaseUser) (ctrl.Result, error) { if customresource.HaveFinalizer(atlasDatabaseUser, customresource.FinalizerLabel) { err := customresource.ManageFinalizer(ctx.Context, r.Client, atlasDatabaseUser, customresource.UnsetFinalizer) if err != nil { diff --git a/internal/controller/atlasdatabaseuser/databaseuser.go b/internal/controller/atlasdatabaseuser/databaseuser.go index 8d78a9a691..7035dadbb4 100644 --- a/internal/controller/atlasdatabaseuser/databaseuser.go +++ b/internal/controller/atlasdatabaseuser/databaseuser.go @@ -18,14 +18,12 @@ import ( "context" "errors" "fmt" - "time" corev1 "k8s.io/api/core/v1" ctrl "sigs.k8s.io/controller-runtime" "github.com/mongodb/mongodb-atlas-kubernetes/v2/api" akov2 "github.com/mongodb/mongodb-atlas-kubernetes/v2/api/v1" - "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/controller/connectionsecret" "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/controller/customresource" "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/controller/workflow" "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/timeutil" @@ -84,18 +82,15 @@ func (r *AtlasDatabaseUserReconciler) dbuLifeCycle(ctx *workflow.Context, dbUser return r.terminate(ctx, atlasDatabaseUser, api.DatabaseUserReadyType, workflow.Internal, true, err) } - expired, err := isExpired(atlasDatabaseUser) + expired, err := timeutil.IsExpired(atlasDatabaseUser.Spec.DeleteAfterDate) if err != nil { return r.terminate(ctx, atlasDatabaseUser, api.DatabaseUserReadyType, workflow.DatabaseUserInvalidSpec, false, err) } if expired { - err = connectionsecret.RemoveStaleSecretsByUserName(ctx.Context, r.Client, atlasProject.ID, atlasDatabaseUser.Spec.Username, *atlasDatabaseUser, r.Log) - if err != nil { - return r.terminate(ctx, atlasDatabaseUser, api.DatabaseUserReadyType, workflow.DatabaseUserConnectionSecretsNotDeleted, true, err) - } - - ctx.SetConditionFromResult(api.DatabaseUserReadyType, workflow.Terminate(workflow.DatabaseUserExpired, errors.New("an expired user cannot be managed"))) - return r.unmanage(ctx, atlasProject.ID, atlasDatabaseUser) + ctx.SetConditionFromResult(api.DatabaseUserReadyType, + workflow.Terminate(workflow.DatabaseUserExpired, errors.New("an expired user cannot be managed")), + ) + return r.unmanage(ctx, atlasDatabaseUser) } scopesAreValid, err := r.areDeploymentScopesValid(ctx, deploymentService, atlasProject.ID, atlasDatabaseUser) @@ -117,7 +112,7 @@ func (r *AtlasDatabaseUserReconciler) dbuLifeCycle(ctx *workflow.Context, dbUser case dbUserExists && wasDeleted: return r.delete(ctx, dbUserService, atlasProject.ID, atlasDatabaseUser) default: - return r.unmanage(ctx, atlasProject.ID, atlasDatabaseUser) + return r.unmanage(ctx, atlasDatabaseUser) } } @@ -139,11 +134,6 @@ func (r *AtlasDatabaseUserReconciler) create(ctx *workflow.Context, dbUserServic } if wasRenamed(atlasDatabaseUser) { - err = connectionsecret.RemoveStaleSecretsByUserName(ctx.Context, r.Client, projectID, atlasDatabaseUser.Status.UserName, *atlasDatabaseUser, r.Log) - if err != nil { - return r.terminate(ctx, atlasDatabaseUser, api.DatabaseUserReadyType, workflow.DatabaseUserConnectionSecretsNotDeleted, true, err) - } - ctx.Log.Infow("'spec.username' has changed - removing the old user from Atlas", "newUserName", atlasDatabaseUser.Spec.Username, "oldUserName", atlasDatabaseUser.Status.UserName) if err = r.removeOldUser(ctx.Context, dbUserService, projectID, atlasDatabaseUser); err != nil { return r.terminate(ctx, atlasDatabaseUser, api.DatabaseUserReadyType, workflow.Internal, true, err) @@ -183,7 +173,7 @@ func (r *AtlasDatabaseUserReconciler) delete(ctx *workflow.Context, dbUserServic if customresource.IsResourcePolicyKeepOrDefault(atlasDatabaseUser, r.ObjectDeletionProtection) { r.Log.Info("Not removing Atlas database user from Atlas as per configuration") - return r.unmanage(ctx, projectID, atlasDatabaseUser) + return r.unmanage(ctx, atlasDatabaseUser) } err := dbUserService.Delete(ctx.Context, atlasDatabaseUser.Spec.DatabaseName, projectID, atlasDatabaseUser.Spec.Username) @@ -195,7 +185,7 @@ func (r *AtlasDatabaseUserReconciler) delete(ctx *workflow.Context, dbUserServic r.Log.Info("Database user doesn't exist or is already deleted") } - return r.unmanage(ctx, projectID, atlasDatabaseUser) + return r.unmanage(ctx, atlasDatabaseUser) } func (r *AtlasDatabaseUserReconciler) readiness(ctx *workflow.Context, deploymentService deployment.AtlasDeploymentsService, @@ -205,19 +195,6 @@ func (r *AtlasDatabaseUserReconciler) readiness(ctx *workflow.Context, deploymen return r.terminate(ctx, atlasDatabaseUser, api.DatabaseUserReadyType, workflow.Internal, true, err) } - removedOrphanSecrets, err := connectionsecret.ReapOrphanConnectionSecrets( - ctx.Context, r.Client, atlasProject.ID, atlasDatabaseUser.Namespace, allDeploymentNames) - if err != nil { - return r.terminate(ctx, atlasDatabaseUser, api.DatabaseUserReadyType, workflow.Internal, true, err) - } - if len(removedOrphanSecrets) > 0 { - r.Log.Debugw("Removed orphan secrets bound to an non existent deployment", - "project", atlasProject.Name, "removed", len(removedOrphanSecrets)) - for _, orphan := range removedOrphanSecrets { - r.Log.Debugw("Removed orphan", "secret", orphan) - } - } - deploymentsToCheck := allDeploymentNames if atlasDatabaseUser.Spec.Scopes != nil { deploymentsToCheck = filterScopeDeployments(atlasDatabaseUser, allDeploymentNames) @@ -243,12 +220,6 @@ func (r *AtlasDatabaseUserReconciler) readiness(ctx *workflow.Context, deploymen ) } - // TODO refactor connectionsecret package to follow state machine approach - result := connectionsecret.CreateOrUpdateConnectionSecrets(ctx, r.Client, deploymentService, r.EventRecorder, atlasProject, *atlasDatabaseUser) - if !result.IsOk() { - return r.terminate(ctx, atlasDatabaseUser, api.DatabaseUserReadyType, workflow.DatabaseUserConnectionSecretsNotCreated, true, errors.New(result.GetMessage())) - } - return r.ready(ctx, atlasDatabaseUser, passwordVersion) } @@ -304,23 +275,6 @@ func (r *AtlasDatabaseUserReconciler) removeOldUser(ctx context.Context, dbUserS return err } -func isExpired(atlasDatabaseUser *akov2.AtlasDatabaseUser) (bool, error) { - if atlasDatabaseUser.Spec.DeleteAfterDate == "" { - return false, nil - } - - deleteAfter, err := timeutil.ParseISO8601(atlasDatabaseUser.Spec.DeleteAfterDate) - if err != nil { - return false, err - } - - if !deleteAfter.Before(time.Now()) { - return false, nil - } - - return true, nil -} - func hasChanged(databaseUserInAKO, databaseUserInAtlas *dbuser.User, currentPassVersion, passVersion string) bool { return !dbuser.EqualSpecs(databaseUserInAKO, databaseUserInAtlas) || currentPassVersion != passVersion } diff --git a/internal/controller/atlasdatabaseuser/databaseuser_test.go b/internal/controller/atlasdatabaseuser/databaseuser_test.go index 6a13c4c8e0..93d8141d3a 100644 --- a/internal/controller/atlasdatabaseuser/databaseuser_test.go +++ b/internal/controller/atlasdatabaseuser/databaseuser_test.go @@ -47,6 +47,7 @@ import ( atlasmock "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/mocks/atlas" "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/mocks/translation" "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/pointer" + "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/timeutil" "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/translation/dbuser" "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/translation/deployment" "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/translation/project" @@ -693,7 +694,6 @@ func TestDbuLifeCycle(t *testing.T) { dService: func() deployment.AtlasDeploymentsService { service := translation.NewAtlasDeploymentsServiceMock(t) service.EXPECT().ListDeploymentNames(context.Background(), "").Return([]string{}, nil) - service.EXPECT().ListDeploymentConnections(context.Background(), "").Return([]deployment.Connection{}, nil) return service }, @@ -1213,7 +1213,6 @@ func TestUpdate(t *testing.T) { dService: func() deployment.AtlasDeploymentsService { service := translation.NewAtlasDeploymentsServiceMock(t) service.EXPECT().ListDeploymentNames(context.Background(), "").Return([]string{}, nil) - service.EXPECT().ListDeploymentConnections(context.Background(), "").Return([]deployment.Connection{}, nil) return service }, @@ -1659,43 +1658,6 @@ func TestReadiness(t *testing.T) { WithMessageRegexp("0 out of 1 deployments have applied database user changes"), }, }, - "failed to create connection secrets": { - wantErr: true, - dbUser: &akov2.AtlasDatabaseUser{ - ObjectMeta: metav1.ObjectMeta{ - Name: "user1", - Namespace: "default", - }, - Spec: akov2.AtlasDatabaseUserSpec{ - Username: "user1", - PasswordSecret: &common.ResourceRef{ - Name: "user-pass", - }, - Scopes: []akov2.ScopeSpec{ - { - Name: "cluster2", - Type: akov2.DeploymentScopeType, - }, - }, - }, - }, - dService: func() deployment.AtlasDeploymentsService { - service := translation.NewAtlasDeploymentsServiceMock(t) - service.EXPECT().ListDeploymentNames(context.Background(), ""). - Return([]string{"cluster1", "cluster2"}, nil) - service.EXPECT().DeploymentIsReady(context.Background(), "", "cluster2"). - Return(true, nil) - service.EXPECT().ListDeploymentConnections(context.Background(), ""). - Return(nil, errors.New("failed to list cluster connections")) - - return service - }, - expectedConditions: []api.Condition{ - api.FalseCondition(api.DatabaseUserReadyType). - WithReason(string(workflow.DatabaseUserConnectionSecretsNotCreated)). - WithMessageRegexp("failed to list cluster connections"), - }, - }, "resource is ready": { dbUser: &akov2.AtlasDatabaseUser{ ObjectMeta: metav1.ObjectMeta{ @@ -1721,8 +1683,6 @@ func TestReadiness(t *testing.T) { Return([]string{"cluster1", "cluster2"}, nil) service.EXPECT().DeploymentIsReady(context.Background(), "", "cluster2"). Return(true, nil) - service.EXPECT().ListDeploymentConnections(context.Background(), ""). - Return([]deployment.Connection{}, nil) return service }, @@ -2127,7 +2087,7 @@ func TestIsExpired(t *testing.T) { for name, tt := range tests { t.Run(name, func(t *testing.T) { - expired, err := isExpired(tt.dbUser) + expired, err := timeutil.IsExpired(tt.dbUser.Spec.DeleteAfterDate) assert.Equal(t, tt.err, err) assert.Equal(t, tt.expected, expired) }) diff --git a/internal/controller/atlasdatafederation/connectionsecrets.go b/internal/controller/atlasdatafederation/connectionsecrets.go deleted file mode 100644 index 66953016e3..0000000000 --- a/internal/controller/atlasdatafederation/connectionsecrets.go +++ /dev/null @@ -1,118 +0,0 @@ -// Copyright 2025 MongoDB Inc -// -// 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 atlasdatafederation - -import ( - "fmt" - "strings" - - v1 "k8s.io/api/core/v1" - "sigs.k8s.io/controller-runtime/pkg/client" - - "github.com/mongodb/mongodb-atlas-kubernetes/v2/api" - akov2 "github.com/mongodb/mongodb-atlas-kubernetes/v2/api/v1" - "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/controller/connectionsecret" - "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/controller/workflow" - "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/stringutil" - "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/translation/datafederation" -) - -func (r *AtlasDataFederationReconciler) ensureConnectionSecrets(ctx *workflow.Context, federationService datafederation.DataFederationService, project *akov2.AtlasProject, df *akov2.AtlasDataFederation) workflow.DeprecatedResult { - databaseUsers := akov2.AtlasDatabaseUserList{} - err := r.Client.List(ctx.Context, &databaseUsers, &client.ListOptions{}) - if err != nil { - return workflow.Terminate(workflow.Internal, err) - } - - atlasDF, err := federationService.Get(ctx.Context, project.ID(), df.Spec.Name) - if err != nil { - return workflow.Terminate(workflow.Internal, err) - } - - connectionHosts := atlasDF.Hostnames - - secrets := make([]string, 0) - for i := range databaseUsers.Items { - dbUser := databaseUsers.Items[i] - - if !dbUserBelongsToProject(&dbUser, project) { - continue - } - - found := false - for _, c := range dbUser.Status.Conditions { - if c.Type == api.ReadyType && c.Status == v1.ConditionTrue { - found = true - break - } - } - - if !found { - ctx.Log.Debugw("AtlasDatabaseUser not ready - not creating connection secret", "user.name", dbUser.Name) - continue - } - - scopes := dbUser.GetScopes(akov2.DeploymentScopeType) - if len(scopes) != 0 && !stringutil.Contains(scopes, df.Spec.Name) { - continue - } - - password, err := dbUser.ReadPassword(ctx.Context, r.Client) - if err != nil { - return workflow.Terminate(workflow.DeploymentConnectionSecretsNotCreated, err) - } - - var connURLs []string - for _, host := range connectionHosts { - connURLs = append(connURLs, fmt.Sprintf("mongodb://%s:%s@%s?ssl=true", dbUser.Spec.Username, password, host)) - } - - data := connectionsecret.ConnectionData{ - DBUserName: dbUser.Spec.Username, - Password: password, - ConnURL: strings.Join(connURLs, ","), - } - - ctx.Log.Debugw("Creating a connection Secret", "data", data) - - secretName, err := connectionsecret.Ensure(ctx.Context, r.Client, dbUser.Namespace, project.Spec.Name, project.ID(), df.Spec.Name, data) - if err != nil { - return workflow.Terminate(workflow.DeploymentConnectionSecretsNotCreated, err) - } - secrets = append(secrets, secretName) - } - - if len(secrets) > 0 { - r.EventRecorder.Eventf(df, "Normal", "ConnectionSecretsEnsured", "Connection Secrets were created/updated: %s", strings.Join(secrets, ", ")) - } - - return workflow.OK() -} - -func dbUserBelongsToProject(dbUser *akov2.AtlasDatabaseUser, project *akov2.AtlasProject) bool { - if dbUser.Spec.ProjectRef.Name != project.Name { - return false - } - - if dbUser.Spec.ProjectRef.Namespace == "" && dbUser.Namespace != project.Namespace { - return false - } - - if dbUser.Spec.ProjectRef.Namespace != "" && dbUser.Spec.ProjectRef.Namespace != project.Namespace { - return false - } - - return true -} diff --git a/internal/controller/atlasdatafederation/datafederation_controller.go b/internal/controller/atlasdatafederation/datafederation_controller.go index b74f6d05cb..a990af230a 100644 --- a/internal/controller/atlasdatafederation/datafederation_controller.go +++ b/internal/controller/atlasdatafederation/datafederation_controller.go @@ -139,10 +139,6 @@ func (r *AtlasDataFederationReconciler) Reconcile(context context.Context, req c return result.ReconcileResult() } - if result = r.ensureConnectionSecrets(ctx, dataFederationService, project, dataFederation); !result.IsOk() { - return result.ReconcileResult() - } - if dataFederation.GetDeletionTimestamp().IsZero() { if !customresource.HaveFinalizer(dataFederation, customresource.FinalizerLabel) { err = r.Client.Get(context, kube.ObjectKeyFromObject(dataFederation), dataFederation) diff --git a/internal/controller/atlasdeployment/advanced_deployment.go b/internal/controller/atlasdeployment/advanced_deployment.go index 6d453aece8..334567b972 100644 --- a/internal/controller/atlasdeployment/advanced_deployment.go +++ b/internal/controller/atlasdeployment/advanced_deployment.go @@ -18,29 +18,19 @@ import ( "errors" "fmt" "reflect" - "strings" - v1 "k8s.io/api/core/v1" - "k8s.io/apimachinery/pkg/fields" ctrl "sigs.k8s.io/controller-runtime" - "sigs.k8s.io/controller-runtime/pkg/client" - "github.com/mongodb/mongodb-atlas-kubernetes/v2/api" - akov2 "github.com/mongodb/mongodb-atlas-kubernetes/v2/api/v1" "github.com/mongodb/mongodb-atlas-kubernetes/v2/api/v1/status" - "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/controller/connectionsecret" "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/controller/customresource" "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/controller/workflow" - "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/indexer" - "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/stringutil" "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/translation/deployment" - "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/translation/project" "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/translation/searchindex" ) const FreeTier = "M0" -func (r *AtlasDeploymentReconciler) handleAdvancedDeployment(ctx *workflow.Context, projectService project.ProjectService, deploymentService deployment.AtlasDeploymentsService, akoDeployment, atlasDeployment deployment.Deployment) (ctrl.Result, error) { +func (r *AtlasDeploymentReconciler) handleAdvancedDeployment(ctx *workflow.Context, deploymentService deployment.AtlasDeploymentsService, akoDeployment, atlasDeployment deployment.Deployment) (ctrl.Result, error) { if akoDeployment.GetCustomResource().Spec.UpgradeToDedicated && !atlasDeployment.IsDedicated() { if atlasDeployment.GetState() == status.StateUPDATING { return r.inProgress(ctx, akoDeployment.GetCustomResource(), atlasDeployment, workflow.DeploymentUpdating, "deployment is updating") @@ -96,11 +86,6 @@ func (r *AtlasDeploymentReconciler) handleAdvancedDeployment(ctx *workflow.Conte return transition(workflow.DeploymentAdvancedOptionsReady) } - err := r.ensureConnectionSecrets(ctx, projectService, akoCluster, atlasCluster.GetConnection()) - if err != nil { - return r.terminate(ctx, workflow.DeploymentConnectionSecretsNotCreated, err) - } - var results []workflow.DeprecatedResult if !r.AtlasProvider.IsCloudGov() { searchNodeResult := handleSearchNodes(ctx, akoCluster.GetCustomResource(), akoCluster.GetProjectID()) @@ -135,7 +120,7 @@ func (r *AtlasDeploymentReconciler) handleAdvancedDeployment(ctx *workflow.Conte return r.transitionFromResult(ctx, deploymentService, akoCluster.GetProjectID(), akoCluster.GetCustomResource(), results[i])(workflow.Internal) } } - err = customresource.ApplyLastConfigApplied(ctx.Context, akoCluster.GetCustomResource(), r.Client) + err := customresource.ApplyLastConfigApplied(ctx.Context, akoCluster.GetCustomResource(), r.Client) if err != nil { return r.terminate(ctx, workflow.Internal, err) } @@ -152,82 +137,6 @@ func (r *AtlasDeploymentReconciler) handleAdvancedDeployment(ctx *workflow.Conte } } -func (r *AtlasDeploymentReconciler) ensureConnectionSecrets(ctx *workflow.Context, projectService project.ProjectService, deploymentInAKO deployment.Deployment, connection *status.ConnectionStrings) error { - databaseUsers := &akov2.AtlasDatabaseUserList{} - listOpts := &client.ListOptions{ - FieldSelector: fields.OneTermEqualSelector(indexer.AtlasDatabaseUserByProject, deploymentInAKO.GetProjectID()), - } - err := r.Client.List(ctx.Context, databaseUsers, listOpts) - if err != nil { - return err - } - - secrets := make([]string, 0) - for _, dbUser := range databaseUsers.Items { - found := false - for _, c := range dbUser.Status.Conditions { - if c.Type == api.ReadyType && c.Status == v1.ConditionTrue { - found = true - break - } - } - - if !found { - ctx.Log.Debugw("AtlasDatabaseUser not ready - not creating connection secret", "user.name", dbUser.Name) - continue - } - - scopes := dbUser.GetScopes(akov2.DeploymentScopeType) - if len(scopes) != 0 && !stringutil.Contains(scopes, deploymentInAKO.GetName()) { - continue - } - - password, err := dbUser.ReadPassword(ctx.Context, r.Client) - if err != nil { - return err - } - - data := connectionsecret.ConnectionData{ - DBUserName: dbUser.Spec.Username, - Password: password, - ConnURL: connection.Standard, - SrvConnURL: connection.StandardSrv, - } - if connection.Private != "" { - data.PrivateConnURLs = append(data.PrivateConnURLs, connectionsecret.PrivateLinkConnURLs{ - PvtConnURL: connection.Private, - PvtSrvConnURL: connection.PrivateSrv, - }) - } - - for _, pe := range connection.PrivateEndpoint { - data.PrivateConnURLs = append(data.PrivateConnURLs, connectionsecret.PrivateLinkConnURLs{ - PvtConnURL: pe.ConnectionString, - PvtSrvConnURL: pe.SRVConnectionString, - PvtShardConnURL: pe.SRVShardOptimizedConnectionString, - }) - } - - project, err := projectService.GetProject(ctx.Context, deploymentInAKO.GetProjectID()) - if err != nil { - return err - } - - ctx.Log.Debugw("Creating a connection Secret", "data", data) - secretName, err := connectionsecret.Ensure(ctx.Context, r.Client, dbUser.Namespace, project.Name, deploymentInAKO.GetProjectID(), deploymentInAKO.GetName(), data) - if err != nil { - return err - } - secrets = append(secrets, secretName) - } - - if len(secrets) > 0 { - r.EventRecorder.Eventf(deploymentInAKO.GetCustomResource(), "Normal", "ConnectionSecretsEnsured", "Connection Secrets were created/updated: %s", strings.Join(secrets, ", ")) - } - - return nil -} - func (r *AtlasDeploymentReconciler) ensureAdvancedOptions(ctx *workflow.Context, deploymentService deployment.AtlasDeploymentsService, deploymentInAKO, deploymentInAtlas *deployment.Cluster) transitionFn { if deploymentInAKO.IsTenant() { return nil diff --git a/internal/controller/atlasdeployment/advanced_deployment_test.go b/internal/controller/atlasdeployment/advanced_deployment_test.go index 1ac94454ca..dfaf45cc66 100644 --- a/internal/controller/atlasdeployment/advanced_deployment_test.go +++ b/internal/controller/atlasdeployment/advanced_deployment_test.go @@ -40,7 +40,6 @@ import ( "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/mocks/translation" "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/pointer" "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/translation/deployment" - "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/translation/project" ) func TestHandleAdvancedDeployment(t *testing.T) { @@ -975,8 +974,7 @@ func TestHandleAdvancedDeployment(t *testing.T) { } deploymentInAKO := deployment.NewDeployment("project-id", tt.atlasDeployment).(*deployment.Cluster) - var projectService project.ProjectService // nil projetc service - result, err := reconciler.handleAdvancedDeployment(ctx, projectService, tt.deploymentService(), deploymentInAKO, tt.deploymentInAtlas) + result, err := reconciler.handleAdvancedDeployment(ctx, tt.deploymentService(), deploymentInAKO, tt.deploymentInAtlas) //require.NoError(t, err) assert.Equal(t, tt.expectedResult, workflowRes{ res: result, diff --git a/internal/controller/atlasdeployment/atlasdeployment_controller.go b/internal/controller/atlasdeployment/atlasdeployment_controller.go index a974417c7d..b5d0766282 100644 --- a/internal/controller/atlasdeployment/atlasdeployment_controller.go +++ b/internal/controller/atlasdeployment/atlasdeployment_controller.go @@ -23,7 +23,6 @@ import ( "go.uber.org/zap" "go.uber.org/zap/zapcore" corev1 "k8s.io/api/core/v1" - k8serrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/fields" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" @@ -41,7 +40,6 @@ import ( akov2 "github.com/mongodb/mongodb-atlas-kubernetes/v2/api/v1" "github.com/mongodb/mongodb-atlas-kubernetes/v2/api/v1/status" "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/controller/atlas" - "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/controller/connectionsecret" "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/controller/customresource" "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/controller/reconciler" "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/controller/statushandler" @@ -51,7 +49,6 @@ import ( "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/kube" "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/pointer" "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/translation/deployment" - "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/translation/project" "github.com/mongodb/mongodb-atlas-kubernetes/v2/pkg/ratelimit" ) @@ -139,7 +136,6 @@ func (r *AtlasDeploymentReconciler) Reconcile(ctx context.Context, req ctrl.Requ return r.terminate(workflowCtx, workflow.AtlasAPIAccessNotConfigured, err) } workflowCtx.SdkClientSet = sdkClientSet - projectService := project.NewProjectAPIService(sdkClientSet.SdkClient20250312002.ProjectsApi) deploymentService := deployment.NewAtlasDeployments(sdkClientSet.SdkClient20250312002.ClustersApi, sdkClientSet.SdkClient20250312002.ServerlessInstancesApi, sdkClientSet.SdkClient20250312002.GlobalClustersApi, sdkClientSet.SdkClient20250312002.FlexClustersApi, r.AtlasProvider.IsCloudGov()) atlasProject, err := r.ResolveProject(workflowCtx.Context, sdkClientSet.SdkClient20250312002, atlasDeployment) if err != nil { @@ -176,13 +172,13 @@ func (r *AtlasDeploymentReconciler) Reconcile(ctx context.Context, req ctrl.Requ switch { case atlasDeployment.IsServerless(): - return r.handleServerlessInstance(workflowCtx, projectService, deploymentService, deploymentInAKO, deploymentInAtlas) + return r.handleServerlessInstance(workflowCtx, deploymentService, deploymentInAKO, deploymentInAtlas) case atlasDeployment.IsFlex(): - return r.handleFlexInstance(workflowCtx, projectService, deploymentService, deploymentInAKO, deploymentInAtlas) + return r.handleFlexInstance(workflowCtx, deploymentService, deploymentInAKO, deploymentInAtlas) case atlasDeployment.IsAdvancedDeployment(): - return r.handleAdvancedDeployment(workflowCtx, projectService, deploymentService, deploymentInAKO, deploymentInAtlas) + return r.handleAdvancedDeployment(workflowCtx, deploymentService, deploymentInAKO, deploymentInAtlas) } return workflow.OK().ReconcileResult() @@ -240,12 +236,7 @@ func (r *AtlasDeploymentReconciler) deleteDeploymentFromAtlas( ) error { ctx.Log.Infow("-> Starting AtlasDeployment deletion", "spec", deploymentInAKO) - err := r.deleteConnectionStrings(ctx, deploymentInAKO) - if err != nil { - return err - } - - err = deploymentService.DeleteDeployment(ctx.Context, deploymentInAtlas) + err := deploymentService.DeleteDeployment(ctx.Context, deploymentInAtlas) if err != nil { ctx.Log.Errorw("Cannot delete Atlas deployment", "error", err) return err @@ -254,25 +245,6 @@ func (r *AtlasDeploymentReconciler) deleteDeploymentFromAtlas( return nil } -func (r *AtlasDeploymentReconciler) deleteConnectionStrings(ctx *workflow.Context, deployment deployment.Deployment) error { - // We always remove the connection secrets even if the deployment is not removed from Atlas - secrets, err := connectionsecret.ListByDeploymentName(ctx.Context, r.Client, "", deployment.GetProjectID(), deployment.GetName()) - 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.Context, &secrets[i]); err != nil { - if k8serrors.IsNotFound(err) { - continue - } - ctx.Log.Errorw("Failed to delete secret", "secretName", secrets[i].Name, "error", err) - } - } - - return nil -} - func (r *AtlasDeploymentReconciler) removeDeletionFinalizer(context context.Context, deployment *akov2.AtlasDeployment) error { err := r.Client.Get(context, kube.ObjectKeyFromObject(deployment), deployment) if err != nil { diff --git a/internal/controller/atlasdeployment/flex_deployment.go b/internal/controller/atlasdeployment/flex_deployment.go index ae2522cde5..0d2ffc8cd6 100644 --- a/internal/controller/atlasdeployment/flex_deployment.go +++ b/internal/controller/atlasdeployment/flex_deployment.go @@ -25,11 +25,10 @@ import ( "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/controller/customresource" "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/controller/workflow" "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/translation/deployment" - "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/translation/project" ) -func (r *AtlasDeploymentReconciler) handleFlexInstance(ctx *workflow.Context, projectService project.ProjectService, - deploymentService deployment.AtlasDeploymentsService, akoDeployment, atlasDeployment deployment.Deployment) (ctrl.Result, error) { +func (r *AtlasDeploymentReconciler) handleFlexInstance(ctx *workflow.Context, deploymentService deployment.AtlasDeploymentsService, + akoDeployment, atlasDeployment deployment.Deployment) (ctrl.Result, error) { akoFlex, ok := akoDeployment.(*deployment.Flex) if !ok { return r.terminate(ctx, workflow.Internal, errors.New("deployment in AKO is not a flex cluster")) @@ -61,12 +60,7 @@ func (r *AtlasDeploymentReconciler) handleFlexInstance(ctx *workflow.Context, pr return r.inProgress(ctx, akoFlex.GetCustomResource(), atlasFlex, workflow.DeploymentUpdating, "deployment is updating") } - err := r.ensureConnectionSecrets(ctx, projectService, akoFlex, atlasFlex.GetConnection()) - if err != nil { - return r.terminate(ctx, workflow.DeploymentConnectionSecretsNotCreated, err) - } - - err = customresource.ApplyLastConfigApplied(ctx.Context, akoFlex.GetCustomResource(), r.Client) + err := customresource.ApplyLastConfigApplied(ctx.Context, akoFlex.GetCustomResource(), r.Client) if err != nil { return r.terminate(ctx, workflow.Internal, err) } diff --git a/internal/controller/atlasdeployment/flex_deployment_test.go b/internal/controller/atlasdeployment/flex_deployment_test.go index 11b4ad2d29..bfdc4df80f 100644 --- a/internal/controller/atlasdeployment/flex_deployment_test.go +++ b/internal/controller/atlasdeployment/flex_deployment_test.go @@ -37,7 +37,6 @@ import ( "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/indexer" "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/mocks/translation" "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/translation/deployment" - "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/translation/project" ) func TestHandleFlexInstance(t *testing.T) { @@ -292,8 +291,7 @@ func TestHandleFlexInstance(t *testing.T) { } deploymentInAKO := deployment.NewDeployment("project-id", tt.atlasDeployment).(*deployment.Flex) - var projectService project.ProjectService - result, err := reconciler.handleFlexInstance(workflowCtx, projectService, tt.deploymentService(), deploymentInAKO, tt.deploymentInAtlas) + result, err := reconciler.handleFlexInstance(workflowCtx, tt.deploymentService(), deploymentInAKO, tt.deploymentInAtlas) assert.Equal(t, tt.expectedResult, workflowRes{ res: result, diff --git a/internal/controller/atlasdeployment/serverless_deployment.go b/internal/controller/atlasdeployment/serverless_deployment.go index 420e092bac..0ed80c3670 100644 --- a/internal/controller/atlasdeployment/serverless_deployment.go +++ b/internal/controller/atlasdeployment/serverless_deployment.go @@ -25,11 +25,10 @@ import ( "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/controller/customresource" "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/controller/workflow" "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/translation/deployment" - "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/translation/project" ) -func (r *AtlasDeploymentReconciler) handleServerlessInstance(ctx *workflow.Context, projectService project.ProjectService, - deploymentService deployment.AtlasDeploymentsService, akoDeployment, atlasDeployment deployment.Deployment) (ctrl.Result, error) { +func (r *AtlasDeploymentReconciler) handleServerlessInstance(ctx *workflow.Context, deploymentService deployment.AtlasDeploymentsService, + akoDeployment, atlasDeployment deployment.Deployment) (ctrl.Result, error) { akoServerless, ok := akoDeployment.(*deployment.Serverless) if !ok { return r.terminate(ctx, workflow.Internal, errors.New("deployment in AKO is not a serverless cluster")) @@ -61,11 +60,6 @@ func (r *AtlasDeploymentReconciler) handleServerlessInstance(ctx *workflow.Conte return r.inProgress(ctx, akoServerless.GetCustomResource(), atlasServerless, workflow.DeploymentUpdating, "deployment is updating") } - err := r.ensureConnectionSecrets(ctx, projectService, akoServerless, atlasServerless.GetConnection()) - if err != nil { - return r.terminate(ctx, workflow.DeploymentConnectionSecretsNotCreated, err) - } - // Note: Serverless Private endpoints keep theirs flows without translation layer (yet) result := ensureServerlessPrivateEndpoints(ctx, akoServerless.GetProjectID(), akoServerless.GetCustomResource()) @@ -76,7 +70,7 @@ func (r *AtlasDeploymentReconciler) handleServerlessInstance(ctx *workflow.Conte return r.terminate(ctx, workflow.ServerlessPrivateEndpointFailed, errors.New(result.GetMessage())) } - err = customresource.ApplyLastConfigApplied(ctx.Context, akoServerless.GetCustomResource(), r.Client) + err := customresource.ApplyLastConfigApplied(ctx.Context, akoServerless.GetCustomResource(), r.Client) if err != nil { return r.terminate(ctx, workflow.Internal, err) } diff --git a/internal/controller/atlasdeployment/serverless_deployment_test.go b/internal/controller/atlasdeployment/serverless_deployment_test.go index a44f199078..9bba6ac7a6 100644 --- a/internal/controller/atlasdeployment/serverless_deployment_test.go +++ b/internal/controller/atlasdeployment/serverless_deployment_test.go @@ -43,7 +43,6 @@ import ( "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/indexer" "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/mocks/translation" "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/translation/deployment" - "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/translation/project" ) func TestHandleServerlessInstance(t *testing.T) { @@ -952,8 +951,7 @@ func TestHandleServerlessInstance(t *testing.T) { } deploymentInAKO := deployment.NewDeployment("project-id", tt.atlasDeployment).(*deployment.Serverless) - var projectService project.ProjectService - result, err := reconciler.handleServerlessInstance(workflowCtx, projectService, tt.deploymentService(), deploymentInAKO, tt.deploymentInAtlas) + result, err := reconciler.handleServerlessInstance(workflowCtx, tt.deploymentService(), deploymentInAKO, tt.deploymentInAtlas) //require.NoError(t, err) assert.Equal(t, tt.expectedResult, workflowRes{ res: result, diff --git a/internal/controller/connectionsecret/connectionsecret_controller.go b/internal/controller/connectionsecret/connectionsecret_controller.go new file mode 100644 index 0000000000..4a74212f92 --- /dev/null +++ b/internal/controller/connectionsecret/connectionsecret_controller.go @@ -0,0 +1,262 @@ +// Copyright 2025 MongoDB Inc +// +// 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 connectionsecret + +import ( + "context" + "errors" + "fmt" + "strings" + + "go.uber.org/zap" + corev1 "k8s.io/api/core/v1" + apiErrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/fields" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/tools/record" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/builder" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/cluster" + "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/reconcile" + + akov2 "github.com/mongodb/mongodb-atlas-kubernetes/v2/api/v1" + "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/controller/atlas" + "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/controller/reconciler" + "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/controller/watch" + "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/controller/workflow" + "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/indexer" + "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/pointer" + "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/stringutil" + "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/timeutil" + "github.com/mongodb/mongodb-atlas-kubernetes/v2/pkg/ratelimit" +) + +type ConnectionSecretReconciler struct { + reconciler.AtlasReconciler + Scheme *runtime.Scheme + GlobalPredicates []predicate.Predicate + EventRecorder record.EventRecorder +} + +func (r *ConnectionSecretReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + // Parses the request name and fills up the identifiers: ProjectID, ClusterName, DatabaseUsername + log := r.Log.With("ns", req.Namespace, "name", req.Name) + log.Debugw("reconcile started") + + ids, err := r.loadRequestIdentifiers(ctx, req.NamespacedName) + if err != nil { + if apiErrors.IsNotFound(err) { + log.Debugw("connectionsecret not found; assuming deleted") + return workflow.TerminateSilently(nil).WithoutRetry().ReconcileResult() + } + log.Errorw("failed to parse connectionsecret request", "reason", workflow.ConnSecretInvalidName, "error", err) + return workflow.Terminate(workflow.ConnSecretInvalidName, err).ReconcileResult() + } + + log.Debugw("identifiers loaded") + + // Loads the pair of AtlasDeployment and AtlasDatabaseUser via the indexers + pair, err := r.loadPairedResources(ctx, ids) + if err != nil { + switch { + // This means there's no owner resources; the secret will be garbage collected + case errors.Is(err, ErrNoPairedResourcesFound): + log.Debugw("no paired resources found") + return workflow.TerminateSilently(nil).WithoutRetry().ReconcileResult() + + // This means an owner from the pair was deleted; the secret will be forcefully removed + case errors.Is(err, ErrNoDeploymentFound), errors.Is(err, ErrNoUserFound): + log.Infow("paired resource missing; scheduling deletion", "reason", workflow.ConnSecretOwnerMissing) + return r.handleDelete(ctx, req, ids, pair) + + case errors.Is(err, ErrManyDeployments), errors.Is(err, ErrManyUsers): + log.Errorw("ambiguous pairing; multiple matches", "reason", workflow.ConnSecretAmbiguousResources, "error", err) + return workflow.Terminate(workflow.ConnSecretAmbiguousResources, err).ReconcileResult() + + default: + log.Errorw("failed to load paired resources", "reason", workflow.ConnSecretInvalidResources, "error", err) + return workflow.Terminate(workflow.ConnSecretInvalidResources, err).ReconcileResult() + } + } + + log.Debugw("paired resources loaded") + + // If the user expired, delete connection secret + expired, err := timeutil.IsExpired(pair.User.Spec.DeleteAfterDate) + if err != nil { + log.Errorw("failed to check expiration date", "reason", workflow.ConnSecretCheckExpirationFailed, "error", err) + return workflow.Terminate(workflow.ConnSecretCheckExpirationFailed, err).ReconcileResult() + } + if expired { + log.Infow("user expired; scheduling deletion", "reason", workflow.ConnSecretUserExpired) + return r.handleDelete(ctx, req, ids, pair) + } + + // If the scope became invalid, delete connection secret + if invalidScopes(pair) { + log.Infow("invalid scope; scheduling deletion", "reason", workflow.ConnSecretInvalidScopes) + return r.handleDelete(ctx, req, ids, pair) + } + + // Checks that AtlasDeployment and AtlasDatabaseUser are ready before proceeding + if ready, notReady := isReady(pair); !ready { + log.Debugw("waiting for paired resources to become ready", "notReady", strings.Join(notReady, ",")) + return workflow.InProgress(workflow.ConnSecretNotReady, fmt.Sprintf("Not ready: %s", strings.Join(notReady, ", "))).ReconcileResult() + } + + // Create or update the k8s connection secret + log.Infow("creating/updating connection secret", "reason", workflow.ConnSecretUpsert) + return r.handleUpsert(ctx, req, ids, pair) +} + +func (r *ConnectionSecretReconciler) For() (client.Object, builder.Predicates) { + preds := append( + r.GlobalPredicates, + watch.SecretLabelPredicate(TypeLabelKey, ProjectLabelKey, ClusterLabelKey), + ) + return &corev1.Secret{}, builder.WithPredicates(preds...) +} + +func (r *ConnectionSecretReconciler) SetupWithManager(mgr ctrl.Manager, skipNameValidation bool) error { + return ctrl.NewControllerManagedBy(mgr). + Named("ConnectionSecret"). + For(r.For()). + Watches( + &akov2.AtlasDeployment{}, + handler.EnqueueRequestsFromMapFunc(r.newDeploymentMapFunc), + builder.WithPredicates(predicate.Or( + watch.ReadyTransitionPredicate((*akov2.AtlasDeployment).IsDeploymentReady), + predicate.GenerationChangedPredicate{}, + )), + ). + Watches( + &akov2.AtlasDatabaseUser{}, + handler.EnqueueRequestsFromMapFunc(r.newDatabaseUserMapFunc), + builder.WithPredicates(predicate.Or( + watch.ReadyTransitionPredicate((*akov2.AtlasDatabaseUser).IsDatabaseUserReady), + predicate.GenerationChangedPredicate{}, + )), + ). + WithOptions(controller.TypedOptions[reconcile.Request]{ + RateLimiter: ratelimit.NewRateLimiter[reconcile.Request](), + SkipNameValidation: pointer.MakePtr(skipNameValidation), + }). + Complete(r) +} + +func (r *ConnectionSecretReconciler) generateConnectionSecretRequests( + projectID string, + deployments []akov2.AtlasDeployment, + users []akov2.AtlasDatabaseUser, +) []reconcile.Request { + var requests []reconcile.Request + for _, d := range deployments { + for _, u := range users { + scopes := u.GetScopes(akov2.DeploymentScopeType) + if len(scopes) != 0 && !stringutil.Contains(scopes, d.GetDeploymentName()) { + continue + } + + requestName := CreateInternalFormat(projectID, d.GetDeploymentName(), u.Spec.Username) + requests = append(requests, reconcile.Request{ + NamespacedName: types.NamespacedName{ + Namespace: u.Namespace, // connection secrets always live in the namespace of the user + Name: requestName, + }, + }) + } + } + return requests +} + +func (r *ConnectionSecretReconciler) ResolveProjectId(ctx context.Context, ref akov2.ProjectDualReference, parentNamespace string) (string, error) { + if ref.ExternalProjectRef != nil && ref.ExternalProjectRef.ID != "" { + return ref.ExternalProjectRef.ID, nil + } + if ref.ProjectRef != nil && ref.ProjectRef.Name != "" { + project := &akov2.AtlasProject{} + if err := r.Client.Get(ctx, *ref.ProjectRef.GetObject(parentNamespace), project); err != nil { + return "", fmt.Errorf("failed to resolve projectRef from deployment: %w", err) + } + return project.ID(), nil + } + return "", fmt.Errorf("missing both external and internal project references") +} + +func (r *ConnectionSecretReconciler) newDeploymentMapFunc(ctx context.Context, obj client.Object) []reconcile.Request { + deployment, ok := obj.(*akov2.AtlasDeployment) + if !ok { + r.Log.Warnf("watching AtlasDeployment but got %T", obj) + return nil + } + projectID, err := r.ResolveProjectId(ctx, deployment.Spec.ProjectDualReference, deployment.GetNamespace()) + if err != nil { + r.Log.Errorw("Unable to resolve projectID for deployment", "error", err) + return nil + } + users := &akov2.AtlasDatabaseUserList{} + if err := r.Client.List(ctx, users, &client.ListOptions{ + FieldSelector: fields.OneTermEqualSelector(indexer.AtlasDatabaseUserByProject, projectID), + }); err != nil { + r.Log.Errorf("failed to list AtlasDatabaseUsers: %v", err) + return nil + } + return r.generateConnectionSecretRequests(projectID, []akov2.AtlasDeployment{*deployment}, users.Items) +} +func (r *ConnectionSecretReconciler) newDatabaseUserMapFunc(ctx context.Context, obj client.Object) []reconcile.Request { + user, ok := obj.(*akov2.AtlasDatabaseUser) + if !ok { + r.Log.Warnf("watching AtlasDatabaseUser but got %T", obj) + return nil + } + projectID, err := r.ResolveProjectId(ctx, user.Spec.ProjectDualReference, user.GetNamespace()) + if err != nil { + r.Log.Errorw("Unable to resolve projectID for user", "error", err) + return nil + } + deployments := &akov2.AtlasDeploymentList{} + if err := r.Client.List(ctx, deployments, &client.ListOptions{ + FieldSelector: fields.OneTermEqualSelector(indexer.AtlasDeploymentByProject, projectID), + }); err != nil { + r.Log.Errorf("failed to list AtlasDeployments: %v", err) + return nil + } + return r.generateConnectionSecretRequests(projectID, deployments.Items, []akov2.AtlasDatabaseUser{*user}) +} + +func NewConnectionSecretReconciler( + c cluster.Cluster, + predicates []predicate.Predicate, + atlasProvider atlas.Provider, + logger *zap.Logger, + globalSecretRef types.NamespacedName, +) *ConnectionSecretReconciler { + return &ConnectionSecretReconciler{ + AtlasReconciler: reconciler.AtlasReconciler{ + Client: c.GetClient(), + Log: logger.Named("controllers").Named("ConnectionSecret").Sugar(), + GlobalSecretRef: globalSecretRef, + AtlasProvider: atlasProvider, + }, + Scheme: c.GetScheme(), + EventRecorder: c.GetEventRecorderFor("ConnectionSecret"), + GlobalPredicates: predicates, + } +} diff --git a/internal/controller/connectionsecret/connectionsecret_controller_test.go b/internal/controller/connectionsecret/connectionsecret_controller_test.go new file mode 100644 index 0000000000..d01c671fa2 --- /dev/null +++ b/internal/controller/connectionsecret/connectionsecret_controller_test.go @@ -0,0 +1,956 @@ +// Copyright 2025 MongoDB Inc +// +// 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 connectionsecret + +import ( + "context" + "fmt" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + admin "go.mongodb.org/atlas-sdk/v20250312002/admin" + "go.mongodb.org/atlas-sdk/v20250312002/mockadmin" + "go.uber.org/zap" + "go.uber.org/zap/zaptest" + corev1 "k8s.io/api/core/v1" + apiErrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "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/client/fake" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + "github.com/mongodb/mongodb-atlas-kubernetes/v2/api" + akov2 "github.com/mongodb/mongodb-atlas-kubernetes/v2/api/v1" + "github.com/mongodb/mongodb-atlas-kubernetes/v2/api/v1/common" + "github.com/mongodb/mongodb-atlas-kubernetes/v2/api/v1/status" + "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/controller/atlas" + "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/controller/reconciler" + "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/controller/workflow" + "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/indexer" + atlasmock "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/mocks/atlas" + "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/pointer" +) + +func TestConnectionSecretReconcile(t *testing.T) { + type testCase struct { + reqName string + deployment *akov2.AtlasDeployment + user *akov2.AtlasDatabaseUser + project *akov2.AtlasProject + secrets []client.Object + expectedDeletion bool + expectedUpdate bool + expectedResult func() (ctrl.Result, error) + } + + tests := map[string]testCase{ + "fail: could not load identifiers": { + reqName: "my-project$cluster", + expectedResult: func() (ctrl.Result, error) { + return workflow.Terminate("InvalidConnectionSecretName", ErrInternalFormatPartsInvalid).ReconcileResult() + }, + }, + "success: could not find secret with k8s format": { + reqName: "test-project-id-cluster1-admin", + expectedResult: func() (ctrl.Result, error) { + return workflow.TerminateSilently(nil).WithoutRetry().ReconcileResult() + }, + }, + "success: missing deployment and missing user; garbage collect secret": { + reqName: "test-project-id$cluster1$admin", + secrets: []client.Object{ + &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "myproject-cluster1-admin", + Namespace: "default", + Labels: map[string]string{ + ProjectLabelKey: "test-project-id", + ClusterLabelKey: "cluster1", + TypeLabelKey: "connection", + }, + }, + }, + }, + + // Deletion will internally be done by Kube via ownerRefernce GC + expectedResult: func() (ctrl.Result, error) { + return workflow.TerminateSilently(nil).WithoutRetry().ReconcileResult() + }, + }, + "success: only one available resource from the pair, other non-existent": { + reqName: "test-project-id$cluster1$admin", + user: &akov2.AtlasDatabaseUser{ + ObjectMeta: metav1.ObjectMeta{ + Name: "admin", + Namespace: "default", + }, + Spec: akov2.AtlasDatabaseUserSpec{ + Username: "admin", + PasswordSecret: &common.ResourceRef{Name: "admin-password"}, + ProjectDualReference: akov2.ProjectDualReference{ + ProjectRef: &common.ResourceRefNamespaced{ + Name: "my-project", + Namespace: "default", + }, + }, + }, + }, + project: &akov2.AtlasProject{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-project", + Namespace: "default", + }, + Spec: akov2.AtlasProjectSpec{ + Name: "project", + }, + }, + expectedResult: func() (ctrl.Result, error) { + return workflow.TerminateSilently(nil).WithoutRetry().ReconcileResult() + }, + }, + "requque: resources are not ready yet": { + reqName: "test-project-id$cluster1$admin", + deployment: &akov2.AtlasDeployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: "deployment", + Namespace: "default", + }, + Spec: akov2.AtlasDeploymentSpec{ + DeploymentSpec: &akov2.AdvancedDeploymentSpec{Name: "cluster1"}, + ProjectDualReference: akov2.ProjectDualReference{ + ExternalProjectRef: &akov2.ExternalProjectReference{ID: "test-project-id"}, + ConnectionSecret: &api.LocalObjectReference{Name: "sdk-creds"}, + }, + }, + Status: status.AtlasDeploymentStatus{}, + }, + user: &akov2.AtlasDatabaseUser{ + ObjectMeta: metav1.ObjectMeta{ + Name: "user", + Namespace: "default", + }, + Spec: akov2.AtlasDatabaseUserSpec{ + Username: "admin", + PasswordSecret: &common.ResourceRef{Name: "admin-password"}, + ProjectDualReference: akov2.ProjectDualReference{ + ExternalProjectRef: &akov2.ExternalProjectReference{ID: "test-project-id"}, + ConnectionSecret: &api.LocalObjectReference{Name: "sdk-creds"}, + }, + }, + Status: status.AtlasDatabaseUserStatus{ + Common: api.Common{ + Conditions: []api.Condition{{Type: api.ReadyType, Status: corev1.ConditionTrue}}, + }, + }, + }, + expectedResult: func() (ctrl.Result, error) { + notReady := []string{"AtlasDeployment/deployment"} + return workflow.InProgress("ConnectionSecretNotReady", fmt.Sprintf("Not ready: %s", strings.Join(notReady, ", "))).ReconcileResult() + }, + }, + "success: deployment missing triggers handleDelete()": { + reqName: "test-project-id$cluster1$admin", + user: &akov2.AtlasDatabaseUser{ + ObjectMeta: metav1.ObjectMeta{ + Name: "user", + Namespace: "default", + }, + Spec: akov2.AtlasDatabaseUserSpec{ + Username: "admin", + PasswordSecret: &common.ResourceRef{Name: "admin-password"}, + ProjectDualReference: akov2.ProjectDualReference{ + ExternalProjectRef: &akov2.ExternalProjectReference{ID: "test-project-id"}, + ConnectionSecret: &api.LocalObjectReference{Name: "sdk-creds"}, + }, + }, + Status: status.AtlasDatabaseUserStatus{ + Common: api.Common{ + Conditions: []api.Condition{{Type: api.ReadyType, Status: corev1.ConditionTrue}}, + }, + }, + }, + secrets: []client.Object{ + &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "sdk-creds", + Namespace: "default", + }, + Data: map[string][]byte{ + "orgId": []byte("test-pass"), + "publicApiKey": []byte("test-pass"), + "privateApiKey": []byte("test-pass"), + }, + }, + &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "myproject-cluster1-admin", + Namespace: "default", + Labels: map[string]string{ + ProjectLabelKey: "test-project-id", + ClusterLabelKey: "cluster1", + TypeLabelKey: "connection", + }, + }, + }, + }, + expectedDeletion: true, + expectedResult: func() (ctrl.Result, error) { + return workflow.TerminateSilently(nil).WithoutRetry().ReconcileResult() + }, + }, + "success: invalid scopes trigger handleDelete()": { + reqName: "test-project-id$cluster1$admin", + deployment: &akov2.AtlasDeployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: "deployment", + Namespace: "default", + }, + Spec: akov2.AtlasDeploymentSpec{ + DeploymentSpec: &akov2.AdvancedDeploymentSpec{Name: "cluster1"}, + ProjectDualReference: akov2.ProjectDualReference{ + ExternalProjectRef: &akov2.ExternalProjectReference{ID: "test-project-id"}, + ConnectionSecret: &api.LocalObjectReference{Name: "sdk-creds"}, + }, + }, + Status: status.AtlasDeploymentStatus{ + Common: api.Common{ + Conditions: []api.Condition{{Type: api.ReadyType, Status: corev1.ConditionTrue}}, + }, + }, + }, + user: &akov2.AtlasDatabaseUser{ + ObjectMeta: metav1.ObjectMeta{ + Name: "admin", + Namespace: "default", + }, + Spec: akov2.AtlasDatabaseUserSpec{ + Username: "admin", + PasswordSecret: &common.ResourceRef{Name: "admin-password"}, + ProjectDualReference: akov2.ProjectDualReference{ + ExternalProjectRef: &akov2.ExternalProjectReference{ID: "test-project-id"}, + ConnectionSecret: &api.LocalObjectReference{Name: "sdk-creds"}, + }, + Scopes: []akov2.ScopeSpec{ + { + Name: "other-cluster", + Type: "CLUSTER", + }, + }, + }, + Status: status.AtlasDatabaseUserStatus{ + Common: api.Common{ + Conditions: []api.Condition{{Type: api.ReadyType, Status: corev1.ConditionTrue}}, + }, + }, + }, + secrets: []client.Object{ + &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "sdk-creds", + Namespace: "default", + }, + Data: map[string][]byte{ + "orgId": []byte("test-pass"), + "publicApiKey": []byte("test-pass"), + "privateApiKey": []byte("test-pass"), + }, + }, + &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "myproject-cluster1-admin", + Namespace: "default", + Labels: map[string]string{ + ProjectLabelKey: "test-project-id", + ClusterLabelKey: "cluster1", + TypeLabelKey: "connection", + }, + }, + }, + }, + expectedDeletion: true, + expectedResult: func() (ctrl.Result, error) { + return workflow.TerminateSilently(nil).WithoutRetry().ReconcileResult() + }, + }, + "success: expired dbuser triggers handleDelete()": { + reqName: "test-project-id$cluster1$admin", + deployment: &akov2.AtlasDeployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: "deployment", + Namespace: "default", + }, + Spec: akov2.AtlasDeploymentSpec{ + DeploymentSpec: &akov2.AdvancedDeploymentSpec{Name: "cluster1"}, + ProjectDualReference: akov2.ProjectDualReference{ + ExternalProjectRef: &akov2.ExternalProjectReference{ID: "test-project-id"}, + ConnectionSecret: &api.LocalObjectReference{Name: "sdk-creds"}, + }, + }, + Status: status.AtlasDeploymentStatus{ + Common: api.Common{ + Conditions: []api.Condition{{Type: api.ReadyType, Status: corev1.ConditionTrue}}, + }, + }, + }, + user: &akov2.AtlasDatabaseUser{ + ObjectMeta: metav1.ObjectMeta{ + Name: "admin", + Namespace: "default", + }, + Spec: akov2.AtlasDatabaseUserSpec{ + Username: "admin", + PasswordSecret: &common.ResourceRef{Name: "admin-password"}, + ProjectDualReference: akov2.ProjectDualReference{ + ExternalProjectRef: &akov2.ExternalProjectReference{ID: "test-project-id"}, + ConnectionSecret: &api.LocalObjectReference{Name: "sdk-creds"}, + }, + DeleteAfterDate: time.Now().UTC().Add(-1 * time.Hour).Format(time.RFC3339), + }, + Status: status.AtlasDatabaseUserStatus{ + Common: api.Common{ + Conditions: []api.Condition{{Type: api.ReadyType, Status: corev1.ConditionTrue}}, + }, + }, + }, + secrets: []client.Object{ + &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "sdk-creds", + Namespace: "default", + }, + Data: map[string][]byte{ + "orgId": []byte("test-pass"), + "publicApiKey": []byte("test-pass"), + "privateApiKey": []byte("test-pass"), + }, + }, + &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "myproject-cluster1-admin", + Namespace: "default", + Labels: map[string]string{ + ProjectLabelKey: "test-project-id", + ClusterLabelKey: "cluster1", + TypeLabelKey: "connection", + }, + }, + }, + }, + expectedDeletion: true, + expectedResult: func() (ctrl.Result, error) { + return workflow.TerminateSilently(nil).WithoutRetry().ReconcileResult() + }, + }, + "success: pair ready will call handleUpsert()": { + reqName: "test-project-id$cluster1$admin", + deployment: &akov2.AtlasDeployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: "deployment", + Namespace: "default", + }, + Spec: akov2.AtlasDeploymentSpec{ + DeploymentSpec: &akov2.AdvancedDeploymentSpec{Name: "cluster1"}, + ProjectDualReference: akov2.ProjectDualReference{ + ProjectRef: &common.ResourceRefNamespaced{ + Name: "my-atlas-project", + Namespace: "default", + }, + }, + }, + Status: status.AtlasDeploymentStatus{ + Common: api.Common{ + Conditions: []api.Condition{{Type: api.ReadyType, Status: corev1.ConditionTrue}}, + }, + ConnectionStrings: &status.ConnectionStrings{ + Standard: "mongodb+srv://cluster1.mongodb.net", + StandardSrv: "mongodb://cluster1.mongodb.net", + }, + }, + }, + user: &akov2.AtlasDatabaseUser{ + ObjectMeta: metav1.ObjectMeta{ + Name: "admin", + Namespace: "default", + }, + Spec: akov2.AtlasDatabaseUserSpec{ + Username: "admin", + PasswordSecret: &common.ResourceRef{Name: "admin-password"}, + ProjectDualReference: akov2.ProjectDualReference{ + ExternalProjectRef: &akov2.ExternalProjectReference{ID: "test-project-id"}, + ConnectionSecret: &api.LocalObjectReference{Name: "sdk-creds"}, + }, + }, + Status: status.AtlasDatabaseUserStatus{ + Common: api.Common{ + Conditions: []api.Condition{{Type: api.ReadyType, Status: corev1.ConditionTrue}}, + }, + }, + }, + project: &akov2.AtlasProject{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-atlas-project", + Namespace: "default", + }, + Spec: akov2.AtlasProjectSpec{ + Name: "MyProject", + }, + Status: status.AtlasProjectStatus{ + ID: "test-project-id", + }, + }, + secrets: []client.Object{ + &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "admin-password", Namespace: "default"}, + Data: map[string][]byte{"password": []byte("test-pass")}, + }, + }, + expectedUpdate: true, + expectedResult: func() (ctrl.Result, error) { + return workflow.TerminateSilently(nil).WithoutRetry().ReconcileResult() + }, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + scheme := runtime.NewScheme() + require.NoError(t, corev1.AddToScheme(scheme)) + require.NoError(t, akov2.AddToScheme(scheme)) + + logger := zaptest.NewLogger(t) + ctx := context.Background() + + objects := make([]client.Object, 0, 3) + if tc.deployment != nil { + objects = append(objects, tc.deployment) + } + if tc.user != nil { + objects = append(objects, tc.user) + } + if tc.project != nil { + objects = append(objects, tc.project) + } + objects = append(objects, tc.secrets...) + + compositeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(objects...). + WithIndex(&akov2.AtlasDeployment{}, indexer.AtlasDeploymentBySpecNameAndProjectID, func(obj client.Object) []string { + d := obj.(*akov2.AtlasDeployment) + if d.Spec.DeploymentSpec == nil || d.Spec.DeploymentSpec.Name == "" { + return nil + } + return []string{"test-project-id-" + d.Spec.DeploymentSpec.Name} + }). + WithIndex(&akov2.AtlasDatabaseUser{}, indexer.AtlasDatabaseUserBySpecUsernameAndProjectID, func(obj client.Object) []string { + u := obj.(*akov2.AtlasDatabaseUser) + if u.Spec.Username == "" { + return nil + } + return []string{"test-project-id-" + u.Spec.Username} + }). + Build() + + atlasProvider := &atlasmock.TestProvider{ + SdkClientSetFunc: func(ctx context.Context, creds *atlas.Credentials, log *zap.SugaredLogger) (*atlas.ClientSet, error) { + projectAPI := mockadmin.NewProjectsApi(t) + + projectAPI.EXPECT(). + GetProject(mock.Anything, "test-project-id"). + Return(admin.GetProjectApiRequest{ApiService: projectAPI}) + + projectAPI.EXPECT(). + GetProjectExecute(mock.AnythingOfType("admin.GetProjectApiRequest")). + Return(&admin.Group{ + Id: pointer.MakePtr("test-project-id"), + Name: "MyProject", + }, nil, nil) + + return &atlas.ClientSet{ + SdkClient20250312002: &admin.APIClient{ + ProjectsApi: projectAPI, + }, + }, nil + }, + IsSupportedFunc: func() bool { return true }, + IsCloudGovFunc: func() bool { return false }, + } + + r := &ConnectionSecretReconciler{ + AtlasReconciler: reconciler.AtlasReconciler{ + Client: compositeClient, + Log: logger.Sugar(), + GlobalSecretRef: types.NamespacedName{ + Name: "global-secret", + Namespace: "default", + }, + AtlasProvider: atlasProvider, + }, + Scheme: scheme, + EventRecorder: record.NewFakeRecorder(10), + } + + req := ctrl.Request{ + NamespacedName: types.NamespacedName{ + Namespace: "default", + Name: tc.reqName, + }, + } + + res, err := r.Reconcile(ctx, req) + expRes, expErr := tc.expectedResult() + + assert.Equal(t, expRes, res) + if expErr != nil { + assert.EqualError(t, err, expErr.Error()) + } else { + assert.NoError(t, err) + } + + if tc.expectedUpdate { + ids, err := r.loadRequestIdentifiers(ctx, req.NamespacedName) + require.NoError(t, err) + ids.ProjectName = "myproject" + + expectedName := CreateK8sFormat(ids.ProjectName, ids.ClusterName, ids.DatabaseUsername) + var outputSecret corev1.Secret + getErr := compositeClient.Get(ctx, types.NamespacedName{ + Namespace: "default", + Name: expectedName, + }, &outputSecret) + assert.NoError(t, getErr, "expected secret %q to exist", expectedName) + } + + if tc.expectedDeletion { + ids, err := r.loadRequestIdentifiers(ctx, req.NamespacedName) + require.NoError(t, err) + + expectedName := CreateK8sFormat(ids.ProjectName, ids.ClusterName, ids.DatabaseUsername) + var check corev1.Secret + getErr := compositeClient.Get(ctx, types.NamespacedName{ + Namespace: "default", + Name: expectedName, + }, &check) + assert.True(t, apiErrors.IsNotFound(getErr), "expected secret %q to be deleted", expectedName) + } + }) + } +} + +func TestConnectionSecretReconcile_MultiDeploymentMultiUser(t *testing.T) { + const ns = "default" + + newDeployment := func(name, cluster string) *akov2.AtlasDeployment { + return &akov2.AtlasDeployment{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: ns}, + Spec: akov2.AtlasDeploymentSpec{ + DeploymentSpec: &akov2.AdvancedDeploymentSpec{Name: cluster}, + ProjectDualReference: akov2.ProjectDualReference{ + ProjectRef: &common.ResourceRefNamespaced{Name: "my-atlas-project", Namespace: ns}, + }, + }, + Status: status.AtlasDeploymentStatus{ + Common: api.Common{ + Conditions: []api.Condition{{Type: api.ReadyType, Status: corev1.ConditionTrue}}, + }, + ConnectionStrings: &status.ConnectionStrings{ + Standard: fmt.Sprintf("mongodb+srv://%s.mongodb.net", cluster), + StandardSrv: fmt.Sprintf("mongodb://%s.mongodb.net", cluster), + }, + }, + } + } + + newUser := func(username, passwordSecret string) *akov2.AtlasDatabaseUser { + return &akov2.AtlasDatabaseUser{ + ObjectMeta: metav1.ObjectMeta{Name: username, Namespace: ns}, + Spec: akov2.AtlasDatabaseUserSpec{ + Username: username, + PasswordSecret: &common.ResourceRef{Name: passwordSecret}, + ProjectDualReference: akov2.ProjectDualReference{ + ProjectRef: &common.ResourceRefNamespaced{Name: "my-atlas-project", Namespace: ns}, + }, + }, + Status: status.AtlasDatabaseUserStatus{ + Common: api.Common{ + Conditions: []api.Condition{{Type: api.ReadyType, Status: corev1.ConditionTrue}}, + }, + }, + } + } + + scheme := runtime.NewScheme() + require.NoError(t, corev1.AddToScheme(scheme)) + require.NoError(t, akov2.AddToScheme(scheme)) + + logger := zaptest.NewLogger(t) + ctx := context.Background() + + // Deployments (2) + deployments := []*akov2.AtlasDeployment{ + newDeployment("dep1", "cluster1"), + newDeployment("dep2", "cluster2"), + } + + // Users (3) + users := []*akov2.AtlasDatabaseUser{ + newUser("admin", "admin-password"), + newUser("user2", "user2-password"), + newUser("user3", "user3-password"), + } + + project := &akov2.AtlasProject{ + ObjectMeta: metav1.ObjectMeta{Name: "my-atlas-project", Namespace: ns}, + Spec: akov2.AtlasProjectSpec{Name: "MyProject"}, + Status: status.AtlasProjectStatus{ID: "test-project-id"}, + } + + secrets := []client.Object{ + &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "admin-password", Namespace: ns}, + Data: map[string][]byte{"password": []byte("adminpass")}, + }, + &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "user2-password", Namespace: ns}, + Data: map[string][]byte{"password": []byte("user2pass")}, + }, + &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "user3-password", Namespace: ns}, + Data: map[string][]byte{"password": []byte("user3pass")}, + }, + } + + objs := make([]client.Object, 0, len(deployments)+len(users)+1+len(secrets)) + for _, d := range deployments { + objs = append(objs, d) + } + for _, u := range users { + objs = append(objs, u) + } + objs = append(objs, project) + objs = append(objs, secrets...) + + clientWithIndexes := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(objs...). + WithIndex(&akov2.AtlasDeployment{}, indexer.AtlasDeploymentBySpecNameAndProjectID, func(obj client.Object) []string { + d := obj.(*akov2.AtlasDeployment) + if d.Spec.DeploymentSpec == nil || d.Spec.DeploymentSpec.Name == "" { + return nil + } + return []string{"test-project-id-" + d.Spec.DeploymentSpec.Name} + }). + WithIndex(&akov2.AtlasDatabaseUser{}, indexer.AtlasDatabaseUserBySpecUsernameAndProjectID, func(obj client.Object) []string { + u := obj.(*akov2.AtlasDatabaseUser) + if u.Spec.Username == "" { + return nil + } + return []string{"test-project-id-" + u.Spec.Username} + }). + Build() + + r := &ConnectionSecretReconciler{ + AtlasReconciler: reconciler.AtlasReconciler{ + Client: clientWithIndexes, + Log: logger.Sugar(), + GlobalSecretRef: types.NamespacedName{Name: "global-secret", Namespace: ns}, + }, + Scheme: scheme, + EventRecorder: record.NewFakeRecorder(10), + } + + for _, d := range deployments { + for _, u := range users { + reqName := fmt.Sprintf("test-project-id$%s$%s", d.Spec.DeploymentSpec.Name, u.Spec.Username) + req := ctrl.Request{ + NamespacedName: types.NamespacedName{ + Namespace: ns, + Name: reqName, + }, + } + + res, err := r.Reconcile(ctx, req) + assert.NoError(t, err, "Reconcile failed for %s", reqName) + assert.Equal(t, ctrl.Result{}, res, "Unexpected result for %s", reqName) + + expectedSecretName := fmt.Sprintf("myproject-%s-%s", d.Spec.DeploymentSpec.Name, u.Spec.Username) + var outputSecret corev1.Secret + err = clientWithIndexes.Get(ctx, types.NamespacedName{ + Namespace: ns, + Name: expectedSecretName, + }, &outputSecret) + assert.NoError(t, err, "Secret not found for %s", reqName) + } + } +} + +func TestGenerateConnectionSecretRequests(t *testing.T) { + const ns = "default" + const projectID = "test-project-id" + + deployment := func(name string) akov2.AtlasDeployment { + return akov2.AtlasDeployment{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: ns}, + Spec: akov2.AtlasDeploymentSpec{ + DeploymentSpec: &akov2.AdvancedDeploymentSpec{Name: name}, + }, + } + } + + user := func(username string, scopes ...string) akov2.AtlasDatabaseUser { + resScopes := make([]akov2.ScopeSpec, 0, len(scopes)) + for _, s := range scopes { + resScopes = append(resScopes, akov2.ScopeSpec{ + Type: akov2.DeploymentScopeType, + Name: s, + }) + } + return akov2.AtlasDatabaseUser{ + ObjectMeta: metav1.ObjectMeta{Name: username, Namespace: ns}, + Spec: akov2.AtlasDatabaseUserSpec{ + Username: username, + Scopes: resScopes, + }, + } + } + + tests := map[string]struct { + deployments []akov2.AtlasDeployment + users []akov2.AtlasDatabaseUser + expected []reconcile.Request + }{ + "no deployments or users": { + deployments: nil, + users: nil, + expected: nil, + }, + "deployment but no users": { + deployments: []akov2.AtlasDeployment{deployment("cluster1")}, + users: nil, + expected: nil, + }, + "users and deployments but all scopes mismatched": { + deployments: []akov2.AtlasDeployment{ + deployment("cluster1"), + deployment("cluster2"), + }, + users: []akov2.AtlasDatabaseUser{ + user("user1", "other1"), + user("user2", "other2"), + }, + expected: nil, + }, + "users and deployments with valid scopes (including global)": { + deployments: []akov2.AtlasDeployment{ + deployment("cluster1"), + deployment("cluster2"), + deployment("cluster3"), + }, + users: []akov2.AtlasDatabaseUser{ + user("admin", "cluster1", "cluster2"), + user("user2", "cluster1"), + user("user3", "cluster2"), + user("user4", "other"), + user("global"), + }, + expected: []reconcile.Request{ + {NamespacedName: types.NamespacedName{ + Namespace: ns, + Name: CreateInternalFormat(projectID, "cluster1", "admin"), + }}, + {NamespacedName: types.NamespacedName{ + Namespace: ns, + Name: CreateInternalFormat(projectID, "cluster2", "admin"), + }}, + {NamespacedName: types.NamespacedName{ + Namespace: ns, + Name: CreateInternalFormat(projectID, "cluster1", "user2"), + }}, + {NamespacedName: types.NamespacedName{ + Namespace: ns, + Name: CreateInternalFormat(projectID, "cluster2", "user3"), + }}, + {NamespacedName: types.NamespacedName{ + Namespace: ns, + Name: CreateInternalFormat(projectID, "cluster1", "global"), + }}, + {NamespacedName: types.NamespacedName{ + Namespace: ns, + Name: CreateInternalFormat(projectID, "cluster2", "global"), + }}, + {NamespacedName: types.NamespacedName{ + Namespace: ns, + Name: CreateInternalFormat(projectID, "cluster3", "global"), + }}, + }, + }, + } + + r := &ConnectionSecretReconciler{} + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + actual := r.generateConnectionSecretRequests(projectID, tc.deployments, tc.users) + assert.ElementsMatch(t, tc.expected, actual) + }) + } +} + +func TestNewDeploymentMapFunc(t *testing.T) { + const ns = "default" + const projectID = "test-project-id" + + scheme := runtime.NewScheme() + require.NoError(t, akov2.AddToScheme(scheme)) + + logger := zaptest.NewLogger(t) + ctx := context.Background() + + deployment := &akov2.AtlasDeployment{ + ObjectMeta: metav1.ObjectMeta{Name: "dep1", Namespace: ns}, + Spec: akov2.AtlasDeploymentSpec{ + DeploymentSpec: &akov2.AdvancedDeploymentSpec{Name: "cluster1"}, + ProjectDualReference: akov2.ProjectDualReference{ + ProjectRef: &common.ResourceRefNamespaced{Name: "my-project", Namespace: ns}, + }, + }, + Status: status.AtlasDeploymentStatus{ + Common: api.Common{ + Conditions: []api.Condition{{Type: api.ReadyType, Status: corev1.ConditionTrue}}, + }, + }, + } + + user := &akov2.AtlasDatabaseUser{ + ObjectMeta: metav1.ObjectMeta{Name: "user1", Namespace: ns}, + Spec: akov2.AtlasDatabaseUserSpec{ + Username: "user1", + Scopes: []akov2.ScopeSpec{ + {Name: "cluster1", Type: akov2.DeploymentScopeType}, + }, + ProjectDualReference: akov2.ProjectDualReference{ + ProjectRef: &common.ResourceRefNamespaced{Name: "my-project", Namespace: ns}, + }, + }, + } + + project := &akov2.AtlasProject{ + ObjectMeta: metav1.ObjectMeta{Name: "my-project", Namespace: ns}, + Status: status.AtlasProjectStatus{ID: projectID}, + } + + objects := []client.Object{deployment, user, project} + + preClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(objects...). + Build() + + userIndexer := indexer.NewAtlasDatabaseUserByProjectIndexer(ctx, preClient, logger) + client := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(objects...). + WithIndex(userIndexer.Object(), userIndexer.Name(), userIndexer.Keys). + Build() + + r := &ConnectionSecretReconciler{ + AtlasReconciler: reconciler.AtlasReconciler{ + Client: client, + Log: logger.Sugar(), + }, + } + + reqs := r.newDeploymentMapFunc(ctx, deployment) + require.Len(t, reqs, 1) + assert.Equal(t, types.NamespacedName{ + Namespace: ns, + Name: CreateInternalFormat(projectID, "cluster1", "user1"), + }, reqs[0].NamespacedName) +} + +func TestNewDatabaseUserMapFunc(t *testing.T) { + const ns = "default" + const projectID = "test-project-id" + + scheme := runtime.NewScheme() + require.NoError(t, akov2.AddToScheme(scheme)) + + logger := zaptest.NewLogger(t) + ctx := context.Background() + + user := &akov2.AtlasDatabaseUser{ + ObjectMeta: metav1.ObjectMeta{Name: "user1", Namespace: ns}, + Spec: akov2.AtlasDatabaseUserSpec{ + Username: "user1", + Scopes: []akov2.ScopeSpec{ + {Name: "cluster1", Type: akov2.DeploymentScopeType}, + }, + ProjectDualReference: akov2.ProjectDualReference{ + ProjectRef: &common.ResourceRefNamespaced{Name: "my-project", Namespace: ns}, + }, + }, + } + + deployment := &akov2.AtlasDeployment{ + ObjectMeta: metav1.ObjectMeta{Name: "dep1", Namespace: ns}, + Spec: akov2.AtlasDeploymentSpec{ + DeploymentSpec: &akov2.AdvancedDeploymentSpec{Name: "cluster1"}, + ProjectDualReference: akov2.ProjectDualReference{ + ProjectRef: &common.ResourceRefNamespaced{Name: "my-project", Namespace: ns}, + }, + }, + } + + project := &akov2.AtlasProject{ + ObjectMeta: metav1.ObjectMeta{Name: "my-project", Namespace: ns}, + Status: status.AtlasProjectStatus{ID: projectID}, + } + + objects := []client.Object{deployment, user, project} + + preClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(objects...). + Build() + + depIndexer := indexer.NewAtlasDeploymentByProjectIndexer(ctx, preClient, logger) + client := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(objects...). + WithIndex(depIndexer.Object(), depIndexer.Name(), depIndexer.Keys). + Build() + + r := &ConnectionSecretReconciler{ + AtlasReconciler: reconciler.AtlasReconciler{ + Client: client, + Log: logger.Sugar(), + }, + } + + reqs := r.newDatabaseUserMapFunc(ctx, user) + require.Len(t, reqs, 1) + assert.Equal(t, types.NamespacedName{ + Namespace: ns, + Name: CreateInternalFormat(projectID, "cluster1", "user1"), + }, reqs[0].NamespacedName) +} diff --git a/internal/controller/connectionsecret/connectionsecret_test.go b/internal/controller/connectionsecret/connectionsecret_test.go new file mode 100644 index 0000000000..9ec82b76a5 --- /dev/null +++ b/internal/controller/connectionsecret/connectionsecret_test.go @@ -0,0 +1,731 @@ +// Copyright 2025 MongoDB Inc +// +// 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 connectionsecret + +import ( + "context" + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + admin "go.mongodb.org/atlas-sdk/v20250312002/admin" + "go.mongodb.org/atlas-sdk/v20250312002/mockadmin" + "go.uber.org/zap" + "go.uber.org/zap/zaptest" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + utilruntime "k8s.io/apimachinery/pkg/util/runtime" + "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/client/fake" + + "github.com/mongodb/mongodb-atlas-kubernetes/v2/api" + akov2 "github.com/mongodb/mongodb-atlas-kubernetes/v2/api/v1" + "github.com/mongodb/mongodb-atlas-kubernetes/v2/api/v1/common" + "github.com/mongodb/mongodb-atlas-kubernetes/v2/api/v1/status" + "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/controller/atlas" + "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/controller/reconciler" + atlasmock "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/mocks/atlas" + "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/pointer" +) + +func Test_resolveProjectName(t *testing.T) { + type expectedResult struct { + expectedProjectName string + expectedError error + } + + projectName := "project-name" + projectID := "test-project-id" + + type testCase struct { + ids ConnSecretIdentifiers + pair ConnSecretPair + project *akov2.AtlasProject + secrets []client.Object + result expectedResult + } + + tests := map[string]testCase{ + "fail: missing deployment and missing user": { + result: expectedResult{ + expectedProjectName: "", + expectedError: fmt.Errorf("unable to resolve ProjectName"), + }, + }, + "fail: missing connectionSecret on deployment and user": { + pair: ConnSecretPair{ + ProjectID: projectID, + Deployment: &akov2.AtlasDeployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: "dep1", + Namespace: "default", + }, + Spec: akov2.AtlasDeploymentSpec{ + DeploymentSpec: &akov2.AdvancedDeploymentSpec{Name: "cluster1"}, + ProjectDualReference: akov2.ProjectDualReference{ + ExternalProjectRef: &akov2.ExternalProjectReference{ID: "test-project-id"}, + }, + }, + }, + User: &akov2.AtlasDatabaseUser{ + ObjectMeta: metav1.ObjectMeta{ + Name: "admin", + Namespace: "default", + }, + Spec: akov2.AtlasDatabaseUserSpec{ + Username: "admin", + PasswordSecret: &common.ResourceRef{Name: "admin-password"}, + ProjectDualReference: akov2.ProjectDualReference{ + ExternalProjectRef: &akov2.ExternalProjectReference{ID: "test-project-id"}, + }, + }, + }, + }, + result: expectedResult{ + expectedProjectName: "", + expectedError: fmt.Errorf("error getting credentials from project reference: failed to read Atlas API credentials from the secret default/global-secret: secrets \"global-secret\" not found"), + }, + }, + "success: projectName is already present": { + ids: ConnSecretIdentifiers{ + ProjectName: projectName, + }, + + result: expectedResult{ + expectedProjectName: projectName, + expectedError: nil, + }, + }, + "success: resolve project name via deployment and project": { + pair: ConnSecretPair{ + Deployment: &akov2.AtlasDeployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: "admin", + Namespace: "default", + }, + Spec: akov2.AtlasDeploymentSpec{ + DeploymentSpec: &akov2.AdvancedDeploymentSpec{Name: "cluster1"}, + ProjectDualReference: akov2.ProjectDualReference{ + ProjectRef: &common.ResourceRefNamespaced{ + Name: "my-project", + Namespace: "default", + }, + }, + }, + }, + }, + project: &akov2.AtlasProject{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-project", + Namespace: "default", + }, + Spec: akov2.AtlasProjectSpec{ + Name: projectName, + }, + }, + result: expectedResult{ + expectedProjectName: projectName, + expectedError: nil, + }, + }, + "success: resolve project name via user and project": { + pair: ConnSecretPair{ + User: &akov2.AtlasDatabaseUser{ + ObjectMeta: metav1.ObjectMeta{ + Name: "admin", + Namespace: "default", + }, + Spec: akov2.AtlasDatabaseUserSpec{ + Username: "admin", + ProjectDualReference: akov2.ProjectDualReference{ + ProjectRef: &common.ResourceRefNamespaced{ + Name: "my-project", + Namespace: "default", + }, + }, + }, + }, + }, + project: &akov2.AtlasProject{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-project", + Namespace: "default", + }, + Spec: akov2.AtlasProjectSpec{ + Name: projectName, + }, + }, + result: expectedResult{ + expectedProjectName: projectName, + expectedError: nil, + }, + }, + "success: resolve via deployment SDK": { + pair: ConnSecretPair{ + ProjectID: projectID, + Deployment: &akov2.AtlasDeployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: "dep1", + Namespace: "default", + }, + Spec: akov2.AtlasDeploymentSpec{ + DeploymentSpec: &akov2.AdvancedDeploymentSpec{Name: "cluster1"}, + ProjectDualReference: akov2.ProjectDualReference{ + ExternalProjectRef: &akov2.ExternalProjectReference{ID: projectID}, + ConnectionSecret: &api.LocalObjectReference{Name: "sdk-creds"}, + }, + }, + }, + }, + secrets: []client.Object{ + &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "sdk-creds", + Namespace: "default", + }, + Data: map[string][]byte{ + "orgId": []byte("test-pass"), + "publicApiKey": []byte("test-pass"), + "privateApiKey": []byte("test-pass"), + }, + }, + }, + result: expectedResult{ + expectedProjectName: projectName, + expectedError: nil, + }, + }, + "success: resolve via user SDK": { + pair: ConnSecretPair{ + ProjectID: projectID, + User: &akov2.AtlasDatabaseUser{ + ObjectMeta: metav1.ObjectMeta{ + Name: "admin", + Namespace: "default", + }, + Spec: akov2.AtlasDatabaseUserSpec{ + Username: "admin", + PasswordSecret: &common.ResourceRef{Name: "admin-password"}, + ProjectDualReference: akov2.ProjectDualReference{ + ExternalProjectRef: &akov2.ExternalProjectReference{ID: "test-project-id"}, + ConnectionSecret: &api.LocalObjectReference{Name: "sdk-creds"}, + }, + }, + }, + }, + secrets: []client.Object{ + &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "sdk-creds", + Namespace: "default", + }, + Data: map[string][]byte{ + "orgId": []byte("test-pass"), + "publicApiKey": []byte("test-pass"), + "privateApiKey": []byte("test-pass"), + }, + }, + }, + result: expectedResult{ + expectedProjectName: projectName, + expectedError: nil, + }, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + scheme := runtime.NewScheme() + utilruntime.Must(corev1.AddToScheme(scheme)) + utilruntime.Must(akov2.AddToScheme(scheme)) + + objs := []client.Object{} + if tc.project != nil { + objs = append(objs, tc.project) + } + if tc.pair.User != nil { + objs = append(objs, tc.pair.User) + } + if tc.pair.Deployment != nil { + objs = append(objs, tc.pair.Deployment) + } + if tc.secrets != nil { + objs = append(objs, tc.secrets...) + } + + logger := zaptest.NewLogger(t) + fakeClient := fake.NewClientBuilder().WithScheme(scheme).WithObjects(objs...).Build() + + atlasProvider := &atlasmock.TestProvider{ + SdkClientSetFunc: func(ctx context.Context, creds *atlas.Credentials, log *zap.SugaredLogger) (*atlas.ClientSet, error) { + projectAPI := mockadmin.NewProjectsApi(t) + + projectAPI.EXPECT(). + GetProject(mock.Anything, projectID). + Return(admin.GetProjectApiRequest{ApiService: projectAPI}) + + projectAPI.EXPECT(). + GetProjectExecute(mock.AnythingOfType("admin.GetProjectApiRequest")). + Return(&admin.Group{ + Id: pointer.MakePtr(projectID), + Name: projectName, + }, nil, nil) + + return &atlas.ClientSet{ + SdkClient20250312002: &admin.APIClient{ + ProjectsApi: projectAPI, + }, + }, nil + }, + IsSupportedFunc: func() bool { return true }, + IsCloudGovFunc: func() bool { return false }, + } + + r := &ConnectionSecretReconciler{ + AtlasReconciler: reconciler.AtlasReconciler{ + Client: fakeClient, + Log: logger.Sugar(), + GlobalSecretRef: types.NamespacedName{Name: "global-secret", Namespace: "default"}, + AtlasProvider: atlasProvider, + }, + EventRecorder: record.NewFakeRecorder(10), + } + + gotName, err := r.resolveProjectName(context.Background(), &tc.ids, &tc.pair) + + require.Equal(t, tc.result.expectedProjectName, gotName) + if tc.result.expectedError != nil { + require.EqualError(t, err, tc.result.expectedError.Error()) + } else { + require.NoError(t, err) + } + }) + } +} + +func Test_handleDelete(t *testing.T) { + type expectedResult struct { + expectedResult ctrl.Result + expectedError error + } + + const ( + ns = "default" + cluster = "cluster1" + username = "admin" + projectID = "test-project-id" + projectName = "myproject" + ) + + type testCase struct { + ids ConnSecretIdentifiers + pair ConnSecretPair + project *akov2.AtlasProject + secrets []client.Object + result expectedResult + } + + tests := map[string]testCase{ + "fail: unresolved project name": { + ids: ConnSecretIdentifiers{ + ClusterName: cluster, + DatabaseUsername: username, + }, + result: expectedResult{ + expectedResult: ctrl.Result{}, + expectedError: fmt.Errorf("project name is empty"), + }, + }, + "success: no secret present": { + ids: ConnSecretIdentifiers{ + ProjectName: projectName, + ClusterName: cluster, + DatabaseUsername: username, + }, + result: expectedResult{ + expectedResult: ctrl.Result{}, + expectedError: nil, + }, + }, + "success: delete existing secret without resolution": { + ids: ConnSecretIdentifiers{ + ProjectName: projectName, + ClusterName: cluster, + DatabaseUsername: username, + }, + secrets: []client.Object{ + &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: CreateK8sFormat(projectName, cluster, username), + Namespace: ns, + }, + }, + }, + result: expectedResult{ + expectedResult: ctrl.Result{}, + expectedError: nil, + }, + }, + "success: delete project with resolution": { + ids: ConnSecretIdentifiers{ + ClusterName: cluster, + DatabaseUsername: username, + }, + pair: ConnSecretPair{ + ProjectID: projectID, + User: &akov2.AtlasDatabaseUser{ + ObjectMeta: metav1.ObjectMeta{Name: username, Namespace: ns}, + Spec: akov2.AtlasDatabaseUserSpec{ + Username: username, + ProjectDualReference: akov2.ProjectDualReference{ + ProjectRef: &common.ResourceRefNamespaced{ + Name: "my-project", + Namespace: ns, + }, + }, + }, + }, + Deployment: &akov2.AtlasDeployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: cluster, + Namespace: ns, + }, + Spec: akov2.AtlasDeploymentSpec{ + DeploymentSpec: &akov2.AdvancedDeploymentSpec{Name: cluster}, + ProjectDualReference: akov2.ProjectDualReference{ + ProjectRef: &common.ResourceRefNamespaced{ + Name: "my-project", + Namespace: ns, + }, + }, + }, + }, + }, + project: &akov2.AtlasProject{ + ObjectMeta: metav1.ObjectMeta{Name: "my-project", Namespace: ns}, + Spec: akov2.AtlasProjectSpec{Name: projectName}, + }, + secrets: []client.Object{ + &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: CreateK8sFormat(projectName, cluster, username), + Namespace: ns, + }, + }, + }, + result: expectedResult{ + expectedResult: ctrl.Result{}, + expectedError: nil, + }, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + scheme := runtime.NewScheme() + utilruntime.Must(corev1.AddToScheme(scheme)) + utilruntime.Must(akov2.AddToScheme(scheme)) + + objects := make([]client.Object, 0, 4) + if tc.project != nil { + objects = append(objects, tc.project) + } + if tc.pair.User != nil { + objects = append(objects, tc.pair.User) + } + if tc.pair.Deployment != nil { + objects = append(objects, tc.pair.Deployment) + } + objects = append(objects, tc.secrets...) + + logger := zaptest.NewLogger(t) + fakeClient := fake.NewClientBuilder().WithScheme(scheme).WithObjects(objects...).Build() + + r := &ConnectionSecretReconciler{ + AtlasReconciler: reconciler.AtlasReconciler{ + Client: fakeClient, + Log: logger.Sugar(), + GlobalSecretRef: types.NamespacedName{Name: "global-secret", Namespace: ns}, + }, + EventRecorder: record.NewFakeRecorder(10), + } + + req := ctrl.Request{ + NamespacedName: types.NamespacedName{Namespace: ns, Name: "any"}, + } + + res, err := r.handleDelete(context.Background(), req, &tc.ids, &tc.pair) + assert.Equal(t, tc.result.expectedResult, res) + if tc.result.expectedError != nil { + require.EqualError(t, err, tc.result.expectedError.Error()) + return + } + require.NoError(t, err) + + if tc.ids.ClusterName != "" && tc.ids.DatabaseUsername != "" { + projectName := tc.ids.ProjectName + if projectName == "" && tc.project != nil { + projectName = tc.project.Spec.Name + } + if projectName != "" { + name := CreateK8sFormat(projectName, tc.ids.ClusterName, tc.ids.DatabaseUsername) + var s corev1.Secret + getErr := fakeClient.Get(context.Background(), types.NamespacedName{Namespace: ns, Name: name}, &s) + require.True(t, apierrors.IsNotFound(getErr), "expected secret %s to be deleted", name) + } + } + }) + } +} + +func Test_handleUpsert(t *testing.T) { + type expectedResult struct { + expectedResult ctrl.Result + expectedError error + } + + const ( + ns = "default" + cluster = "cluster1" + username = "admin" + projectID = "test-project-id" + projectName = "myproject" + ) + + newDeployment := func() *akov2.AtlasDeployment { + return &akov2.AtlasDeployment{ + ObjectMeta: metav1.ObjectMeta{Name: "dep1", Namespace: ns}, + Spec: akov2.AtlasDeploymentSpec{ + DeploymentSpec: &akov2.AdvancedDeploymentSpec{Name: cluster}, + }, + Status: status.AtlasDeploymentStatus{ + Common: api.Common{ + Conditions: []api.Condition{{Type: api.ReadyType, Status: corev1.ConditionTrue}}, + }, + ConnectionStrings: &status.ConnectionStrings{ + Standard: "mongodb://cluster1.mongodb.net/?authSource=admin", + StandardSrv: "mongodb+srv://cluster1.mongodb.net/?authSource=admin", + PrivateEndpoint: []status.PrivateEndpoint{ + { + ConnectionString: "mongodb://pe1.mongodb.net", + SRVConnectionString: "mongodb+srv://pe1.mongodb.net", + SRVShardOptimizedConnectionString: "mongodb+srv://pe1-shard.mongodb.net", + }, + { + ConnectionString: "mongodb://pe2.mongodb.net", + SRVConnectionString: "mongodb+srv://pe2.mongodb.net", + SRVShardOptimizedConnectionString: "mongodb+srv://pe2-shard.mongodb.net", + }, + }, + }, + }, + } + } + + newUser := func() *akov2.AtlasDatabaseUser { + return &akov2.AtlasDatabaseUser{ + ObjectMeta: metav1.ObjectMeta{Name: username, Namespace: ns}, + Spec: akov2.AtlasDatabaseUserSpec{ + Username: username, + PasswordSecret: &common.ResourceRef{Name: "admin-password"}, + }, + Status: status.AtlasDatabaseUserStatus{ + Common: api.Common{ + Conditions: []api.Condition{{Type: api.ReadyType, Status: corev1.ConditionTrue}}, + }, + }, + } + } + + newPasswordSecret := func() *corev1.Secret { + return &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "admin-password", Namespace: ns}, + Data: map[string][]byte{passwordKey: []byte("test-pass")}, + } + } + + newExistingConnSecret := func() *corev1.Secret { + return &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: CreateK8sFormat(projectName, cluster, username), + Namespace: ns, + Labels: map[string]string{ + TypeLabelKey: CredLabelVal, + ProjectLabelKey: projectID, + ClusterLabelKey: cluster, + }, + }, + Data: map[string][]byte{ + userNameKey: []byte("beforeusername"), + passwordKey: []byte("beforepassword"), + standardKey: []byte("mongodb://cluster1.mongodb.net/?authSource=admin"), + standardKeySrv: []byte("mongodb+srv://cluster1.mongodb.net/?authSource=admin"), + }, + } + } + + tests := map[string]struct { + ids ConnSecretIdentifiers + pair ConnSecretPair + project *akov2.AtlasProject + secrets []client.Object + result expectedResult + }{ + "fail: unresolved project name": { + ids: ConnSecretIdentifiers{ + ClusterName: cluster, + DatabaseUsername: username, + }, + result: expectedResult{ + expectedResult: ctrl.Result{}, + expectedError: fmt.Errorf("project name is empty"), + }, + }, + "success: test create": { + ids: ConnSecretIdentifiers{ + ProjectID: projectID, + ProjectName: projectName, + ClusterName: cluster, + DatabaseUsername: username, + }, + pair: ConnSecretPair{ + Deployment: newDeployment(), + User: newUser(), + }, + secrets: []client.Object{newPasswordSecret()}, + result: expectedResult{ + expectedResult: ctrl.Result{}, + expectedError: nil, + }, + }, + "success: test update": { + ids: ConnSecretIdentifiers{ + ProjectID: projectID, + ProjectName: projectName, + ClusterName: cluster, + DatabaseUsername: username, + }, + pair: ConnSecretPair{ + Deployment: newDeployment(), + User: newUser(), + }, + secrets: []client.Object{ + newPasswordSecret(), + newExistingConnSecret(), + }, + result: expectedResult{ + expectedResult: ctrl.Result{}, + expectedError: nil, + }, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + scheme := runtime.NewScheme() + utilruntime.Must(corev1.AddToScheme(scheme)) + utilruntime.Must(akov2.AddToScheme(scheme)) + + objects := make([]client.Object, 0, len(tc.secrets)+3) + if tc.project != nil { + objects = append(objects, tc.project) + } + if tc.pair.User != nil { + objects = append(objects, tc.pair.User) + } + if tc.pair.Deployment != nil { + objects = append(objects, tc.pair.Deployment) + } + objects = append(objects, tc.secrets...) + + logger := zaptest.NewLogger(t) + fakeClient := fake.NewClientBuilder().WithScheme(scheme).WithObjects(objects...).Build() + + r := &ConnectionSecretReconciler{ + AtlasReconciler: reconciler.AtlasReconciler{ + Client: fakeClient, + Log: logger.Sugar(), + GlobalSecretRef: types.NamespacedName{Name: "global-secret", Namespace: ns}, + }, + Scheme: scheme, + EventRecorder: record.NewFakeRecorder(10), + } + + req := ctrl.Request{NamespacedName: types.NamespacedName{Namespace: ns, Name: "any"}} + + res, err := r.handleUpsert(context.Background(), req, &tc.ids, &tc.pair) + assert.Equal(t, tc.result.expectedResult, res) + if tc.result.expectedError != nil { + require.EqualError(t, err, tc.result.expectedError.Error()) + return + } + require.NoError(t, err) + + secretName := CreateK8sFormat(projectName, cluster, username) + var s corev1.Secret + getErr := fakeClient.Get(context.Background(), types.NamespacedName{Namespace: ns, Name: secretName}, &s) + require.NoError(t, getErr) + + require.Equal(t, CredLabelVal, s.Labels[TypeLabelKey]) + require.Equal(t, projectID, s.Labels[ProjectLabelKey]) + require.Equal(t, cluster, s.Labels[ClusterLabelKey]) + + require.Equal(t, username, string(s.Data[userNameKey])) + require.Equal(t, "test-pass", string(s.Data[passwordKey])) + + // Verify all connection string variants + urlsToCheck := map[string]string{ + standardKey: "mongodb://cluster1.mongodb.net/?authSource=admin", + standardKeySrv: "mongodb+srv://cluster1.mongodb.net/?authSource=admin", + } + + privateEndpoints := []status.PrivateEndpoint{ + { + ConnectionString: "mongodb://pe1.mongodb.net", + SRVConnectionString: "mongodb+srv://pe1.mongodb.net", + SRVShardOptimizedConnectionString: "mongodb+srv://pe1-shard.mongodb.net", + }, + { + ConnectionString: "mongodb://pe2.mongodb.net", + SRVConnectionString: "mongodb+srv://pe2.mongodb.net", + SRVShardOptimizedConnectionString: "mongodb+srv://pe2-shard.mongodb.net", + }, + } + + for i, pe := range privateEndpoints { + var suffix string + if i != 0 { + suffix = fmt.Sprint(i) + } + + urlsToCheck[fmt.Sprintf("%s%s", privateKey, suffix)] = pe.ConnectionString + urlsToCheck[fmt.Sprintf("%s%s", privateSrvKey, suffix)] = pe.SRVConnectionString + urlsToCheck[fmt.Sprintf("%s%s", privateShardKey, suffix)] = pe.SRVShardOptimizedConnectionString + } + + for key, baseURL := range urlsToCheck { + want, _ := CreateURL(baseURL, username, "test-pass") + require.Equal(t, want, string(s.Data[key]), "mismatch for %s", key) + } + }) + } +} diff --git a/internal/controller/connectionsecret/connectionsecrets.go b/internal/controller/connectionsecret/connectionsecrets.go index 4e5150aeaa..0dc271c6bc 100644 --- a/internal/controller/connectionsecret/connectionsecrets.go +++ b/internal/controller/connectionsecret/connectionsecrets.go @@ -17,198 +17,290 @@ package connectionsecret import ( "context" "fmt" - "strings" + "net/url" - "go.uber.org/zap" corev1 "k8s.io/api/core/v1" - "k8s.io/apimachinery/pkg/labels" - "k8s.io/client-go/tools/record" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" akov2 "github.com/mongodb/mongodb-atlas-kubernetes/v2/api/v1" + "github.com/mongodb/mongodb-atlas-kubernetes/v2/api/v1/common" "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/controller/workflow" "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/kube" - "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/stringutil" - "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/translation/deployment" - "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/translation/project" ) -const ConnectionSecretsEnsuredEvent = "ConnectionSecretsEnsured" +const ( + ProjectLabelKey string = "atlas.mongodb.com/project-id" + ClusterLabelKey string = "atlas.mongodb.com/cluster-name" + TypeLabelKey = "atlas.mongodb.com/type" + CredLabelVal = "credentials" -func ReapOrphanConnectionSecrets(ctx context.Context, k8sClient client.Client, projectID, namespace string, projectDeploymentNames []string) ([]string, error) { - secretList := &corev1.SecretList{} - labelSelector := labels.SelectorFromSet(labels.Set{TypeLabelKey: CredLabelVal, ProjectLabelKey: projectID}) - err := k8sClient.List(context.Background(), secretList, &client.ListOptions{ - LabelSelector: labelSelector, - Namespace: namespace, - }) - if err != nil { - return nil, fmt.Errorf("failed listing possible orphan secrets: %w", err) - } + userNameKey string = "username" + passwordKey string = "password" + standardKey string = "connectionStringStandard" + standardKeySrv string = "connectionStringStandardSrv" + privateKey string = "connectionStringPrivate" + privateSrvKey string = "connectionStringPrivateSrv" + privateShardKey string = "connectionStringPrivateShard" +) - removedOrphanSecrets := []string{} - for _, secret := range secretList.Items { - clusterName, ok := secret.Labels[ClusterLabelKey] - if !ok { - continue - } - if clusterExists := stringutil.Contains(projectDeploymentNames, clusterName); clusterExists { - continue - } - if err := k8sClient.Delete(ctx, &secret); err != nil { - return nil, fmt.Errorf("failed to remove orphan connection Secret: %w", err) - } else { - removedOrphanSecrets = append(removedOrphanSecrets, fmt.Sprintf("%s/%s", namespace, secret.Name)) - } +// resolveProjectNameK8s retrieves the ProjectName by K8s AtlasProject resource if available +func (r *ConnectionSecretReconciler) resolveProjectNameK8s(ctx context.Context, p *ConnSecretPair) (string, error) { + var ref *common.ResourceRefNamespaced + + if p.Deployment != nil && p.Deployment.Spec.ProjectRef != nil { + ref = p.Deployment.Spec.ProjectRef + } else if p.User != nil && p.User.Spec.ProjectRef != nil { + ref = p.User.Spec.ProjectRef } - return removedOrphanSecrets, nil -} -func CreateOrUpdateConnectionSecrets(ctx *workflow.Context, k8sClient client.Client, ds deployment.AtlasDeploymentsService, recorder record.EventRecorder, project *project.Project, dbUser akov2.AtlasDatabaseUser) workflow.DeprecatedResult { - conns, err := ds.ListDeploymentConnections(ctx.Context, project.ID) - if err != nil { - return workflow.Terminate(workflow.DatabaseUserConnectionSecretsNotCreated, err) + if ref == nil { + return "", nil } - // ensure secrets for both deployments and advanced deployment. - if result := createOrUpdateConnectionSecretsFromDeploymentSecrets(ctx, k8sClient, recorder, project, dbUser, conns); !result.IsOk() { - return result + proj := &akov2.AtlasProject{} + if err := r.Client.Get(ctx, kube.ObjectKey(ref.Namespace, ref.Name), proj); err != nil { + return "", fmt.Errorf("failed to retrieve AtlasProject %q: %w", ref.Name, err) } - return workflow.OK() + return kube.NormalizeIdentifier(proj.Spec.Name), nil } -func createOrUpdateConnectionSecretsFromDeploymentSecrets(ctx *workflow.Context, k8sClient client.Client, recorder record.EventRecorder, project *project.Project, dbUser akov2.AtlasDatabaseUser, conns []deployment.Connection) workflow.DeprecatedResult { - requeue := false - secrets := make([]string, 0) +// resolveProjectName finds the respective project name for the given projectID in the identifiers +func (r *ConnectionSecretReconciler) resolveProjectName(ctx context.Context, ids *ConnSecretIdentifiers, pair *ConnSecretPair) (string, error) { + if ids.ProjectName != "" { + return ids.ProjectName, nil + } + + // Prefer K8s path when a ProjectRef exists on either resource. + if projectName, err := r.resolveProjectNameK8s(ctx, pair); err == nil && projectName != "" { + return projectName, nil + } - for _, di := range conns { - scopes := dbUser.GetScopes(akov2.DeploymentScopeType) - if len(scopes) != 0 && !stringutil.Contains(scopes, di.Name) { - continue + // SDK path from Deployment + if pair.Deployment != nil { + connCfg, err := r.ResolveConnectionConfig(ctx, pair.Deployment) + if err != nil { + return "", err } - // Deployment may be not ready yet, so no connection urls - skipping - // Note, that Atlas usually returns the not-nil connection strings with empty fields in it - if di.SrvConnURL == "" { - ctx.Log.Debugw("Deployment is not ready yet - not creating a connection Secret", "deployment", di.Name) - requeue = true - continue + sdkClientSet, err := r.AtlasProvider.SdkClientSet(ctx, connCfg.Credentials, r.Log) + if err != nil { + return "", err } - password, err := dbUser.ReadPassword(ctx.Context, k8sClient) + atlasProject, err := r.ResolveProject(ctx, sdkClientSet.SdkClient20250312002, pair.Deployment) if err != nil { - return workflow.Terminate(workflow.DatabaseUserConnectionSecretsNotCreated, err) + return "", err } - data := ConnectionData{ - DBUserName: dbUser.Spec.Username, - Password: password, - ConnURL: di.ConnURL, - SrvConnURL: di.SrvConnURL, + if atlasProject.Name != "" { + return atlasProject.Name, nil } - FillPrivateConns(di, &data) + } - var secretName string - if secretName, err = Ensure(ctx.Context, k8sClient, dbUser.Namespace, project.Name, project.ID, di.Name, data); err != nil { - return workflow.Terminate(workflow.DatabaseUserConnectionSecretsNotCreated, err) + // SDK path from User + if pair.User != nil { + connCfg, err := r.ResolveConnectionConfig(ctx, pair.User) + if err != nil { + return "", err + } + sdkClientSet, err := r.AtlasProvider.SdkClientSet(ctx, connCfg.Credentials, r.Log) + if err != nil { + return "", err + } + atlasProject, err := r.ResolveProject(ctx, sdkClientSet.SdkClient20250312002, pair.User) + if err != nil { + return "", err + } + if atlasProject.Name != "" { + return atlasProject.Name, nil } - secrets = append(secrets, secretName) - ctx.Log.Debugw("Ensured connection Secret up-to-date", "secretname", secretName) } - if len(secrets) > 0 { - recorder.Eventf(&dbUser, "Normal", ConnectionSecretsEnsuredEvent, "Connection Secrets were created/updated: %s", strings.Join(secrets, ", ")) + return "", fmt.Errorf("unable to resolve ProjectName") +} + +// handleDelete manages the case where we will delete the connection secret +func (r *ConnectionSecretReconciler) handleDelete( + ctx context.Context, req ctrl.Request, ids *ConnSecretIdentifiers, pair *ConnSecretPair) (ctrl.Result, error) { + log := r.Log.With("ns", req.Namespace, "name", req.Name) + + // ProjectName is required for ConnectionSecret metadata.name to delete + projectName, err := r.resolveProjectName(ctx, ids, pair) + if projectName == "" { + err = fmt.Errorf("project name is empty") } + if err != nil { + log.Errorw("failed to resolve project name", "reason", workflow.ConnSecretUnresolvedProjectName, "error", err) + return workflow.Terminate(workflow.ConnSecretUnresolvedProjectName, err).ReconcileResult() + } + + log.Debugw("project name resolved for delete") - if err := cleanupStaleSecrets(ctx, k8sClient, project.ID, dbUser); err != nil { - return workflow.Terminate(workflow.DatabaseUserStaleConnectionSecrets, err) + name := CreateK8sFormat(projectName, ids.ClusterName, ids.DatabaseUsername) + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: req.Namespace, + }, } - if requeue { - return workflow.InProgress(workflow.DatabaseUserConnectionSecretsNotCreated, "Waiting for deployments to get created/updated") + // Delete the secret + if err := r.Client.Delete(ctx, secret); err != nil { + if apierrors.IsNotFound(err) { + log.Debugw("no secret to delete; already gone") + return workflow.TerminateSilently(nil).WithoutRetry().ReconcileResult() + } + log.Errorw("unable to delete secret", "reason", workflow.ConnSecretFailedDeletion, "error", err) + return workflow.Terminate(workflow.ConnSecretFailedDeletion, err).ReconcileResult() } - return workflow.OK() + + log.Infow("secret deleted", "reason", workflow.ConnSecretDeleted) + r.EventRecorder.Event(secret, corev1.EventTypeNormal, "Deleted", "ConnectionSecret deleted") + return workflow.TerminateSilently(nil).WithoutRetry().ReconcileResult() } -func cleanupStaleSecrets(ctx *workflow.Context, k8sClient client.Client, projectID string, user akov2.AtlasDatabaseUser) error { - if err := removeStaleByScope(ctx, k8sClient, projectID, user); err != nil { - return err +// handleUpsert manages the case where we will create or update the connection secret +func (r *ConnectionSecretReconciler) handleUpsert( + ctx context.Context, req ctrl.Request, ids *ConnSecretIdentifiers, pair *ConnSecretPair) (ctrl.Result, error) { + log := r.Log.With("ns", req.Namespace, "name", req.Name) + + // ProjectName is required for ConnectionSecret metadata.name to create or update + projectName, err := r.resolveProjectName(ctx, ids, pair) + if projectName == "" { + err = fmt.Errorf("project name is empty") } - // Performing the cleanup of old secrets only if the username has changed - if user.Status.UserName != user.Spec.Username { - // Note, that we pass the username from the status, not from the spec - return RemoveStaleSecretsByUserName(ctx.Context, k8sClient, projectID, user.Status.UserName, user, ctx.Log) + if err != nil { + log.Errorw("failed to resolve project name", "reason", workflow.ConnSecretFailedToResolveProjectName, "error", err) + return workflow.Terminate(workflow.ConnSecretFailedToResolveProjectName, err).ReconcileResult() } - return nil + ids.ProjectName = projectName + log.Debugw("project name resolved for upsert") + + // Build connection data + data, err := r.buildConnectionData(ctx, pair) + if err != nil { + log.Errorw("failed to build connection data", "reason", workflow.ConnSecretFailedToBuildData, "error", err) + return workflow.Terminate(workflow.ConnSecretFailedToBuildData, err).ReconcileResult() + } + log.Debugw("connection data built") + + if err := r.ensureSecret(ctx, ids, pair, data); err != nil { + return workflow.Terminate(workflow.ConnSecretFailedToCreateSecret, err).ReconcileResult() + } + + log.Infow("secret upserted", "reason", workflow.ConnSecretUpsert) + return workflow.OK().ReconcileResult() } -// removeStaleByScope removes the secrets that are not relevant due to changes to 'scopes' field for the AtlasDatabaseUser. -func removeStaleByScope(ctx *workflow.Context, k8sClient client.Client, projectID string, user akov2.AtlasDatabaseUser) error { - scopes := user.GetScopes(akov2.DeploymentScopeType) - if len(scopes) == 0 { - return nil +// ensureSecret creates or updates the Secret for the given identifiers and connection data +func (r *ConnectionSecretReconciler) ensureSecret( + ctx context.Context, ids *ConnSecretIdentifiers, pair *ConnSecretPair, data ConnSecretData) error { + namespace := pair.User.GetNamespace() + log := r.Log.With("ns", namespace, "project", ids.ProjectName) + + name := CreateK8sFormat(ids.ProjectName, ids.ClusterName, ids.DatabaseUsername) + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + }, } - secrets, err := ListByUserName(ctx.Context, k8sClient, user.Namespace, projectID, user.Spec.Username) - if err != nil { + + // Populate Secret data/labels + if err := fillConnSecretData(secret, ids, data); err != nil { + log.Errorw("failed to fill secret data", "reason", workflow.ConnSecretFailedToFillData, "error", err) return err } - for i, s := range secrets { - deployment, ok := s.Labels[ClusterLabelKey] - if !ok { - continue - } - if !stringutil.Contains(scopes, deployment) { - if err = k8sClient.Delete(ctx.Context, &secrets[i]); err != nil { + + // Add owners + if err := controllerutil.SetControllerReference(pair.User, secret, r.Scheme); err != nil { + log.Errorw("failed to set controller owner", "reason", workflow.ConnSecretFailedToSetOwnerReferences, "error", err) + return err + } + + // Upsert secret + if err := r.Client.Create(ctx, secret); err != nil { + if apierrors.IsAlreadyExists(err) { + // Fetch existing to get ResourceVersion, then update + current := &corev1.Secret{} + if err := r.Client.Get(ctx, client.ObjectKeyFromObject(secret), current); err != nil { + log.Errorw("failed to fetch existing secret", "reason", workflow.ConnSecretFailedToGetSecret, "error", err) + return err + } + secret.ResourceVersion = current.ResourceVersion + if err := r.Client.Update(ctx, secret); err != nil { + log.Errorw("failed to update secret", "reason", workflow.ConnSecretFailedToUpdateSecret, "error", err) return err } - ctx.Log.Debugw("Removed connection Secret as it's not referenced by the AtlasDatabaseUser anymore", "secretname", s.Name) + } else { + log.Errorw("failed to create secret", "reason", workflow.ConnSecretFailedToCreateSecret, "error", err) + return err } } return nil } -// RemoveStaleSecretsByUserName removes the stale secrets when the database user name changes (as it's used as a part of Secret name) -func RemoveStaleSecretsByUserName(ctx context.Context, k8sClient client.Client, projectID, userName string, user akov2.AtlasDatabaseUser, log *zap.SugaredLogger) error { - secrets, err := ListByUserName(ctx, k8sClient, user.Namespace, projectID, userName) - if err != nil { +// fillConnSecretData fills the stringData of the secret +func fillConnSecretData(secret *corev1.Secret, ids *ConnSecretIdentifiers, data ConnSecretData) error { + var err error + username := data.DBUserName + password := data.Password + + if data.ConnURL, err = CreateURL(data.ConnURL, username, password); err != nil { return err } - var lastError error - removed := 0 - for i := range secrets { - if err = k8sClient.Delete(ctx, &secrets[i]); err != nil { - log.Errorf("Failed to remove connection Secret: %v", err) - lastError = err - } else { - log.Debugw("Removed connection Secret", "secret", kube.ObjectKeyFromObject(&secrets[i])) - removed++ + if data.SrvConnURL, err = CreateURL(data.SrvConnURL, username, password); err != nil { + return err + } + for idx, privateConn := range data.PrivateConnURLs { + if data.PrivateConnURLs[idx].PvtConnURL, err = CreateURL(privateConn.PvtConnURL, username, password); err != nil { + return err + } + if data.PrivateConnURLs[idx].PvtSrvConnURL, err = CreateURL(privateConn.PvtSrvConnURL, username, password); err != nil { + return err + } + if data.PrivateConnURLs[idx].PvtShardConnURL, err = CreateURL(privateConn.PvtShardConnURL, username, password); err != nil { + return err } } - if removed > 0 { - log.Infof("Removed %d connection secrets", removed) + + secret.Labels = map[string]string{ + TypeLabelKey: CredLabelVal, + ProjectLabelKey: ids.ProjectID, + ClusterLabelKey: ids.ClusterName, } - return lastError -} -func FillPrivateConns(conn deployment.Connection, data *ConnectionData) { - if conn.PrivateURL != "" { - data.PrivateConnURLs = append(data.PrivateConnURLs, PrivateLinkConnURLs{ - PvtConnURL: conn.PrivateURL, - PvtSrvConnURL: conn.SrvPrivateURL, - }) + secret.Data = map[string][]byte{ + userNameKey: []byte(data.DBUserName), + passwordKey: []byte(data.Password), + standardKey: []byte(data.ConnURL), + standardKeySrv: []byte(data.SrvConnURL), + privateKey: []byte(""), + privateSrvKey: []byte(""), } - if conn.Serverless { - for _, pe := range conn.PrivateEndpoints { - data.PrivateConnURLs = append(data.PrivateConnURLs, PrivateLinkConnURLs{ - PvtSrvConnURL: pe.ServerURL, - }) - } - } else { - for _, pe := range conn.PrivateEndpoints { - data.PrivateConnURLs = append(data.PrivateConnURLs, PrivateLinkConnURLs{ - PvtConnURL: pe.URL, - PvtSrvConnURL: pe.ServerURL, - PvtShardConnURL: pe.ShardURL, - }) + for idx, privateConn := range data.PrivateConnURLs { + var suffix string + if idx != 0 { + suffix = fmt.Sprint(idx) } + secret.Data[privateKey+suffix] = []byte(privateConn.PvtConnURL) + secret.Data[privateSrvKey+suffix] = []byte(privateConn.PvtSrvConnURL) + secret.Data[privateShardKey+suffix] = []byte(privateConn.PvtShardConnURL) + } + + return nil +} + +// CreateURL creates the connection secrets urls for the data fields +func CreateURL(connURL, username, password string) (string, error) { + cs, err := url.Parse(connURL) + if err != nil { + return "", err } + + cs.User = url.UserPassword(username, password) + return cs.String(), nil } diff --git a/internal/controller/connectionsecret/connectionsecrets_test.go b/internal/controller/connectionsecret/connectionsecrets_test.go deleted file mode 100644 index 00446a3104..0000000000 --- a/internal/controller/connectionsecret/connectionsecrets_test.go +++ /dev/null @@ -1,165 +0,0 @@ -// Copyright 2025 MongoDB Inc -// -// 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 connectionsecret_test - -import ( - "context" - "fmt" - "testing" - - "github.com/stretchr/testify/assert" - corev1 "k8s.io/api/core/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime" - utilruntime "k8s.io/apimachinery/pkg/util/runtime" - "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/client/fake" - - akov2 "github.com/mongodb/mongodb-atlas-kubernetes/v2/api/v1" - "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/controller/connectionsecret" -) - -const ( - testProjectID = "123456" - - testNamespace = "some-namespace" -) - -func TestReapOrphanConnectionSecrets(t *testing.T) { - scheme := runtime.NewScheme() - utilruntime.Must(corev1.AddToScheme(scheme)) - utilruntime.Must(akov2.AddToScheme(scheme)) - - for _, tc := range []struct { - title string - deployments []string - objects []client.Object - expectedErr error - expectedRemovals []string - }{ - { - title: "Empty list of secrets returns empty list of removals", - expectedRemovals: []string{}, - }, - - { - title: "Matching secrets do not get removed", - deployments: sampleDeployments(), - objects: matchingSecrets(), - expectedRemovals: []string{}, - }, - - { - title: "Secrets to non existing clusters get removed", - deployments: sampleDeployments(), - objects: merge(matchingSecrets(), nonMatchingSecrets()), - expectedRemovals: namesOf(nonMatchingSecrets()), - }, - } { - t.Run(tc.title, func(t *testing.T) { - fakeClient := fake.NewClientBuilder(). - WithScheme(scheme). - WithObjects(tc.objects...).Build() - removedOrphans, err := connectionsecret.ReapOrphanConnectionSecrets( - context.Background(), - fakeClient, - testProjectID, - testNamespace, - tc.deployments, - ) - assert.Equal(t, tc.expectedErr, err) - assert.Equal(t, tc.expectedRemovals, removedOrphans) - }) - } -} - -func sampleDeployments() []string { - return []string{"cluster1", "serverless2"} -} - -func matchingSecrets() []client.Object { - return []client.Object{ - &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: "secret1", - Namespace: testNamespace, - Labels: map[string]string{ - connectionsecret.ClusterLabelKey: "cluster1", - connectionsecret.ProjectLabelKey: testProjectID, - connectionsecret.TypeLabelKey: connectionsecret.CredLabelVal, - }, - }, - }, - - &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: "secret2", - Namespace: testNamespace, - Labels: map[string]string{ - connectionsecret.ClusterLabelKey: "serverless2", - connectionsecret.ProjectLabelKey: testProjectID, - connectionsecret.TypeLabelKey: connectionsecret.CredLabelVal, - }, - }, - }, - } -} - -func nonMatchingSecrets() []client.Object { - return []client.Object{ - &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: "secret3", - Namespace: testNamespace, - Labels: map[string]string{ - connectionsecret.ClusterLabelKey: "cluster3", - connectionsecret.ProjectLabelKey: testProjectID, - connectionsecret.TypeLabelKey: connectionsecret.CredLabelVal, - }, - }, - }, - - &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: "secret4", - Namespace: testNamespace, - Labels: map[string]string{ - connectionsecret.ClusterLabelKey: "serverless4", - connectionsecret.ProjectLabelKey: testProjectID, - connectionsecret.TypeLabelKey: connectionsecret.CredLabelVal, - }, - }, - }, - } -} - -func namesOf(objs []client.Object) []string { - names := make([]string, 0, len(objs)) - for _, obj := range objs { - names = append(names, fmt.Sprintf("%s/%s", obj.GetNamespace(), obj.GetName())) - } - return names -} - -func merge(objs ...[]client.Object) []client.Object { - if len(objs) == 0 { - return []client.Object{} - } - result := objs[0] - for i := 1; i < len(objs); i++ { - result = append(result, objs[i]...) - } - return result -} diff --git a/internal/controller/connectionsecret/ensuresecret.go b/internal/controller/connectionsecret/ensuresecret.go deleted file mode 100644 index 5562b11374..0000000000 --- a/internal/controller/connectionsecret/ensuresecret.go +++ /dev/null @@ -1,149 +0,0 @@ -// Copyright 2025 MongoDB Inc -// -// 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 connectionsecret - -import ( - "context" - "fmt" - "net/url" - - corev1 "k8s.io/api/core/v1" - apierrors "k8s.io/apimachinery/pkg/api/errors" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "sigs.k8s.io/controller-runtime/pkg/client" - - "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/kube" -) - -const ( - ProjectLabelKey string = "atlas.mongodb.com/project-id" - ClusterLabelKey string = "atlas.mongodb.com/cluster-name" - TypeLabelKey = "atlas.mongodb.com/type" - CredLabelVal = "credentials" - - standardKey string = "connectionStringStandard" - standardKeySrv string = "connectionStringStandardSrv" - privateKey string = "connectionStringPrivate" - privateKeySrv string = "connectionStringPrivateSrv" - privateShardKey string = "connectionStringPrivateShard" - userNameKey string = "username" - passwordKey string = "password" -) - -type ConnectionData struct { - DBUserName string - Password string - ConnURL string - SrvConnURL string - PrivateConnURLs []PrivateLinkConnURLs -} - -type PrivateLinkConnURLs struct { - PvtConnURL string - PvtSrvConnURL string - PvtShardConnURL string -} - -// Ensure creates or updates the connection Secret for the specific cluster and db user. Returns the name of the Secret -// created. -func Ensure(ctx context.Context, client client.Client, namespace, projectName, projectID, clusterName string, data ConnectionData) (string, error) { - var getError error - s := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{ - Name: formatSecretName(projectName, clusterName, data.DBUserName), - Namespace: namespace, - }} - if getError = client.Get(ctx, kube.ObjectKeyFromObject(s), s); getError != nil && !apierrors.IsNotFound(getError) { - return "", getError - } - if err := fillSecret(s, projectID, clusterName, data); err != nil { - return "", err - } - if getError != nil { - // Creating - return s.Name, client.Create(ctx, s) - } - - return s.Name, client.Update(ctx, s) -} - -func fillSecret(secret *corev1.Secret, projectID string, clusterName string, data ConnectionData) error { - var err error - if data.ConnURL, err = AddCredentialsToConnectionURL(data.ConnURL, data.DBUserName, data.Password); err != nil { - return err - } - if data.SrvConnURL, err = AddCredentialsToConnectionURL(data.SrvConnURL, data.DBUserName, data.Password); err != nil { - return err - } - for idx, privateConn := range data.PrivateConnURLs { - if data.PrivateConnURLs[idx].PvtConnURL, err = AddCredentialsToConnectionURL(privateConn.PvtConnURL, data.DBUserName, data.Password); err != nil { - return err - } - if data.PrivateConnURLs[idx].PvtSrvConnURL, err = AddCredentialsToConnectionURL(privateConn.PvtSrvConnURL, data.DBUserName, data.Password); err != nil { - return err - } - if data.PrivateConnURLs[idx].PvtShardConnURL, err = AddCredentialsToConnectionURL(privateConn.PvtShardConnURL, data.DBUserName, data.Password); err != nil { - return err - } - } - - secret.Labels = map[string]string{ - TypeLabelKey: CredLabelVal, - ProjectLabelKey: projectID, - ClusterLabelKey: kube.NormalizeLabelValue(clusterName), - } - - secret.Data = map[string][]byte{ - userNameKey: []byte(data.DBUserName), - passwordKey: []byte(data.Password), - standardKey: []byte(data.ConnURL), - standardKeySrv: []byte(data.SrvConnURL), - privateKey: []byte(""), - privateKeySrv: []byte(""), - } - - for idx, privateConn := range data.PrivateConnURLs { - suffix := getSuffix(idx) - secret.Data[privateKey+suffix] = []byte(privateConn.PvtConnURL) - secret.Data[privateKeySrv+suffix] = []byte(privateConn.PvtSrvConnURL) - secret.Data[privateShardKey+suffix] = []byte(privateConn.PvtShardConnURL) - } - - return nil -} - -func getSuffix(idx int) string { - if idx == 0 { - return "" - } - - return fmt.Sprint(idx) -} - -func formatSecretName(projectName, clusterName, dbUserName string) string { - name := fmt.Sprintf("%s-%s-%s", - kube.NormalizeIdentifier(projectName), - kube.NormalizeIdentifier(clusterName), - kube.NormalizeIdentifier(dbUserName)) - return kube.NormalizeIdentifier(name) -} - -func AddCredentialsToConnectionURL(connURL, userName, password string) (string, error) { - cs, err := url.Parse(connURL) - if err != nil { - return "", err - } - cs.User = url.UserPassword(userName, password) - return cs.String(), nil -} diff --git a/internal/controller/connectionsecret/ensuresecret_test.go b/internal/controller/connectionsecret/ensuresecret_test.go deleted file mode 100644 index bf41a3b231..0000000000 --- a/internal/controller/connectionsecret/ensuresecret_test.go +++ /dev/null @@ -1,141 +0,0 @@ -// Copyright 2025 MongoDB Inc -// -// 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 connectionsecret - -import ( - "context" - "fmt" - "testing" - - "github.com/stretchr/testify/assert" - corev1 "k8s.io/api/core/v1" - "k8s.io/apimachinery/pkg/runtime" - utilruntime "k8s.io/apimachinery/pkg/util/runtime" - "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/client/fake" - - akov2 "github.com/mongodb/mongodb-atlas-kubernetes/v2/api/v1" - "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/kube" -) - -func TestAddCredentialsToConnectionURL(t *testing.T) { - t.Run("Adding Credentials to standard url", func(t *testing.T) { - url, err := AddCredentialsToConnectionURL("mongodb://mongodb0.example.com:27017,mongodb1.example.com:27017/?authSource=admin", "super-user", "P@ssword!") - assert.NoError(t, err) - assert.Equal(t, "mongodb://super-user:P%40ssword%21@mongodb0.example.com:27017,mongodb1.example.com:27017/?authSource=admin", url) - }) - t.Run("Adding Credentials to srv url", func(t *testing.T) { - url, err := AddCredentialsToConnectionURL("mongodb+srv://server.example.com/?authSource=$external&authMechanism=PLAIN&connectTimeoutMS=300000", "ldap_user", "Simple#") - assert.NoError(t, err) - assert.Equal(t, "mongodb+srv://ldap_user:Simple%23@server.example.com/?authSource=$external&authMechanism=PLAIN&connectTimeoutMS=300000", url) - }) -} - -func TestEnsure(t *testing.T) { - // Fake client - scheme := runtime.NewScheme() - utilruntime.Must(corev1.AddToScheme(scheme)) - utilruntime.Must(akov2.AddToScheme(scheme)) - fakeClient := fake.NewClientBuilder().WithScheme(scheme).Build() - - t.Run("Create/Update", func(t *testing.T) { - data := dataForSecret() - // Create - _, err := Ensure(context.Background(), fakeClient, "testNs", "project1", "603e7bf38a94956835659ae5", "cluster1", data) - assert.NoError(t, err) - validateSecret(t, fakeClient, "testNs", "project1", "603e7bf38a94956835659ae5", "cluster1", data) - - // Update - data.Password = "new$!" - data.SrvConnURL = "mongodb+srv://mongodb10.example.com:27017/?authSource=admin&tls=true" - data.ConnURL = "mongodb://mongodb10.example.com:27017,mongodb1.example.com:27017/?authSource=admin&tls=true" - _, err = Ensure(context.Background(), fakeClient, "testNs", "project1", "603e7bf38a94956835659ae5", "cluster1", data) - assert.NoError(t, err) - validateSecret(t, fakeClient, "testNs", "project1", "603e7bf38a94956835659ae5", "cluster1", data) - }) - - t.Run("Create two different secrets", func(t *testing.T) { - data := dataForSecret() - // First secret - _, err := Ensure(context.Background(), fakeClient, "testNs", "project1", "603e7bf38a94956835659ae5", "cluster1", data) - assert.NoError(t, err) - validateSecret(t, fakeClient, "testNs", "project1", "603e7bf38a94956835659ae5", "cluster1", data) - - // The second secret (the same cluster and user name but different projects) - _, err = Ensure(context.Background(), fakeClient, "testNs", "project2", "903e7bf38a94256835659ae5", "cluster1", data) - assert.NoError(t, err) - validateSecret(t, fakeClient, "testNs", "project2", "903e7bf38a94256835659ae5", "cluster1", data) - }) - - t.Run("Create secret with special symbols", func(t *testing.T) { - data := dataForSecret() - data.DBUserName = "#simple@user_for.test" - - // Unfortunately, fake client doesn't validate object names, so this doesn't cover the validness of the produced name :( - _, err := Ensure(context.Background(), fakeClient, "otherNs", "my@project", "603e7bf38a94956835659ae5", "some cluster!", data) - assert.NoError(t, err) - s := validateSecret(t, fakeClient, "otherNs", "my-project", "603e7bf38a94956835659ae5", "some-cluster", data) - assert.Equal(t, "my-project-some-cluster-simple-user-for.test", s.Name) - }) -} - -func validateSecret(t *testing.T, fakeClient client.Client, namespace, projectName, projectID, clusterName string, data ConnectionData) corev1.Secret { - secret := corev1.Secret{} - secretName := fmt.Sprintf("%s-%s-%s", projectName, clusterName, kube.NormalizeIdentifier(data.DBUserName)) - err := fakeClient.Get(context.Background(), kube.ObjectKey(namespace, secretName), &secret) - assert.NoError(t, err) - - expectedData := map[string][]byte{ - "connectionStringStandard": []byte(buildConnectionURL(data.ConnURL, data.DBUserName, data.Password)), - "connectionStringStandardSrv": []byte(buildConnectionURL(data.SrvConnURL, data.DBUserName, data.Password)), - "connectionStringPrivate": []byte(buildConnectionURL(data.PrivateConnURLs[0].PvtConnURL, data.DBUserName, data.Password)), - "connectionStringPrivateSrv": []byte(buildConnectionURL(data.PrivateConnURLs[0].PvtSrvConnURL, data.DBUserName, data.Password)), - "username": []byte(data.DBUserName), - "password": []byte(data.Password), - "connectionStringPrivateShard": []byte(data.PrivateConnURLs[0].PvtShardConnURL), - } - expectedLabels := map[string]string{ - "atlas.mongodb.com/project-id": projectID, - "atlas.mongodb.com/cluster-name": clusterName, - TypeLabelKey: CredLabelVal, - } - assert.Equal(t, expectedData, secret.Data) - assert.Equal(t, expectedLabels, secret.Labels) - - return secret -} - -func buildConnectionURL(connURL, userName, password string) string { - url, err := AddCredentialsToConnectionURL(connURL, userName, password) - if err != nil { - panic(err.Error()) - } - return url -} - -func dataForSecret() ConnectionData { - return ConnectionData{ - DBUserName: "admin", - Password: "m@gick%", - ConnURL: "mongodb://mongodb0.example.com:27017,mongodb1.example.com:27017/?authSource=admin", - SrvConnURL: "mongodb+srv://mongodb.example.com:27017/?authSource=admin", - PrivateConnURLs: []PrivateLinkConnURLs{ - { - PvtConnURL: "mongodb://mongodb0-pri.example.com:27017,mongodb1-pri.example.com:27017/?authSource=admin", - PvtSrvConnURL: "mongodb+srv://mongodb-pri.example.com:27017/?authSource=admin", - }, - }, - } -} diff --git a/internal/controller/connectionsecret/listsecrets.go b/internal/controller/connectionsecret/listsecrets.go index 5ead1f9b09..5f8fd00ba3 100644 --- a/internal/controller/connectionsecret/listsecrets.go +++ b/internal/controller/connectionsecret/listsecrets.go @@ -19,12 +19,39 @@ import ( "fmt" corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/labels" "sigs.k8s.io/controller-runtime/pkg/client" "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/kube" ) +func Ensure(ctx context.Context, client client.Client, namespace, projectName, projectID, clusterName string, data ConnSecretData) (string, error) { + var getError error + s := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{ + Name: CreateK8sFormat(projectName, clusterName, data.DBUserName), + Namespace: namespace, + }} + if getError = client.Get(ctx, kube.ObjectKeyFromObject(s), s); getError != nil && !apierrors.IsNotFound(getError) { + return "", getError + } + + ids := &ConnSecretIdentifiers{ + ProjectID: projectID, + ClusterName: kube.NormalizeIdentifier(clusterName), + } + if err := fillConnSecretData(s, ids, data); err != nil { + return "", err + } + if getError != nil { + // Creating + return s.Name, client.Create(ctx, s) + } + + return s.Name, client.Update(ctx, s) +} + // ListByDeploymentName returns all secrets in the specified namespace that have labels for 'projectID' and 'clusterName' func ListByDeploymentName(ctx context.Context, k8sClient client.Client, namespace, projectID, clusterName string) ([]corev1.Secret, error) { return list(ctx, k8sClient, namespace, projectID, clusterName, "") diff --git a/internal/controller/connectionsecret/listsecrets_test.go b/internal/controller/connectionsecret/listsecrets_test.go index 77c38b0f97..e4745b366c 100644 --- a/internal/controller/connectionsecret/listsecrets_test.go +++ b/internal/controller/connectionsecret/listsecrets_test.go @@ -114,3 +114,18 @@ func getSecretsNames(secrets []corev1.Secret) []string { } return res } + +func dataForSecret() ConnSecretData { + return ConnSecretData{ + DBUserName: "admin", + Password: "m@gick%", + ConnURL: "mongodb://mongodb0.example.com:27017,mongodb1.example.com:27017/?authSource=admin", + SrvConnURL: "mongodb+srv://mongodb.example.com:27017/?authSource=admin", + PrivateConnURLs: []PrivateLinkConnURLs{ + { + PvtConnURL: "mongodb://mongodb0-pri.example.com:27017,mongodb1-pri.example.com:27017/?authSource=admin", + PvtSrvConnURL: "mongodb+srv://mongodb-pri.example.com:27017/?authSource=admin", + }, + }, + } +} diff --git a/internal/controller/connectionsecret/requestname_extractor.go b/internal/controller/connectionsecret/requestname_extractor.go new file mode 100644 index 0000000000..d0d6cb7b73 --- /dev/null +++ b/internal/controller/connectionsecret/requestname_extractor.go @@ -0,0 +1,275 @@ +// Copyright 2025 MongoDB Inc +// +// 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 connectionsecret + +import ( + "context" + "errors" + "fmt" + "strings" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/fields" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + + akov2 "github.com/mongodb/mongodb-atlas-kubernetes/v2/api/v1" + "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/indexer" + "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/kube" + "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/stringutil" +) + +const InternalSeparator = "$" + +var ( + // Parsing & format + ErrInternalFormatPartsInvalid = errors.New("internal format expected 3 parts separated by $") + ErrInternalFormatPartEmpty = errors.New("internal format got empty value in one or more parts") + ErrK8sLabelsMissing = errors.New("k8s format got a missing required label(s)") + ErrK8sLabelEmpty = errors.New("k8s format got label present but empty") + ErrK8sNameSplitInvalid = errors.New("k8s format expected to separate across --") + ErrK8sNameSplitEmpty = errors.New("k8s format got empty value in one or more parts") + + // Index lookups + ErrNoPairedResourcesFound = errors.New("no AtlasDeployment and no AtlasDatabaseUser found") + ErrNoDeploymentFound = errors.New("no AtlasDeployment found") + ErrManyDeployments = errors.New("multiple AtlasDeployments found") + ErrNoUserFound = errors.New("no AtlasDatabaseUser found") + ErrManyUsers = errors.New("multiple AtlasDatabaseUsers found") +) + +// ConnSecretIdentifiers holds the values extracted from a reconcile request name. +type ConnSecretIdentifiers struct { + ProjectID string + ProjectName string + ClusterName string + DatabaseUsername string +} + +// ConnSecretPair represents the pairing of an AtlasDeployment and an AtlasDatabaseUser +// required to construct a ConnectionSecret. It holds resolved identifiers and the corresponding resources. +// NOTE: this struct intentionally stores only ProjectID (not all identifiers) to keep only necessary information. +type ConnSecretPair struct { + ProjectID string + Deployment *akov2.AtlasDeployment + User *akov2.AtlasDatabaseUser +} + +// ConnectionData contains all connection information required to populate +// the Kubernetes Secret, including standard and SRV URLs and optional Private Link URLs. +type ConnSecretData struct { + DBUserName string + Password string + ConnURL string + SrvConnURL string + PrivateConnURLs []PrivateLinkConnURLs +} + +// PrivateLinkConnURLs holds all Private Link connection strings for a single endpoint set. +// Multiple entries allow for multiple private link configurations per deployment. +type PrivateLinkConnURLs struct { + PvtConnURL string + PvtSrvConnURL string + PvtShardConnURL string +} + +// CreateK8sFormat returns the Secret name in the Kubernetes naming format: -- +func CreateK8sFormat(projectName string, clusterName string, databaseUsername string) string { + return strings.Join([]string{ + kube.NormalizeIdentifier(projectName), + kube.NormalizeIdentifier(clusterName), + kube.NormalizeIdentifier(databaseUsername), + }, "-") +} + +// CreateInternalFormat returns the Secret name in the internal format used by watchers: $$ +func CreateInternalFormat(projectID string, clusterName string, databaseUsername string) string { + return strings.Join([]string{ + projectID, + kube.NormalizeIdentifier(clusterName), + kube.NormalizeIdentifier(databaseUsername), + }, InternalSeparator) +} + +// LoadRequestIdentifiers determines whether the request name is internal or K8s format +// and extracts ProjectID, ClusterName, and DatabaseUsername. +func (r *ConnectionSecretReconciler) loadRequestIdentifiers(ctx context.Context, req types.NamespacedName) (*ConnSecretIdentifiers, error) { + if strings.Contains(req.Name, InternalSeparator) { + return r.indetifiersFromInternalName(req) + } + + return r.indentifiersFromSecret(ctx, req) +} + +func (r *ConnectionSecretReconciler) indetifiersFromInternalName(req types.NamespacedName) (*ConnSecretIdentifiers, error) { + // === Internal format: $$ + parts := strings.Split(req.Name, InternalSeparator) + if len(parts) != 3 { + return nil, ErrInternalFormatPartsInvalid + } + if parts[0] == "" || parts[1] == "" || parts[2] == "" { + return nil, ErrInternalFormatPartEmpty + } + return &ConnSecretIdentifiers{ + ProjectID: parts[0], + ClusterName: parts[1], + DatabaseUsername: parts[2], + }, nil +} + +func (r *ConnectionSecretReconciler) indentifiersFromSecret(ctx context.Context, req types.NamespacedName) (*ConnSecretIdentifiers, error) { + // === K8s format: -- + var secret corev1.Secret + if err := r.Client.Get(ctx, req, &secret); err != nil { + return nil, err + } + labels := secret.GetLabels() + projectID, hasProject := labels[ProjectLabelKey] + clusterName, hasCluster := labels[ClusterLabelKey] + // Missing labels or values + if !hasProject || !hasCluster { + return nil, ErrK8sLabelsMissing + } + if projectID == "" || clusterName == "" { + return nil, ErrK8sLabelEmpty + } + sep := fmt.Sprintf("-%s-", clusterName) + parts := strings.SplitN(req.Name, sep, 2) + if len(parts) != 2 { + return nil, ErrK8sNameSplitInvalid + } + if parts[0] == "" || parts[1] == "" { + return nil, ErrK8sNameSplitEmpty + } + return &ConnSecretIdentifiers{ + ProjectID: projectID, + ProjectName: parts[0], + ClusterName: clusterName, + DatabaseUsername: parts[1], + }, nil +} + +// LoadPairedResources fetches the paired AtlasDeployment and AtlasDatabaseUser forming the ConnectionSecret +// using the registered indexers +func (r *ConnectionSecretReconciler) loadPairedResources(ctx context.Context, ids *ConnSecretIdentifiers) (*ConnSecretPair, error) { + compositeDeploymentKey := ids.ProjectID + "-" + ids.ClusterName + deployments := &akov2.AtlasDeploymentList{} + if err := r.Client.List(ctx, deployments, &client.ListOptions{ + FieldSelector: fields.OneTermEqualSelector(indexer.AtlasDeploymentBySpecNameAndProjectID, compositeDeploymentKey), + }); err != nil { + return nil, err + } + + compositeUserKey := ids.ProjectID + "-" + ids.DatabaseUsername + users := &akov2.AtlasDatabaseUserList{} + if err := r.Client.List(ctx, users, &client.ListOptions{ + FieldSelector: fields.OneTermEqualSelector(indexer.AtlasDatabaseUserBySpecUsernameAndProjectID, compositeUserKey), + }); err != nil { + return nil, err + } + + switch { + case len(deployments.Items) == 0 && len(users.Items) == 0: + return nil, ErrNoPairedResourcesFound + case len(deployments.Items) == 0: + return &ConnSecretPair{ + ProjectID: ids.ProjectID, + Deployment: nil, + User: &users.Items[0], + }, ErrNoDeploymentFound + case len(users.Items) == 0: + return &ConnSecretPair{ + ProjectID: ids.ProjectID, + Deployment: &deployments.Items[0], + User: nil, + }, ErrNoUserFound + case len(deployments.Items) > 1: + return nil, ErrManyDeployments + case len(users.Items) > 1: + return nil, ErrManyUsers + } + + return &ConnSecretPair{ + ProjectID: ids.ProjectID, + Deployment: &deployments.Items[0], + User: &users.Items[0], + }, nil +} + +// InvalidScopes checks whether the Deployment and User have a common scope +func invalidScopes(p *ConnSecretPair) bool { + scopes := p.User.GetScopes(akov2.DeploymentScopeType) + if len(scopes) != 0 && !stringutil.Contains(scopes, p.Deployment.GetDeploymentName()) { + return true + } + + return false +} + +// IsReady checks that both AtlasDeployment and AtlasDatabaseUser are ready +func isReady(p *ConnSecretPair) (bool, []string) { + notReady := []string{} + + if p.Deployment == nil || !p.Deployment.IsDeploymentReady() { + if p.Deployment != nil { + notReady = append(notReady, fmt.Sprintf("AtlasDeployment/%s", p.Deployment.GetName())) + } else { + notReady = append(notReady, "AtlasDeployment/") + } + } + if p.User == nil || !p.User.IsDatabaseUserReady() { + if p.User != nil { + notReady = append(notReady, fmt.Sprintf("AtlasDatabaseUser/%s", p.User.GetName())) + } else { + notReady = append(notReady, "AtlasDatabaseUser/") + } + } + + return len(notReady) == 0, notReady +} + +// BuildConnectionData constructs the secret data that will be passed in the secret +func (r *ConnectionSecretReconciler) buildConnectionData(ctx context.Context, p *ConnSecretPair) (ConnSecretData, error) { + password, err := p.User.ReadPassword(ctx, r.Client) + if err != nil { + return ConnSecretData{}, fmt.Errorf("failed to read password for user %q: %w", p.User.Spec.Username, err) + } + + conn := p.Deployment.Status.ConnectionStrings + + data := ConnSecretData{ + DBUserName: p.User.Spec.Username, + Password: password, + ConnURL: conn.Standard, + SrvConnURL: conn.StandardSrv, + } + + if conn.Private != "" { + data.PrivateConnURLs = append(data.PrivateConnURLs, PrivateLinkConnURLs{ + PvtConnURL: conn.Private, + PvtSrvConnURL: conn.PrivateSrv, + }) + } + + for _, pe := range conn.PrivateEndpoint { + data.PrivateConnURLs = append(data.PrivateConnURLs, PrivateLinkConnURLs{ + PvtConnURL: pe.ConnectionString, + PvtSrvConnURL: pe.SRVConnectionString, + PvtShardConnURL: pe.SRVShardOptimizedConnectionString, + }) + } + + return data, nil +} diff --git a/internal/controller/connectionsecret/requestname_extractor_test.go b/internal/controller/connectionsecret/requestname_extractor_test.go new file mode 100644 index 0000000000..04a25beede --- /dev/null +++ b/internal/controller/connectionsecret/requestname_extractor_test.go @@ -0,0 +1,561 @@ +// Copyright 2025 MongoDB Inc +// +// 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 connectionsecret + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + utilruntime "k8s.io/apimachinery/pkg/util/runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + "github.com/mongodb/mongodb-atlas-kubernetes/v2/api" + akov2 "github.com/mongodb/mongodb-atlas-kubernetes/v2/api/v1" + "github.com/mongodb/mongodb-atlas-kubernetes/v2/api/v1/common" + "github.com/mongodb/mongodb-atlas-kubernetes/v2/api/v1/status" + "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/controller/reconciler" + "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/indexer" +) + +func Test_createK8sFormat(t *testing.T) { + tests := map[string]struct { + projectName string + clusterName string + databaseUsername string + expected string + }{ + "normal values": { + projectName: "MyProject", + clusterName: "MyCluster", + databaseUsername: "AdminUser", + expected: "myproject-mycluster-adminuser", + }, + "already normalized": { + projectName: "proj", + clusterName: "cluster", + databaseUsername: "user", + expected: "proj-cluster-user", + }, + "values with spaces and caps": { + projectName: "Proj A", + clusterName: "Cluster B", + databaseUsername: "Admin X", + expected: "proj-a-cluster-b-admin-x", + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + actual := CreateK8sFormat(tc.projectName, tc.clusterName, tc.databaseUsername) + assert.Equal(t, tc.expected, actual) + }) + } +} + +func TestCreateInternalFormat(t *testing.T) { + tests := map[string]struct { + projectID string + clusterName string + databaseUsername string + expected string + }{ + "normal values": { + projectID: "proj123", + clusterName: "ClusterOne", + databaseUsername: "DBUser", + expected: "proj123$clusterone$dbuser", + }, + "cluster and user already normalized": { + projectID: "id456", + clusterName: "cluster", + databaseUsername: "user", + expected: "id456$cluster$user", + }, + "values with spaces": { + projectID: "id789", + clusterName: "CL X", + databaseUsername: "U X", + expected: "id789$cl-x$u-x", + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + actual := CreateInternalFormat(tc.projectID, tc.clusterName, tc.databaseUsername) + assert.Equal(t, tc.expected, actual) + }) + } +} + +func Test_loadRequestIdentifiers(t *testing.T) { + scheme := runtime.NewScheme() + utilruntime.Must(corev1.AddToScheme(scheme)) + + secretValid := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "myproj-mycluster-admin", + Namespace: "default", + Labels: map[string]string{ + ProjectLabelKey: "proj123", + ClusterLabelKey: "mycluster", + }, + }, + } + secretMissingLabels := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "missing-mycluster-admin", + Namespace: "default", + Labels: map[string]string{}, + }, + } + secretEmptyProject := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "emptyproject-mycluster-admin", + Namespace: "default", + Labels: map[string]string{ + ProjectLabelKey: "", + ClusterLabelKey: "mycluster", + }, + }, + } + secretEmptyCluster := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "myproj--admin", + Namespace: "default", + Labels: map[string]string{ + ProjectLabelKey: "proj123", + ClusterLabelKey: "", + }, + }, + } + secretBadSplit := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "-mycluster-admin", + Namespace: "default", + Labels: map[string]string{ + ProjectLabelKey: "proj123", + ClusterLabelKey: "mycluster", + }, + }, + } + secretInvalidSep := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "invalid-separator", + Namespace: "default", + Labels: map[string]string{ + ProjectLabelKey: "proj123", + ClusterLabelKey: "unknown", + }, + }, + } + + client := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects( + secretValid, + secretMissingLabels, + secretEmptyProject, + secretEmptyCluster, + secretBadSplit, + secretInvalidSep, + ). + Build() + + r := &ConnectionSecretReconciler{ + AtlasReconciler: reconciler.AtlasReconciler{ + Client: client, + }, + } + + tests := map[string]struct { + name string + namespace string + expected ConnSecretIdentifiers + expectedErr error + }{ + "valid internal format": { + name: "proj123$mycluster$admin", + expected: ConnSecretIdentifiers{ + ProjectID: "proj123", + ClusterName: "mycluster", + DatabaseUsername: "admin", + }, + }, + "internal format with too few parts": { + name: "proj123$clusterOnly", + expectedErr: ErrInternalFormatPartsInvalid, + }, + "internal format with empty part": { + name: "proj123$$admin", + expectedErr: ErrInternalFormatPartEmpty, + }, + "valid k8s format": { + name: "myproj-mycluster-admin", + namespace: "default", + expected: ConnSecretIdentifiers{ + ProjectID: "proj123", + ProjectName: "myproj", + ClusterName: "mycluster", + DatabaseUsername: "admin", + }, + }, + "k8s format with missing labels": { + name: "missing-mycluster-admin", + namespace: "default", + expectedErr: ErrK8sLabelsMissing, + }, + "k8s format with empty project label": { + name: "emptyproject-mycluster-admin", + namespace: "default", + expectedErr: ErrK8sLabelEmpty, + }, + "k8s format with empty cluster label": { + name: "myproj--admin", + namespace: "default", + expectedErr: ErrK8sLabelEmpty, + }, + "k8s format with invalid name separator": { + name: "invalid-separator", + namespace: "default", + expectedErr: ErrK8sNameSplitInvalid, + }, + "k8s format with empty value after split": { + name: "-mycluster-admin", + namespace: "default", + expectedErr: ErrK8sNameSplitEmpty, + }, + } + + for tn, tc := range tests { + t.Run(tn, func(t *testing.T) { + ids, err := r.loadRequestIdentifiers( + context.Background(), + types.NamespacedName{Name: tc.name, Namespace: tc.namespace}, + ) + + if tc.expectedErr != nil { + assert.ErrorIs(t, err, tc.expectedErr) + return + } + + assert.NoError(t, err) + assert.Equal(t, tc.expected, *ids) + }) + } +} + +func Test_isReady(t *testing.T) { + t.Run("Both ready", func(t *testing.T) { + p := &ConnSecretPair{ + Deployment: &akov2.AtlasDeployment{ + ObjectMeta: metav1.ObjectMeta{Name: "dep"}, + Status: status.AtlasDeploymentStatus{ + Common: api.Common{ + Conditions: []api.Condition{{Type: api.ReadyType, Status: corev1.ConditionTrue}}, + }, + }, + }, + User: &akov2.AtlasDatabaseUser{ + ObjectMeta: metav1.ObjectMeta{Name: "user"}, + Status: status.AtlasDatabaseUserStatus{ + Common: api.Common{ + Conditions: []api.Condition{{Type: api.ReadyType, Status: corev1.ConditionTrue}}, + }, + }, + }, + ProjectID: "proj123", + } + ok, notReady := isReady(p) + assert.True(t, ok) + assert.Empty(t, notReady) + }) + + t.Run("One not ready", func(t *testing.T) { + p := &ConnSecretPair{ + Deployment: &akov2.AtlasDeployment{ + ObjectMeta: metav1.ObjectMeta{Name: "dep"}, + Status: status.AtlasDeploymentStatus{ + Common: api.Common{ + Conditions: []api.Condition{{Type: api.ReadyType, Status: corev1.ConditionTrue}}, + }, + }, + }, + User: &akov2.AtlasDatabaseUser{ + ObjectMeta: metav1.ObjectMeta{Name: "user"}, + Status: status.AtlasDatabaseUserStatus{ + Common: api.Common{ + // Intentionally not ReadyType to simulate "not ready" + Conditions: []api.Condition{{Type: api.DatabaseUserReadyType, Status: corev1.ConditionTrue}}, + }, + }, + }, + ProjectID: "proj123", + } + ok, notReady := isReady(p) + assert.False(t, ok) + assert.Equal(t, []string{"AtlasDatabaseUser/user"}, notReady) + }) +} + +func Test_loadPairedResources(t *testing.T) { + scheme := runtime.NewScheme() + utilruntime.Must(akov2.AddToScheme(scheme)) + + const ( + ns = "default" + projectID = "proj123" + otherprojectID = "proj456" + ) + + tests := map[string]struct { + clusterName string + databaseUsername string + deployments []client.Object + users []client.Object + expectedErr error + }{ + "no deployments found overall": { + clusterName: "clusterA", + databaseUsername: "admin", + users: []client.Object{ + &akov2.AtlasDatabaseUser{ + ObjectMeta: metav1.ObjectMeta{Name: "user1", Namespace: ns}, + Spec: akov2.AtlasDatabaseUserSpec{Username: "admin"}, + }, + }, + expectedErr: ErrNoDeploymentFound, + }, + "no deployments found due to missing index": { + clusterName: "clusterB", + databaseUsername: "admin", + deployments: []client.Object{ + &akov2.AtlasDeployment{ + ObjectMeta: metav1.ObjectMeta{Name: "dep1", Namespace: ns}, + Spec: akov2.AtlasDeploymentSpec{ + DeploymentSpec: &akov2.AdvancedDeploymentSpec{Name: "clusterB"}, + }, + }, + }, + users: []client.Object{ + &akov2.AtlasDatabaseUser{ + ObjectMeta: metav1.ObjectMeta{Name: "user1", Namespace: ns}, + Spec: akov2.AtlasDatabaseUserSpec{Username: "admin"}, + }, + }, + expectedErr: ErrNoDeploymentFound, + }, + "multiple users found": { + clusterName: "clusterA", + databaseUsername: "admin", + deployments: []client.Object{ + &akov2.AtlasDeployment{ + ObjectMeta: metav1.ObjectMeta{Name: "dep1", Namespace: ns}, + Spec: akov2.AtlasDeploymentSpec{ + DeploymentSpec: &akov2.AdvancedDeploymentSpec{Name: "clusterA"}, + }, + }, + }, + users: []client.Object{ + &akov2.AtlasDatabaseUser{ + ObjectMeta: metav1.ObjectMeta{Name: "user1", Namespace: ns}, + Spec: akov2.AtlasDatabaseUserSpec{Username: "admin"}, + }, + &akov2.AtlasDatabaseUser{ + ObjectMeta: metav1.ObjectMeta{Name: "user2", Namespace: ns}, + Spec: akov2.AtlasDatabaseUserSpec{Username: "admin"}, + }, + }, + expectedErr: ErrManyUsers, + }, + "successfully finds one deployment and one user": { + clusterName: "clusterA", + databaseUsername: "admin", + deployments: []client.Object{ + &akov2.AtlasDeployment{ + ObjectMeta: metav1.ObjectMeta{Name: "dep1", Namespace: ns}, + Spec: akov2.AtlasDeploymentSpec{ + DeploymentSpec: &akov2.AdvancedDeploymentSpec{Name: "clusterA"}, + }, + }, + }, + users: []client.Object{ + &akov2.AtlasDatabaseUser{ + ObjectMeta: metav1.ObjectMeta{Name: "user1", Namespace: ns}, + Spec: akov2.AtlasDatabaseUserSpec{Username: "admin"}, + }, + }, + expectedErr: nil, + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + allObjects := append(tt.deployments, tt.users...) + + cl := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(allObjects...). + WithIndex(&akov2.AtlasDeployment{}, indexer.AtlasDeploymentBySpecNameAndProjectID, func(obj client.Object) []string { + // Simulate an index only for projectID + "clusterA" + return []string{projectID + "-" + "clusterA"} + }). + WithIndex(&akov2.AtlasDatabaseUser{}, indexer.AtlasDatabaseUserBySpecUsernameAndProjectID, func(obj client.Object) []string { + // Simulate an index only for projectID + "admin" + return []string{projectID + "-" + "admin"} + }). + Build() + + r := &ConnectionSecretReconciler{ + AtlasReconciler: reconciler.AtlasReconciler{ + Client: cl, + }, + } + + ids := &ConnSecretIdentifiers{ + ProjectID: projectID, + ClusterName: tt.clusterName, + DatabaseUsername: tt.databaseUsername, + } + + pair, err := r.loadPairedResources(context.Background(), ids) + + if tt.expectedErr == nil { + assert.NoError(t, err) + assert.NotNil(t, pair) + assert.NotNil(t, pair.Deployment) + assert.NotNil(t, pair.User) + assert.Equal(t, tt.clusterName, pair.Deployment.GetDeploymentName()) + assert.Equal(t, tt.databaseUsername, pair.User.Spec.Username) + } else { + assert.ErrorIs(t, err, tt.expectedErr) + } + + // When the projectID doesn't match the indexed keys, BOTH resources are missing -> special error. + failIDs := &ConnSecretIdentifiers{ + ProjectID: otherprojectID, + ClusterName: tt.clusterName, + DatabaseUsername: tt.databaseUsername, + } + + failPair, failErr := r.loadPairedResources(context.Background(), failIDs) + assert.Error(t, failErr) + assert.Nil(t, failPair) + assert.ErrorIs(t, failErr, ErrNoPairedResourcesFound) + }) + } +} + +func Test_buildConnectionData(t *testing.T) { + const ( + username = "admin" + passwordValue = "p@ssw0rd" + ) + + scheme := runtime.NewScheme() + utilruntime.Must(corev1.AddToScheme(scheme)) + utilruntime.Must(akov2.AddToScheme(scheme)) + + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "admin-password", + Namespace: "default", + }, + Data: map[string][]byte{ + "password": []byte(passwordValue), + }, + } + + user := &akov2.AtlasDatabaseUser{ + ObjectMeta: metav1.ObjectMeta{ + Name: "admin", + Namespace: "default", + }, + Spec: akov2.AtlasDatabaseUserSpec{ + Username: username, + PasswordSecret: &common.ResourceRef{ + Name: "admin-password", + }, + }, + } + + deployment := &akov2.AtlasDeployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: "dep1", + Namespace: "default", + }, + Status: status.AtlasDeploymentStatus{ + ConnectionStrings: &status.ConnectionStrings{ + Standard: "mongodb+srv://cluster.mongodb.net", + StandardSrv: "mongodb://cluster.mongodb.net", + Private: "mongodb://private.mongodb.net", + PrivateSrv: "mongodb+srv://private.mongodb.net", + PrivateEndpoint: []status.PrivateEndpoint{ + { + ConnectionString: "mongodb://pe1.mongodb.net", + SRVConnectionString: "mongodb+srv://pe1.mongodb.net", + SRVShardOptimizedConnectionString: "mongodb+srv://pe1-shard.mongodb.net", + }, + { + ConnectionString: "mongodb://pe2.mongodb.net", + SRVConnectionString: "mongodb+srv://pe2.mongodb.net", + SRVShardOptimizedConnectionString: "mongodb+srv://pe2-shard.mongodb.net", + }, + }, + }, + }, + } + + client := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(secret, user, deployment). + Build() + + r := &ConnectionSecretReconciler{ + AtlasReconciler: reconciler.AtlasReconciler{ + Client: client, + }, + } + + pair := &ConnSecretPair{ + Deployment: deployment, + User: user, + ProjectID: "proj123", + } + + data, err := r.buildConnectionData(context.Background(), pair) + assert.NoError(t, err) + assert.Equal(t, username, data.DBUserName) + assert.Equal(t, passwordValue, data.Password) + assert.Equal(t, "mongodb+srv://cluster.mongodb.net", data.ConnURL) + assert.Equal(t, "mongodb://cluster.mongodb.net", data.SrvConnURL) + assert.Len(t, data.PrivateConnURLs, 3) + + assert.Equal(t, "mongodb://private.mongodb.net", data.PrivateConnURLs[0].PvtConnURL) + assert.Equal(t, "mongodb+srv://private.mongodb.net", data.PrivateConnURLs[0].PvtSrvConnURL) + + assert.Equal(t, "mongodb://pe1.mongodb.net", data.PrivateConnURLs[1].PvtConnURL) + assert.Equal(t, "mongodb+srv://pe1.mongodb.net", data.PrivateConnURLs[1].PvtSrvConnURL) + assert.Equal(t, "mongodb+srv://pe1-shard.mongodb.net", data.PrivateConnURLs[1].PvtShardConnURL) + + assert.Equal(t, "mongodb://pe2.mongodb.net", data.PrivateConnURLs[2].PvtConnURL) + assert.Equal(t, "mongodb+srv://pe2.mongodb.net", data.PrivateConnURLs[2].PvtSrvConnURL) + assert.Equal(t, "mongodb+srv://pe2-shard.mongodb.net", data.PrivateConnURLs[2].PvtShardConnURL) +} diff --git a/internal/controller/connsecrets-generic/connectionsecret.go b/internal/controller/connsecrets-generic/connectionsecret.go new file mode 100644 index 0000000000..48b46e4db7 --- /dev/null +++ b/internal/controller/connsecrets-generic/connectionsecret.go @@ -0,0 +1,457 @@ +// Copyright 2025 MongoDB Inc +// +// 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 connsecretsgeneric + +import ( + "context" + "errors" + "fmt" + "net/url" + "strings" + + corev1 "k8s.io/api/core/v1" + apiErrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/fields" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + + akov2 "github.com/mongodb/mongodb-atlas-kubernetes/v2/api/v1" + "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/controller/workflow" + "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/indexer" + "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/kube" +) + +const ( + InternalSeparator string = "$" + + ProjectLabelKey string = "atlas.mongodb.com/project-id" + ClusterLabelKey string = "atlas.mongodb.com/cluster-name" + TypeLabelKey = "atlas.mongodb.com/type" + CredLabelVal = "credentials" + + userNameKey string = "username" + passwordKey string = "password" + standardKey string = "connectionStringStandard" + standardKeySrv string = "connectionStringStandardSrv" + privateKey string = "connectionStringPrivate" + privateSrvKey string = "connectionStringPrivateSrv" + privateShardKey string = "connectionStringPrivateShard" +) + +var ( + ErrInternalFormatErr = errors.New("identifiers could not be loaded from internal format") + ErrK8SFormatErr = errors.New("identifiers could not be loaded from k8s format") + ErrMissingPairing = errors.New("missing user/endpoint") + ErrAmbiguousPairing = errors.New("multiple users/endpoints with the same name found") + ErrUnresolvedProjectID = errors.New("could not resolve the project id") + ErrUnresolvedProjectName = errors.New("could not resolve the project name") +) + +// ConnnSecretIdentifiers stores all the necessary information that will +// be needed to identiy and get a K8s connection secret +type ConnSecretIdentifiers struct { + ProjectID string + ProjectName string + ClusterName string + DatabaseUsername string +} + +// ConnectionData contains all connection information required to populate +// the Kubernetes Secret, including standard and SRV URLs and optional Private Link URLs. +type ConnSecretData struct { + DBUserName string + Password string + ConnURL string + SrvConnURL string + PrivateConnURLs []PrivateLinkConnURLs +} +type PrivateLinkConnURLs struct { + PvtConnURL string + PvtSrvConnURL string + PvtShardConnURL string +} + +// CreateK8sFormat returns the Secret name in the Kubernetes naming format: -- +func CreateK8sFormat(projectName string, clusterName string, databaseUsername string) string { + return strings.Join([]string{ + kube.NormalizeIdentifier(projectName), + kube.NormalizeIdentifier(clusterName), + kube.NormalizeIdentifier(databaseUsername), + }, "-") +} + +// CreateInternalFormat returns the Secret name in the internal format used by watchers: $$ +func CreateInternalFormat(projectID string, clusterName string, databaseUsername string) string { + return strings.Join([]string{ + projectID, + kube.NormalizeIdentifier(clusterName), + kube.NormalizeIdentifier(databaseUsername), + }, InternalSeparator) +} + +// loadIdentifiers determines whether the request name is internal or K8s format +// and extracts ProjectID, ClusterName, and DatabaseUsername. +func (r *ConnSecretReconciler) loadIdentifiers(ctx context.Context, req types.NamespacedName) (*ConnSecretIdentifiers, error) { + if strings.Contains(req.Name, InternalSeparator) { + return r.indetifiersFromInternalName(req) + } + + return r.indentifiersFromK8s(ctx, req) +} + +// indetifiersFromInternalName loads the identifiers for the internal format +// === Internal format: $$ +func (r *ConnSecretReconciler) indetifiersFromInternalName(req types.NamespacedName) (*ConnSecretIdentifiers, error) { + parts := strings.Split(req.Name, InternalSeparator) + if len(parts) != 3 { + return nil, ErrInternalFormatErr + } + if parts[0] == "" || parts[1] == "" || parts[2] == "" { + return nil, ErrInternalFormatErr + } + return &ConnSecretIdentifiers{ + ProjectID: parts[0], + ClusterName: parts[1], + DatabaseUsername: parts[2], + }, nil +} + +// indentifiersFromSecret loads the identifiers for the k8s format +// === K8s format: -- +// K8s secret must exists in the cluster +func (r *ConnSecretReconciler) indentifiersFromK8s(ctx context.Context, req types.NamespacedName) (*ConnSecretIdentifiers, error) { + var secret corev1.Secret + if err := r.Client.Get(ctx, req, &secret); err != nil { + return nil, err + } + labels := secret.GetLabels() + projectID, hasProject := labels[ProjectLabelKey] + clusterName, hasCluster := labels[ClusterLabelKey] + if !hasProject || !hasCluster { + return nil, ErrK8SFormatErr + } + if projectID == "" || clusterName == "" { + return nil, ErrK8SFormatErr + } + sep := fmt.Sprintf("-%s-", clusterName) + parts := strings.Split(req.Name, sep) + if len(parts) != 2 { + return nil, ErrK8SFormatErr + } + if parts[0] == "" || parts[1] == "" { + return nil, ErrK8SFormatErr + } + return &ConnSecretIdentifiers{ + ProjectID: projectID, + ProjectName: parts[0], + ClusterName: clusterName, + DatabaseUsername: parts[1], + }, nil +} + +// loadPair creates the paired resource that contains the parent AtlasDatabaseUser and the Endpoint. +// Endpoint could be AtlasDeployment or AtlasDataFederation +func (r *ConnSecretReconciler) loadPair(ctx context.Context, ids *ConnSecretIdentifiers) (*ConnSecretPair, error) { + compositeUserKey := ids.ProjectID + "-" + ids.DatabaseUsername + + // Retrieve the AtlasDatabaseUser using the defined indexers + users := &akov2.AtlasDatabaseUserList{} + if err := r.Client.List(ctx, users, &client.ListOptions{ + FieldSelector: fields.OneTermEqualSelector(indexer.AtlasDatabaseUserBySpecUsernameAndProjectID, compositeUserKey), + }); err != nil { + return nil, err + } + usersCount := len(users.Items) + + // Retrieve the Endpoints using the defined indexers + totalEndpoints := 0 + var selected Endpoint + for _, kind := range r.EndpointKinds { + list := kind.ListObj() + if err := r.Client.List(ctx, list, &client.ListOptions{FieldSelector: kind.SelectorByProjectAndName(ids)}); err != nil { + return nil, err + } + eps, err := kind.ExtractList(list) + if err != nil { + return nil, err + } + if len(eps) == 1 { + selected = eps[0] + } + totalEndpoints += len(eps) + } + + // AmbiguousPairing (more than 1 of either resource) + if usersCount > 1 || totalEndpoints > 1 { + return nil, ErrAmbiguousPairing + } + + // Exactly one of each (OK case) + if usersCount == 1 && totalEndpoints == 1 { + return &ConnSecretPair{ + ProjectID: ids.ProjectID, + User: &users.Items[0], + Endpoint: selected, + }, nil + } + + // MissingPairing (one or both missing) + if usersCount == 0 && totalEndpoints == 0 { + return nil, ErrMissingPairing + } + if usersCount == 0 { + return &ConnSecretPair{ + ProjectID: ids.ProjectID, + User: nil, + Endpoint: selected, + }, ErrMissingPairing + } + return &ConnSecretPair{ + ProjectID: ids.ProjectID, + User: &users.Items[0], + Endpoint: nil, + }, ErrMissingPairing +} + +// resolveProject attempts to find the project name, required for creating connection secrets +// as it is used in metadata.name +func (r *ConnSecretReconciler) resolveProjectName( + ctx context.Context, + ids *ConnSecretIdentifiers, + pair *ConnSecretPair, +) (string, error) { + if ids != nil && ids.ProjectName != "" { + return ids.ProjectName, nil + } + + // project name resolution requires at least on parent to be available + if pair == nil { + return "", ErrUnresolvedProjectName + } + + var err error + var projectName string + // Try resolving from the Endpoint if present + if pair.Endpoint != nil { + projectName, err = pair.Endpoint.GetProjectName(ctx) + if projectName != "" { + return projectName, nil + } + } + + // Fallback, try resolving from the User if present + if pair.User != nil { + if name, uerr := r.GetUserProjectName(ctx, pair.User); name != "" { + return name, nil + } else if err == nil { + err = uerr + } + } + + if err == nil { + err = ErrUnresolvedProjectName + } + return "", err +} + +// handleDelete ensures that the connection secret from the paired resource and identifiers will get deleted +func (r *ConnSecretReconciler) handleDelete( + ctx context.Context, + req ctrl.Request, + ids *ConnSecretIdentifiers, + pair *ConnSecretPair, +) (ctrl.Result, error) { + log := r.Log.With("ns", req.Namespace, "name", req.Name) + + // project name is required for metadata.name + projectName, err := r.resolveProjectName(ctx, ids, pair) + if err != nil { + log.Errorw("failed to resolve project name", "error", err) + return workflow.Terminate(workflow.ConnSecretUnresolvedProjectName, err).ReconcileResult() + } + name := CreateK8sFormat(projectName, ids.ClusterName, ids.DatabaseUsername) + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: req.Namespace, + }, + } + + // delete secret in k8s + if err := r.Client.Delete(ctx, secret); err != nil { + if apiErrors.IsNotFound(err) { + log.Debugw("no secret to delete; already gone") + return workflow.TerminateSilently(nil).WithoutRetry().ReconcileResult() + } + log.Errorw("unable to delete secret", "reason", workflow.ConnSecretFailedDeletion, "error", err) + return workflow.Terminate(workflow.ConnSecretFailedDeletion, err).ReconcileResult() + } + + log.Debugw("connection secret deleted") + r.EventRecorder.Event(secret, corev1.EventTypeNormal, "Deleted", "ConnectionSecret deleted") + return workflow.TerminateSilently(nil).WithoutRetry().ReconcileResult() +} + +// handleUpsert ensures that the connection secret from the paired resource and identifiers will be upserted +func (r *ConnSecretReconciler) handleUpsert( + ctx context.Context, + req ctrl.Request, + ids *ConnSecretIdentifiers, + pair *ConnSecretPair, +) (ctrl.Result, error) { + log := r.Log.With("ns", req.Namespace, "name", req.Name) + + // project name is required for metadata.name + projectName, err := r.resolveProjectName(ctx, ids, pair) + if err != nil { + log.Errorw("failed to resolve project name", "error", err) + return workflow.Terminate(workflow.ConnSecretFailedToResolveProjectName, err).ReconcileResult() + } + ids.ProjectName = projectName + log.Debugw("project name resolved for upsert", "projectName", projectName) + + // create the connection data that will populate secret.stringData + data, err := pair.Endpoint.BuildConnData(ctx, pair.User) + if err != nil { + log.Errorw("failed to build connection data", "error", err) + return workflow.Terminate(workflow.ConnSecretFailedToBuildData, err).ReconcileResult() + } + log.Debugw("connection data built") + if err := r.ensureSecret(ctx, ids, pair, data); err != nil { + return workflow.Terminate(workflow.ConnSecretFailedToCreateSecret, err).ReconcileResult() + } + + log.Infow("connection secret upserted") + return workflow.OK().ReconcileResult() +} + +// ensureSecret creates or updates the Secret for the given identifiers and connection data +func (r *ConnSecretReconciler) ensureSecret( + ctx context.Context, + ids *ConnSecretIdentifiers, + pair *ConnSecretPair, + data ConnSecretData, +) error { + namespace := pair.User.GetNamespace() + log := r.Log.With("ns", namespace, "project", ids.ProjectName) + + name := CreateK8sFormat(ids.ProjectName, ids.ClusterName, ids.DatabaseUsername) + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + }, + } + + // fills the secret.stringData with the information stored in ConnSecretData + if err := fillConnSecretData(secret, ids, data); err != nil { + log.Errorw("failed to fill secret data", "reason", workflow.ConnSecretFailedToFillData, "error", err) + return err + } + + // adds the owner to be the AtlasDatabaseUser for garbage collecting + if err := controllerutil.SetControllerReference(pair.User, secret, r.Scheme); err != nil { + log.Errorw("failed to set controller owner", "reason", workflow.ConnSecretFailedToSetOwnerReferences, "error", err) + return err + } + + // upsert secret in k8s + if err := r.Client.Create(ctx, secret); err != nil { + if apiErrors.IsAlreadyExists(err) { + current := &corev1.Secret{} + if err := r.Client.Get(ctx, client.ObjectKeyFromObject(secret), current); err != nil { + log.Errorw("failed to fetch existing secret", "error", err) + return err + } + secret.ResourceVersion = current.ResourceVersion + if err := r.Client.Update(ctx, secret); err != nil { + log.Errorw("failed to update secret", "error", err) + return err + } + } else { + log.Errorw("failed to create secret", "error", err) + return err + } + } + return nil +} + +// fillConnSecretData converts the ConnSecretData into secret.stringData +func fillConnSecretData(secret *corev1.Secret, ids *ConnSecretIdentifiers, data ConnSecretData) error { + var err error + username := data.DBUserName + password := data.Password + + if data.ConnURL, err = createURL(data.ConnURL, username, password); err != nil { + return err + } + if data.SrvConnURL, err = createURL(data.SrvConnURL, username, password); err != nil { + return err + } + for i, pe := range data.PrivateConnURLs { + if data.PrivateConnURLs[i].PvtConnURL, err = createURL(pe.PvtConnURL, username, password); err != nil { + return err + } + if data.PrivateConnURLs[i].PvtSrvConnURL, err = createURL(pe.PvtSrvConnURL, username, password); err != nil { + return err + } + if data.PrivateConnURLs[i].PvtShardConnURL, err = createURL(pe.PvtShardConnURL, username, password); err != nil { + return err + } + } + + secret.Labels = map[string]string{ + TypeLabelKey: CredLabelVal, + ProjectLabelKey: ids.ProjectID, + ClusterLabelKey: ids.ClusterName, + } + + secret.Data = map[string][]byte{ + userNameKey: []byte(data.DBUserName), + passwordKey: []byte(data.Password), + standardKey: []byte(data.ConnURL), + standardKeySrv: []byte(data.SrvConnURL), + privateKey: []byte(""), + privateSrvKey: []byte(""), + } + + for i, pe := range data.PrivateConnURLs { + suffix := "" + if i != 0 { + suffix = fmt.Sprint(i) + } + secret.Data[privateKey+suffix] = []byte(pe.PvtConnURL) + secret.Data[privateSrvKey+suffix] = []byte(pe.PvtSrvConnURL) + secret.Data[privateShardKey+suffix] = []byte(pe.PvtShardConnURL) + } + + return nil +} + +// createURL creates the connection urls given a hostname, user, and password +func createURL(hostname, username, password string) (string, error) { + if hostname == "" { + return "", nil + } + u, err := url.Parse(hostname) + if err != nil { + return "", err + } + u.User = url.UserPassword(username, password) + return u.String(), nil +} diff --git a/internal/controller/connsecrets-generic/connectionsecret_controller.go b/internal/controller/connsecrets-generic/connectionsecret_controller.go new file mode 100644 index 0000000000..137d4de28d --- /dev/null +++ b/internal/controller/connsecrets-generic/connectionsecret_controller.go @@ -0,0 +1,333 @@ +// Copyright 2025 MongoDB Inc +// +// 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 connsecretsgeneric + +import ( + "context" + "errors" + "fmt" + + "go.uber.org/zap" + corev1 "k8s.io/api/core/v1" + apiErrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/fields" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/tools/record" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/builder" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/cluster" + "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/reconcile" + + "github.com/mongodb/mongodb-atlas-kubernetes/v2/api" + akov2 "github.com/mongodb/mongodb-atlas-kubernetes/v2/api/v1" + "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/controller/atlas" + "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/controller/reconciler" + "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/controller/watch" + "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/controller/workflow" + "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/indexer" + "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/pointer" + "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/stringutil" + "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/timeutil" + "github.com/mongodb/mongodb-atlas-kubernetes/v2/pkg/ratelimit" +) + +type ConnSecretReconciler struct { + reconciler.AtlasReconciler + Scheme *runtime.Scheme + EventRecorder record.EventRecorder + GlobalPredicates []predicate.Predicate + EndpointKinds []Endpoint // Endpoints are generic +} + +// Each endpoint would have to implement this interface (e.g. AtlasDeployment, AtlasDataFederation) +type Endpoint interface { + GetName() string + IsReady() bool + GetScopeType() akov2.ScopeType + GetProjectID(ctx context.Context) (string, error) + GetProjectName(ctx context.Context) (string, error) + + ListObj() client.ObjectList + ExtractList(client.ObjectList) ([]Endpoint, error) + SelectorByProject(projectRef string) fields.Selector + SelectorByProjectAndName(ids *ConnSecretIdentifiers) fields.Selector + + BuildConnData(ctx context.Context, user *akov2.AtlasDatabaseUser) (ConnSecretData, error) +} + +// Each connection secret needs a paired resource: User and Endpoint +type ConnSecretPair struct { + ProjectID string + User *akov2.AtlasDatabaseUser + Endpoint Endpoint +} + +func (r *ConnSecretReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + log := r.Log.With("namespace", req.Namespace, "name", req.Name) + log.Info("reconciliation started") + + // Parse the request and load up the identifiers + ids, err := r.loadIdentifiers(ctx, req.NamespacedName) + if err != nil { + if apiErrors.IsNotFound(err) { + log.Debugw("Connection secret not found; assuming deleted") + return workflow.TerminateSilently(nil).WithoutRetry().ReconcileResult() + } + log.Errorw("failed to parse connection secret request", "error", err) + return workflow.Terminate(workflow.ConnSecretInvalidName, err).ReconcileResult() + } + + // Load the paired resource + pair, err := r.loadPair(ctx, ids) + if err != nil { + switch { + case errors.Is(err, ErrMissingPairing): + log.Debugw("paired resource is missing; scheduling deletion of connection secrets") + return r.handleDelete(ctx, req, ids, pair) + case errors.Is(err, ErrAmbiguousPairing): + log.Errorw("failed to load paired resources; ambigous parent resources", "error", err) + return workflow.Terminate(workflow.ConnSecretAmbiguousResources, err).ReconcileResult() + default: + log.Errorw("failed to load paired resource", "error", err) + return workflow.Terminate("", err).ReconcileResult() + } + } + + // Check if user is expired + expired, err := timeutil.IsExpired(pair.User.Spec.DeleteAfterDate) + if err != nil { + log.Errorw("failed to check expiration date on user", "error", err) + return workflow.Terminate(workflow.ConnSecretCheckExpirationFailed, err).ReconcileResult() + } + if expired { + log.Debugw("user is expired; scheduling deletion of connection secrets") + return r.handleDelete(ctx, req, ids, pair) + } + + // Check that scopes are still valid + if !allowsByScopes(pair.User, pair.Endpoint.GetName(), pair.Endpoint.GetScopeType()) { + log.Infow("invalid scope; scheduling deletion of connection secrets") + return r.handleDelete(ctx, req, ids, pair) + } + + // Paired resource must be ready + if !(pair.User.IsDatabaseUserReady() && pair.Endpoint.IsReady()) { + log.Debugw("waiting on paired resource to be ready") + return workflow.InProgress(workflow.ConnSecretNotReady, "resources not ready").ReconcileResult() + } + + return r.handleUpsert(ctx, req, ids, pair) +} + +func (r *ConnSecretReconciler) For() (client.Object, builder.Predicates) { + preds := append(r.GlobalPredicates, watch.SecretLabelPredicate(TypeLabelKey, ProjectLabelKey, ClusterLabelKey)) + return &corev1.Secret{}, builder.WithPredicates(preds...) +} + +func (r *ConnSecretReconciler) SetupWithManager(mgr ctrl.Manager, skipNameValidation bool) error { + return ctrl.NewControllerManagedBy(mgr). + Named("ConnectionSecret"). + For(r.For()). + Watches( + &akov2.AtlasDeployment{}, + handler.EnqueueRequestsFromMapFunc(r.newEndpointMapFunc), + builder.WithPredicates(predicate.Or( + watch.ReadyTransitionPredicate(func(d *akov2.AtlasDeployment) bool { + return api.HasReadyCondition(d.Status.Conditions) + }), + predicate.GenerationChangedPredicate{}, + )), + ). + Watches( + &akov2.AtlasDataFederation{}, + handler.EnqueueRequestsFromMapFunc(r.newEndpointMapFunc), + builder.WithPredicates(predicate.Or( + watch.ReadyTransitionPredicate(func(d *akov2.AtlasDataFederation) bool { + return api.HasReadyCondition(d.Status.Conditions) + }), + predicate.GenerationChangedPredicate{}, + )), + ). + Watches( + &akov2.AtlasDatabaseUser{}, + handler.EnqueueRequestsFromMapFunc(r.newDatabaseUserMapFunc), + builder.WithPredicates(predicate.Or( + watch.ReadyTransitionPredicate(func(u *akov2.AtlasDatabaseUser) bool { + return api.HasReadyCondition(u.Status.Conditions) + }), + predicate.GenerationChangedPredicate{}, + )), + ). + WithOptions(controller.TypedOptions[reconcile.Request]{ + RateLimiter: ratelimit.NewRateLimiter[reconcile.Request](), + SkipNameValidation: pointer.MakePtr(skipNameValidation), + }). + Complete(r) +} + +func allowsByScopes(u *akov2.AtlasDatabaseUser, epName string, epType akov2.ScopeType) bool { + scopes := u.Spec.Scopes + filtered_scopes := u.GetScopes(epType) + if len(scopes) == 0 || stringutil.Contains(filtered_scopes, epName) { + return true + } + + return false +} + +func (r *ConnSecretReconciler) generateConnectionSecretRequests(projectID string, endpoints []Endpoint, users []akov2.AtlasDatabaseUser) []reconcile.Request { + var reqs []reconcile.Request + for _, ep := range endpoints { + for _, u := range users { + if !allowsByScopes(&u, ep.GetName(), ep.GetScopeType()) { + continue + } + + name := CreateInternalFormat(projectID, ep.GetName(), u.Spec.Username) + reqs = append(reqs, reconcile.Request{ + NamespacedName: types.NamespacedName{Namespace: u.Namespace, Name: name}, + }) + } + } + return reqs +} + +// TODO: create indexers for DataFederation by projectID +func (r *ConnSecretReconciler) parseProject(ctx context.Context, ref akov2.ProjectDualReference, userns string) (string, string, error) { + if ref.ExternalProjectRef != nil && ref.ExternalProjectRef.ID != "" { + return "", ref.ExternalProjectRef.ID, nil + } + if ref.ProjectRef != nil && ref.ProjectRef.Name != "" { + project := &akov2.AtlasProject{} + key := ref.ProjectRef.GetObject(userns) + if err := r.Client.Get(ctx, *key, project); err != nil { + return "", "", fmt.Errorf("failed to resolve projectRef: %w", err) + } + return key.String(), project.ID(), nil + } + return "", "", fmt.Errorf("missing both external and internal project references") +} + +// listEndpointsByProject retrives all of the Endpoints that live under an AtlasProject +func (r *ConnSecretReconciler) listEndpointsByProject(ctx context.Context, projectRef string, projectID string) ([]Endpoint, error) { + var out []Endpoint + for _, kind := range r.EndpointKinds { + var ref string + // Federation uses the projectRef as index key wheres Deployment uses projectID! + scopeType := kind.GetScopeType() + if scopeType == akov2.DeploymentScopeType { + ref = projectID + } else { + ref = projectRef + } + + list := kind.ListObj() + if err := r.Client.List(ctx, list, &client.ListOptions{ + FieldSelector: kind.SelectorByProject(ref), + }); err != nil { + return nil, err + } + + eps, err := kind.ExtractList(list) + if err != nil { + return nil, err + } + + out = append(out, eps...) + } + + return out, nil +} + +// newEndpointMapFunc maps an Endpoint to requests by fetching all AtlasDatabaseUsers and creating a request for each +func (r *ConnSecretReconciler) newEndpointMapFunc(ctx context.Context, obj client.Object) []reconcile.Request { + var ep Endpoint + + // Case on the type of endpoint + switch o := obj.(type) { + case *akov2.AtlasDeployment: + ep = DeploymentEndpoint{obj: o, r: r} + case *akov2.AtlasDataFederation: + ep = FederationEndpoint{obj: o, r: r} + default: + return nil + } + + projectID, err := ep.GetProjectID(ctx) + if err != nil || projectID == "" { + return nil + } + + users := &akov2.AtlasDatabaseUserList{} + if err := r.Client.List(ctx, users, &client.ListOptions{ + FieldSelector: fields.OneTermEqualSelector(indexer.AtlasDatabaseUserByProject, projectID), + }); err != nil { + return nil + } + + return r.generateConnectionSecretRequests(projectID, []Endpoint{ep}, users.Items) +} + +// newDatabaseUserMapFunc maps an AtlasDatabaseUser to requests by fetching all endpoints and creating a request for each +func (r *ConnSecretReconciler) newDatabaseUserMapFunc(ctx context.Context, obj client.Object) []reconcile.Request { + u, ok := obj.(*akov2.AtlasDatabaseUser) + if !ok { + return nil + } + projectRef, projectID, err := r.parseProject(ctx, u.Spec.ProjectDualReference, u.GetNamespace()) + if err != nil { + return nil + } + + endpoints, err := r.listEndpointsByProject(ctx, projectRef, projectID) + if err != nil { + return nil + } + + return r.generateConnectionSecretRequests(projectID, endpoints, []akov2.AtlasDatabaseUser{*u}) +} + +func NewConnectionSecretReconciler( + c cluster.Cluster, + predicates []predicate.Predicate, + atlasProvider atlas.Provider, + logger *zap.Logger, + globalSecretRef types.NamespacedName, +) *ConnSecretReconciler { + r := &ConnSecretReconciler{ + AtlasReconciler: reconciler.AtlasReconciler{ + Client: c.GetClient(), + Log: logger.Named("controllers").Named("ConnectionSecret").Sugar(), + GlobalSecretRef: globalSecretRef, + AtlasProvider: atlasProvider, + }, + Scheme: c.GetScheme(), + EventRecorder: c.GetEventRecorderFor("ConnectionSecret"), + GlobalPredicates: predicates, + } + + // Register all the endpoint types + r.EndpointKinds = []Endpoint{ + DeploymentEndpoint{r: r}, + FederationEndpoint{r: r}, + } + + return r +} diff --git a/internal/controller/connsecrets-generic/connectionsecret_controller_test.go b/internal/controller/connsecrets-generic/connectionsecret_controller_test.go new file mode 100644 index 0000000000..5810927163 --- /dev/null +++ b/internal/controller/connsecrets-generic/connectionsecret_controller_test.go @@ -0,0 +1,428 @@ +// Copyright 2025 MongoDB Inc +// +// 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 connsecretsgeneric + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + apiErrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/mongodb/mongodb-atlas-kubernetes/v2/api" + akov2 "github.com/mongodb/mongodb-atlas-kubernetes/v2/api/v1" + "github.com/mongodb/mongodb-atlas-kubernetes/v2/api/v1/common" + "github.com/mongodb/mongodb-atlas-kubernetes/v2/api/v1/status" + "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/controller/workflow" +) + +func TestConnectionSecretReconcile(t *testing.T) { + type testCase struct { + reqName string + deployment *akov2.AtlasDeployment + federation *akov2.AtlasDataFederation + user *akov2.AtlasDatabaseUser + expectedDeletion bool + expectedUpdate bool + expectedResult func() (ctrl.Result, error) + } + + user := &akov2.AtlasDatabaseUser{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-user", + Namespace: "test-ns", + }, + Spec: akov2.AtlasDatabaseUserSpec{ + Username: "admin", + PasswordSecret: &common.ResourceRef{Name: "user-pass"}, + ProjectDualReference: akov2.ProjectDualReference{ + ExternalProjectRef: &akov2.ExternalProjectReference{ID: "test-project-id"}, + ConnectionSecret: &api.LocalObjectReference{Name: "sdk-creds"}, + }, + }, + Status: status.AtlasDatabaseUserStatus{ + Common: api.Common{ + Conditions: []api.Condition{{Type: api.ReadyType, Status: corev1.ConditionTrue}}, + }, + }, + } + + depl := &akov2.AtlasDeployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-depl", + Namespace: "test-ns", + }, + Spec: akov2.AtlasDeploymentSpec{ + DeploymentSpec: &akov2.AdvancedDeploymentSpec{Name: "cluster1"}, + ProjectDualReference: akov2.ProjectDualReference{ + ExternalProjectRef: &akov2.ExternalProjectReference{ID: "test-project-id"}, + ConnectionSecret: &api.LocalObjectReference{Name: "sdk-creds"}, + }, + }, + Status: status.AtlasDeploymentStatus{ + Common: api.Common{ + Conditions: []api.Condition{{Type: api.ReadyType, Status: corev1.ConditionTrue}}, + }, + ConnectionStrings: &status.ConnectionStrings{ + Standard: "mongodb+srv://cluster1.mongodb.net", + StandardSrv: "mongodb://cluster1.mongodb.net", + }, + }, + } + + tests := map[string]testCase{ + "fail: could not load identifiers": { + reqName: "my-project$cluster", + expectedResult: func() (ctrl.Result, error) { + return workflow.Terminate("InvalidConnectionSecretName", ErrInternalFormatErr).ReconcileResult() + }, + }, + "success: could not find secret with k8s format; assume deleted": { + reqName: "test-project-id-cluster1-admin", + expectedResult: func() (ctrl.Result, error) { + return workflow.TerminateSilently(nil).WithoutRetry().ReconcileResult() + }, + }, + "success: missing both parent resources; garbage collect secret": { + reqName: "test-project-id$cluster1$admin", + expectedDeletion: true, + expectedResult: func() (ctrl.Result, error) { + return workflow.Terminate(workflow.ConnSecretUnresolvedProjectName, ErrUnresolvedProjectName).ReconcileResult() + }, + }, + "success: only one available resource from the pair, trigger delete": { + reqName: "test-project-id$cluster1$admin", + user: user, + expectedDeletion: true, + expectedResult: func() (ctrl.Result, error) { + return workflow.TerminateSilently(nil).WithoutRetry().ReconcileResult() + }, + }, + "success: invalid scopes; trigger delete": { + reqName: "test-project-id$cluster1$admin", + deployment: depl, + user: &akov2.AtlasDatabaseUser{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-user", + Namespace: "test-ns", + }, + Spec: akov2.AtlasDatabaseUserSpec{ + Username: "admin", + Scopes: []akov2.ScopeSpec{ + { + Name: "df", + Type: akov2.DataLakeScopeType, + }, + }, + }, + }, + expectedDeletion: true, + expectedResult: func() (ctrl.Result, error) { + return workflow.TerminateSilently(nil).WithoutRetry().ReconcileResult() + }, + }, + "success: expired user; trigger delete": { + reqName: "test-project-id$cluster1$admin", + deployment: depl, + user: &akov2.AtlasDatabaseUser{ + ObjectMeta: metav1.ObjectMeta{ + Name: "admin", + Namespace: "test-ns", + }, + Spec: akov2.AtlasDatabaseUserSpec{ + Username: "admin", + DeleteAfterDate: time.Now().UTC().Add(-1 * time.Hour).Format(time.RFC3339), + }, + }, + expectedDeletion: true, + expectedResult: func() (ctrl.Result, error) { + return workflow.TerminateSilently(nil).WithoutRetry().ReconcileResult() + }, + }, + "requque: resources are not ready yet": { + reqName: "test-project-id$cluster1$admin", + deployment: depl, + user: &akov2.AtlasDatabaseUser{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-user", + Namespace: "test-ns", + }, + Spec: akov2.AtlasDatabaseUserSpec{ + Username: "admin", + PasswordSecret: &common.ResourceRef{Name: "user-pass"}, + ProjectDualReference: akov2.ProjectDualReference{ + ExternalProjectRef: &akov2.ExternalProjectReference{ID: "test-project-id"}, + ConnectionSecret: &api.LocalObjectReference{Name: "sdk-creds"}, + }, + }, + }, + expectedResult: func() (ctrl.Result, error) { + return workflow.InProgress(workflow.ConnSecretNotReady, "resources not ready").ReconcileResult() + }, + }, + "success: pair ready; trigger upsert": { + reqName: "test-project-id$cluster1$admin", + deployment: depl, + user: user, + expectedUpdate: true, + expectedResult: func() (ctrl.Result, error) { + return workflow.TerminateSilently(nil).WithoutRetry().ReconcileResult() + }, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + var all []client.Object + if tc.deployment != nil { + all = append(all, tc.deployment) + } + if tc.federation != nil { + all = append(all, tc.federation) + } + if tc.user != nil { + all = append(all, tc.user) + } + + r := createDummyEnv(t, all) + r.EndpointKinds = []Endpoint{DeploymentEndpoint{r: r}, FederationEndpoint{r: r}} + + req := ctrl.Request{ + NamespacedName: types.NamespacedName{ + Namespace: "test-ns", + Name: tc.reqName, + }, + } + + res, err := r.Reconcile(context.Background(), req) + expRes, expErr := tc.expectedResult() + + assert.Equal(t, expRes, res) + if expErr != nil { + assert.EqualError(t, err, expErr.Error()) + } else { + assert.NoError(t, err) + } + + if tc.expectedUpdate { + ids, err := r.loadIdentifiers(context.Background(), req.NamespacedName) + require.NoError(t, err) + ids.ProjectName = "my-project-name" + + expectedName := CreateK8sFormat(ids.ProjectName, ids.ClusterName, ids.DatabaseUsername) + var outputSecret corev1.Secret + getErr := r.Client.Get(context.Background(), types.NamespacedName{ + Namespace: "test-ns", + Name: expectedName, + }, &outputSecret) + assert.NoError(t, getErr, "expected secret %q to exist", expectedName) + } + + if tc.expectedDeletion { + ids, err := r.loadIdentifiers(context.Background(), req.NamespacedName) + require.NoError(t, err) + + expectedName := CreateK8sFormat(ids.ProjectName, ids.ClusterName, ids.DatabaseUsername) + var check corev1.Secret + getErr := r.Client.Get(context.Background(), types.NamespacedName{ + Namespace: "test-ns", + Name: expectedName, + }, &check) + assert.True(t, apiErrors.IsNotFound(getErr), "expected secret %q to be deleted", expectedName) + } + }) + } +} + +func Test_allowsByScopes(t *testing.T) { + type args struct { + epName string + epType akov2.ScopeType + } + tests := map[string]struct { + user *akov2.AtlasDatabaseUser + args args + want bool + }{ + "allow: no scopes field (nil)": { + user: &akov2.AtlasDatabaseUser{Spec: akov2.AtlasDatabaseUserSpec{Scopes: nil}}, + args: args{epName: "clusterA", epType: akov2.DeploymentScopeType}, + want: true, + }, + "allow: empty scopes slice": { + user: &akov2.AtlasDatabaseUser{Spec: akov2.AtlasDatabaseUserSpec{Scopes: []akov2.ScopeSpec{}}}, + args: args{epName: "clusterA", epType: akov2.DeploymentScopeType}, + want: true, + }, + "allow: deployment scope matches name": { + user: &akov2.AtlasDatabaseUser{ + Spec: akov2.AtlasDatabaseUserSpec{ + Scopes: []akov2.ScopeSpec{{Type: akov2.DeploymentScopeType, Name: "clusterA"}}, + }, + }, + args: args{epName: "clusterA", epType: akov2.DeploymentScopeType}, + want: true, + }, + "deny: only data lake scopes present for deployment endpoint": { + user: &akov2.AtlasDatabaseUser{ + Spec: akov2.AtlasDatabaseUserSpec{ + Scopes: []akov2.ScopeSpec{ + {Type: akov2.DeploymentScopeType, Name: "clusterB"}, + {Type: akov2.DataLakeScopeType, Name: "clusterA"}, + {Type: akov2.DataLakeScopeType, Name: "df1"}, + {Type: akov2.DataLakeScopeType, Name: "df2"}, + }, + }, + }, + args: args{epName: "clusterA", epType: akov2.DeploymentScopeType}, + want: false, + }, + "allow: multiple scopes where one matches deployment name": { + user: &akov2.AtlasDatabaseUser{ + Spec: akov2.AtlasDatabaseUserSpec{ + Scopes: []akov2.ScopeSpec{ + {Type: akov2.DeploymentScopeType, Name: "clusterX"}, + {Type: akov2.DeploymentScopeType, Name: "clusterA"}, + {Type: akov2.DataLakeScopeType, Name: "df1"}, + }, + }, + }, + args: args{epName: "clusterA", epType: akov2.DeploymentScopeType}, + want: true, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + got := allowsByScopes(tc.user, tc.args.epName, tc.args.epType) + assert.Equal(t, tc.want, got) + }) + } +} + +func Test_generateConnectionSecretRequests(t *testing.T) { + type testCase struct { + projectID string + endpoints []Endpoint + users []akov2.AtlasDatabaseUser + expect []types.NamespacedName + } + + const ( + projectID = "proj-1" + ns1 = "ns-1" + ns2 = "ns-2" + ) + + r := createDummyEnv(t, nil) + + depA := DeploymentEndpoint{ + r: r, + obj: &akov2.AtlasDeployment{ + ObjectMeta: metav1.ObjectMeta{Name: "test-depl", Namespace: "test-ns"}, + Spec: akov2.AtlasDeploymentSpec{DeploymentSpec: &akov2.AdvancedDeploymentSpec{Name: "my-depl-name"}}, + }, + } + df1 := FederationEndpoint{ + r: r, + obj: &akov2.AtlasDataFederation{ + ObjectMeta: metav1.ObjectMeta{Name: "test-df", Namespace: "test-ns"}, + Spec: akov2.DataFederationSpec{Name: "my-df-name"}, + }, + } + + userNoScopes := akov2.AtlasDatabaseUser{ + ObjectMeta: metav1.ObjectMeta{Name: "u1", Namespace: ns1}, + Spec: akov2.AtlasDatabaseUserSpec{Username: "user1"}, + } + userDepScopedMatch := akov2.AtlasDatabaseUser{ + ObjectMeta: metav1.ObjectMeta{Name: "u2", Namespace: ns2}, + Spec: akov2.AtlasDatabaseUserSpec{ + Username: "user2", + Scopes: []akov2.ScopeSpec{{Type: akov2.DeploymentScopeType, Name: "my-depl-name"}}, + }, + } + userDepScopedNoMatch := akov2.AtlasDatabaseUser{ + ObjectMeta: metav1.ObjectMeta{Name: "u3", Namespace: ns1}, + Spec: akov2.AtlasDatabaseUserSpec{ + Username: "user3", + Scopes: []akov2.ScopeSpec{{Type: akov2.DeploymentScopeType, Name: "missing-depl"}}, + }, + } + userDfScopedMatch := akov2.AtlasDatabaseUser{ + ObjectMeta: metav1.ObjectMeta{Name: "u4", Namespace: ns1}, + Spec: akov2.AtlasDatabaseUserSpec{ + Username: "user4", + Scopes: []akov2.ScopeSpec{{Type: akov2.DataLakeScopeType, Name: "my-df-name"}}, + }, + } + + tests := map[string]testCase{ + "no scopes; all endpoints allowed": { + projectID: projectID, + endpoints: []Endpoint{depA, df1}, + users: []akov2.AtlasDatabaseUser{userNoScopes}, + expect: []types.NamespacedName{ + {Namespace: ns1, Name: "proj-1$my-depl-name$user1"}, + {Namespace: ns1, Name: "proj-1$my-df-name$user1"}, + }, + }, + "deployment scoping filters correctly": { + projectID: projectID, + endpoints: []Endpoint{depA, df1}, + users: []akov2.AtlasDatabaseUser{userDepScopedMatch, userDepScopedNoMatch}, + expect: []types.NamespacedName{ + {Namespace: ns2, Name: "proj-1$my-depl-name$user2"}, + }, + }, + "data lake scoping filters correctly with mixed users": { + projectID: projectID, + endpoints: []Endpoint{depA, df1}, + users: []akov2.AtlasDatabaseUser{userNoScopes, userDfScopedMatch}, + expect: []types.NamespacedName{ + {Namespace: ns1, Name: "proj-1$my-depl-name$user1"}, + {Namespace: ns1, Name: "proj-1$my-df-name$user1"}, + {Namespace: ns1, Name: "proj-1$my-df-name$user4"}, + }, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + got := r.generateConnectionSecretRequests(tc.projectID, tc.endpoints, tc.users) + + require := require.New(t) + assert := assert.New(t) + + require.Len(got, len(tc.expect), "unexpected number of requests") + + gotSet := map[types.NamespacedName]struct{}{} + for _, req := range got { + gotSet[req.NamespacedName] = struct{}{} + } + for _, e := range tc.expect { + _, ok := gotSet[e] + assert.Truef(ok, "missing expected request %s/%s", e.Namespace, e.Name) + } + }) + } +} diff --git a/internal/controller/connsecrets-generic/connectionsecret_test.go b/internal/controller/connsecrets-generic/connectionsecret_test.go new file mode 100644 index 0000000000..8fdfe04307 --- /dev/null +++ b/internal/controller/connsecrets-generic/connectionsecret_test.go @@ -0,0 +1,1016 @@ +// Copyright 2025 MongoDB Inc +// +// 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 connsecretsgeneric + +import ( + "context" + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + apiErrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + + akov2 "github.com/mongodb/mongodb-atlas-kubernetes/v2/api/v1" + "github.com/mongodb/mongodb-atlas-kubernetes/v2/api/v1/common" + "github.com/mongodb/mongodb-atlas-kubernetes/v2/api/v1/status" +) + +func Test_createK8sFormat(t *testing.T) { + tests := map[string]struct { + projectName string + clusterName string + databaseUsername string + expected string + }{ + "normal values": { + projectName: "MyProject", + clusterName: "MyCluster", + databaseUsername: "AdminUser", + expected: "myproject-mycluster-adminuser", + }, + "already normalized": { + projectName: "proj", + clusterName: "cluster", + databaseUsername: "user", + expected: "proj-cluster-user", + }, + "values with spaces and caps": { + projectName: "Proj A", + clusterName: "Cluster B", + databaseUsername: "Admin X", + expected: "proj-a-cluster-b-admin-x", + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + actual := CreateK8sFormat(tc.projectName, tc.clusterName, tc.databaseUsername) + assert.Equal(t, tc.expected, actual) + }) + } +} + +func TestCreateInternalFormat(t *testing.T) { + tests := map[string]struct { + projectID string + clusterName string + databaseUsername string + expected string + }{ + "normal values": { + projectID: "proj123", + clusterName: "ClusterOne", + databaseUsername: "DBUser", + expected: "proj123$clusterone$dbuser", + }, + "cluster and user already normalized": { + projectID: "id456", + clusterName: "cluster", + databaseUsername: "user", + expected: "id456$cluster$user", + }, + "values with spaces": { + projectID: "id789", + clusterName: "CL X", + databaseUsername: "U X", + expected: "id789$cl-x$u-x", + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + actual := CreateInternalFormat(tc.projectID, tc.clusterName, tc.databaseUsername) + assert.Equal(t, tc.expected, actual) + }) + } +} + +func Test_loadIdentifiers(t *testing.T) { + type want struct { + projectID string + projectName string + clusterName string + databaseUsername string + err error + } + + tests := map[string]struct { + reqName string + ns string + secret *corev1.Secret + want want + }{ + "fail: internal format-invalid parts count": { + reqName: "only" + InternalSeparator + "two", + ns: "default", + want: want{err: ErrInternalFormatErr}, + }, + "fail: internal format-empty part": { + reqName: "p" + InternalSeparator + InternalSeparator + "u", + ns: "default", + want: want{err: ErrInternalFormatErr}, + }, + "success: internal format": { + reqName: "proj123" + InternalSeparator + "mycluster" + InternalSeparator + "theuser", + ns: "default", + want: want{ + projectID: "proj123", + projectName: "", + clusterName: "mycluster", + databaseUsername: "theuser", + err: nil, + }, + }, + "fail: k8s format-missing labels": { + reqName: "p-c-u", + ns: "ns", + secret: &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "p-c-u", + Namespace: "ns", + Labels: map[string]string{}, + }, + }, + want: want{err: ErrK8SFormatErr}, + }, + "fail: k8s format-empty labels": { + reqName: "p-c-u", + ns: "ns", + secret: &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "p-c-u", + Namespace: "ns", + Labels: map[string]string{ + ProjectLabelKey: "", + ClusterLabelKey: "", + }, + }, + }, + want: want{err: ErrK8SFormatErr}, + }, + "fail: k8s format-name split invalid": { + reqName: "proj-notmatchingsep-user", + ns: "ns", + secret: &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "proj-notmatchingsep-user", + Namespace: "ns", + Labels: map[string]string{ + ProjectLabelKey: "pid-1", + ClusterLabelKey: "clusterX", + }, + }, + }, + want: want{err: ErrK8SFormatErr}, + }, + "fail: k8s format-name split empty": { + reqName: "-clusterY-user", + ns: "ns", + secret: &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "-clusterY-user", + Namespace: "ns", + Labels: map[string]string{ + ProjectLabelKey: "pid-2", + ClusterLabelKey: "clusterY", + }, + }, + }, + want: want{err: ErrK8SFormatErr}, + }, + "success: k8s format": { + reqName: "myproj-mycluster-admin", + ns: "test-ns", + secret: &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "myproj-mycluster-admin", + Namespace: "test-ns", + Labels: map[string]string{ + ProjectLabelKey: "test-project-id", + ClusterLabelKey: "mycluster", + }, + }, + }, + want: want{ + projectID: "test-project-id", + projectName: "myproj", + clusterName: "mycluster", + databaseUsername: "admin", + err: nil, + }, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + var objs []client.Object + if tc.secret != nil { + objs = append(objs, tc.secret) + } + r := createDummyEnv(t, objs) + + got, err := r.loadIdentifiers(context.Background(), types.NamespacedName{ + Name: tc.reqName, + Namespace: tc.ns, + }) + + if tc.want.err != nil { + assert.ErrorIs(t, err, tc.want.err) + assert.Nil(t, got) + return + } + + assert.NoError(t, err) + if assert.NotNil(t, got) { + assert.Equal(t, tc.want.projectID, got.ProjectID) + assert.Equal(t, tc.want.projectName, got.ProjectName) + assert.Equal(t, tc.want.clusterName, got.ClusterName) + assert.Equal(t, tc.want.databaseUsername, got.DatabaseUsername) + } + }) + } +} + +func Test_loadPair(t *testing.T) { + scheme := runtime.NewScheme() + assert.NoError(t, akov2.AddToScheme(scheme)) + + const ( + ns = "test-ns" + projectID = "test-project-id" + otherProjectID = "proj456" + ) + + type fields struct { + endpointObjs []client.Object + users []*akov2.AtlasDatabaseUser + } + + tests := map[string]struct { + clusterName string + databaseUsername string + fields fields + expectedErr error + expectedPairNil bool + expectUserNil bool + expectEpNil bool + }{ + "fail: ambiguous-multiple users": { + clusterName: "clusterA", + databaseUsername: "admin", + fields: fields{ + endpointObjs: []client.Object{ + &akov2.AtlasDeployment{ + ObjectMeta: metav1.ObjectMeta{Name: "dep1", Namespace: ns}, + Spec: akov2.AtlasDeploymentSpec{DeploymentSpec: &akov2.AdvancedDeploymentSpec{Name: "clusterA"}}, + }, + }, + users: []*akov2.AtlasDatabaseUser{ + {ObjectMeta: metav1.ObjectMeta{Name: "u1", Namespace: ns}, Spec: akov2.AtlasDatabaseUserSpec{Username: "admin"}}, + {ObjectMeta: metav1.ObjectMeta{Name: "u2", Namespace: ns}, Spec: akov2.AtlasDatabaseUserSpec{Username: "admin"}}, + }, + }, + expectedErr: ErrAmbiguousPairing, + expectedPairNil: true, + }, + "fail: ambiguous-multiple endpoints (2 deployments)": { + clusterName: "clusterB", + databaseUsername: "root", + fields: fields{ + endpointObjs: []client.Object{ + &akov2.AtlasDeployment{ + ObjectMeta: metav1.ObjectMeta{Name: "dep-a", Namespace: ns}, + Spec: akov2.AtlasDeploymentSpec{DeploymentSpec: &akov2.AdvancedDeploymentSpec{Name: "clusterB"}}, + }, + &akov2.AtlasDeployment{ + ObjectMeta: metav1.ObjectMeta{Name: "dep-b", Namespace: ns}, + Spec: akov2.AtlasDeploymentSpec{DeploymentSpec: &akov2.AdvancedDeploymentSpec{Name: "clusterB"}}, + }, + }, + users: []*akov2.AtlasDatabaseUser{ + {ObjectMeta: metav1.ObjectMeta{Name: "u", Namespace: ns}, Spec: akov2.AtlasDatabaseUserSpec{Username: "root"}}, + }, + }, + expectedErr: ErrAmbiguousPairing, + expectedPairNil: true, + }, + "fail: ambiguous-multiple endpoints (deployment and federation share name)": { + clusterName: "clusterC", + databaseUsername: "admin", + fields: fields{ + endpointObjs: []client.Object{ + &akov2.AtlasDeployment{ + ObjectMeta: metav1.ObjectMeta{Name: "dep-a", Namespace: ns}, + Spec: akov2.AtlasDeploymentSpec{DeploymentSpec: &akov2.AdvancedDeploymentSpec{Name: "clusterC"}}, + }, + &akov2.AtlasDataFederation{ + ObjectMeta: metav1.ObjectMeta{Name: "df-a", Namespace: ns}, + Spec: akov2.DataFederationSpec{Name: "clusterC"}, + }, + }, + users: []*akov2.AtlasDatabaseUser{ + {ObjectMeta: metav1.ObjectMeta{Name: "u", Namespace: ns}, Spec: akov2.AtlasDatabaseUserSpec{Username: "admin"}}, + }, + }, + expectedErr: ErrAmbiguousPairing, + expectedPairNil: true, + }, + "fail: both missing": { + clusterName: "clusterD", + databaseUsername: "andrpac", + fields: fields{ + endpointObjs: nil, + users: nil, + }, + expectedErr: ErrMissingPairing, + expectedPairNil: true, + expectUserNil: true, + expectEpNil: true, + }, + "fail: user present but endpoint missing": { + clusterName: "missing", + databaseUsername: "admin", + fields: fields{ + endpointObjs: nil, + users: []*akov2.AtlasDatabaseUser{ + {ObjectMeta: metav1.ObjectMeta{Name: "u-only", Namespace: ns}, Spec: akov2.AtlasDatabaseUserSpec{Username: "admin"}}, + }, + }, + expectedErr: ErrMissingPairing, + expectEpNil: true, + }, + "fail: user absent but endpoint present": { + clusterName: "clusterE", + databaseUsername: "missing", + fields: fields{ + endpointObjs: []client.Object{ + &akov2.AtlasDataFederation{ + ObjectMeta: metav1.ObjectMeta{Name: "df", Namespace: ns}, + Spec: akov2.DataFederationSpec{Name: "clusterE"}, + }, + }, + users: nil, + }, + expectedErr: ErrMissingPairing, + expectUserNil: true, + }, + "success: exactly one user and one endpoint": { + clusterName: "clusterF", + databaseUsername: "admin", + fields: fields{ + endpointObjs: []client.Object{ + &akov2.AtlasDeployment{ + ObjectMeta: metav1.ObjectMeta{Name: "dep", Namespace: ns}, + Spec: akov2.AtlasDeploymentSpec{DeploymentSpec: &akov2.AdvancedDeploymentSpec{Name: "clusterF"}}, + }, + }, + users: []*akov2.AtlasDatabaseUser{ + {ObjectMeta: metav1.ObjectMeta{Name: "uu", Namespace: ns}, Spec: akov2.AtlasDatabaseUserSpec{Username: "admin"}}, + }, + }, + expectedErr: nil, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + var all []client.Object + all = append(all, tc.fields.endpointObjs...) + for _, u := range tc.fields.users { + all = append(all, u) + } + + r := createDummyEnv(t, all) + r.EndpointKinds = []Endpoint{DeploymentEndpoint{r: r}, FederationEndpoint{r: r}} + + ids := &ConnSecretIdentifiers{ + ProjectID: projectID, + ClusterName: tc.clusterName, + DatabaseUsername: tc.databaseUsername, + } + + pair, err := r.loadPair(context.Background(), ids) + + if tc.expectedErr != nil { + assert.ErrorIs(t, err, tc.expectedErr) + } else { + assert.NoError(t, err) + } + + if tc.expectedPairNil { + assert.Nil(t, pair) + return + } + + if tc.expectUserNil { + assert.Nil(t, pair.User) + } else { + if assert.NotNil(t, pair.User) { + assert.Equal(t, tc.databaseUsername, pair.User.Spec.Username) + } + } + if tc.expectEpNil { + assert.Nil(t, pair.Endpoint) + } else { + assert.NotNil(t, pair.Endpoint) + } + assert.Equal(t, projectID, pair.ProjectID) + + missIDs := &ConnSecretIdentifiers{ + ProjectID: otherProjectID, + ClusterName: tc.clusterName, + DatabaseUsername: tc.databaseUsername, + } + missPair, missErr := r.loadPair(context.Background(), missIDs) + assert.ErrorIs(t, missErr, ErrMissingPairing) + assert.Nil(t, missPair) + }) + } +} + +func Test_resolveProjectName(t *testing.T) { + const ( + ns = "test-ns" + projectID = "test-project-id" + ) + + type want struct { + projectName string + err error + } + + r := createDummyEnv(t, nil) + + var notFoundErr = apiErrors.NewNotFound( + schema.GroupResource{Group: "atlas.mongodb.com", Resource: "atlasprojects"}, + "missing-proj", + ) + + tests := map[string]struct { + ids *ConnSecretIdentifiers + pair *ConnSecretPair + want want + }{ + "fail: nil pair and ids without projectName": { + ids: &ConnSecretIdentifiers{ProjectID: projectID}, + pair: nil, + want: want{ + projectName: "", + err: ErrUnresolvedProjectName, + }, + }, + "fail: cannot resolve from endpoint; no user": { + ids: &ConnSecretIdentifiers{ProjectID: projectID}, + pair: &ConnSecretPair{ + ProjectID: projectID, + Endpoint: DeploymentEndpoint{ + r: r, + obj: &akov2.AtlasDeployment{ + ObjectMeta: metav1.ObjectMeta{Name: "dep", Namespace: ns}, + Spec: akov2.AtlasDeploymentSpec{ + ProjectDualReference: akov2.ProjectDualReference{ + ProjectRef: &common.ResourceRefNamespaced{Name: "missing-proj"}, + }, + }, + }, + }, + User: nil, + }, + want: want{ + projectName: "", + err: notFoundErr, + }, + }, + "fail: cannot resolve from user; no endpoint": { + ids: &ConnSecretIdentifiers{ProjectID: projectID}, + pair: &ConnSecretPair{ + ProjectID: projectID, + User: &akov2.AtlasDatabaseUser{ + ObjectMeta: metav1.ObjectMeta{Name: "u", Namespace: ns}, + Spec: akov2.AtlasDatabaseUserSpec{ + ProjectDualReference: akov2.ProjectDualReference{ + ProjectRef: &common.ResourceRefNamespaced{Name: "missing-proj"}, + }, + }, + }, + Endpoint: nil, + }, + want: want{ + projectName: "", + err: notFoundErr, + }, + }, + "success: ids carries projectName": { + ids: &ConnSecretIdentifiers{ + ProjectID: projectID, + ProjectName: "my-project-name", + }, + pair: nil, + want: want{ + projectName: "my-project-name", + err: nil, + }, + }, + "success: resolve from endpoint": { + ids: &ConnSecretIdentifiers{ProjectID: projectID}, + pair: &ConnSecretPair{ + ProjectID: projectID, + Endpoint: DeploymentEndpoint{ + r: r, + obj: &akov2.AtlasDeployment{ + ObjectMeta: metav1.ObjectMeta{Name: "dep", Namespace: ns}, + Spec: akov2.AtlasDeploymentSpec{ + ProjectDualReference: akov2.ProjectDualReference{ + ProjectRef: &common.ResourceRefNamespaced{Name: "test-project"}, + }, + }, + }, + }, + }, + want: want{ + projectName: "my-project-name", + err: nil, + }, + }, + "success: resolve from user": { + ids: &ConnSecretIdentifiers{ProjectID: projectID}, + pair: &ConnSecretPair{ + ProjectID: projectID, + User: &akov2.AtlasDatabaseUser{ + ObjectMeta: metav1.ObjectMeta{Name: "u", Namespace: ns}, + Spec: akov2.AtlasDatabaseUserSpec{ + ProjectDualReference: akov2.ProjectDualReference{ + ProjectRef: &common.ResourceRefNamespaced{Name: "test-project"}, + }, + }, + }, + Endpoint: nil, + }, + want: want{ + projectName: "my-project-name", + err: nil, + }, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + got, err := r.resolveProjectName(context.Background(), tc.ids, tc.pair) + + if tc.want.err != nil { + assert.Equal(t, tc.want.err, err) + assert.Equal(t, "", got) + return + } + + assert.NoError(t, err) + assert.Equal(t, tc.want.projectName, got) + }) + } +} + +func Test_handleDelete(t *testing.T) { + type expectedResult struct { + expectedResult ctrl.Result + expectedError error + } + + const ( + cluster = "cluster1" + username = "admin" + projectID = "test-project-id" + projectName = "my-project-name" + ) + + type testCase struct { + ids ConnSecretIdentifiers + pair ConnSecretPair + result expectedResult + } + + r := createDummyEnv(t, nil) + + dep := &akov2.AtlasDeployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-deployment", + Namespace: "test-ns", + }, + Spec: akov2.AtlasDeploymentSpec{ + ProjectDualReference: akov2.ProjectDualReference{ + ProjectRef: &common.ResourceRefNamespaced{ + Name: "test-project", + Namespace: "test-ns", + }, + }, + DeploymentSpec: &akov2.AdvancedDeploymentSpec{ + Name: "cluster1", + }, + }, + } + + user := &akov2.AtlasDatabaseUser{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-user", + Namespace: "test-ns", + }, Spec: akov2.AtlasDatabaseUserSpec{ + Username: "admin", + }, + } + + depEndpoint := DeploymentEndpoint{r: r, obj: dep} + + tests := map[string]testCase{ + "fail: projects with no parents cannot be directly deleted": { + ids: ConnSecretIdentifiers{ + ClusterName: cluster, + DatabaseUsername: username, + }, + pair: ConnSecretPair{ + ProjectID: projectID, + User: nil, + Endpoint: nil, + }, + result: expectedResult{ + expectedResult: ctrl.Result{}, + expectedError: ErrUnresolvedProjectName, + }, + }, + "success: no secret present beforehand": { + ids: ConnSecretIdentifiers{ + ProjectName: "missing-proj", + ClusterName: cluster, + DatabaseUsername: username, + }, + pair: ConnSecretPair{ + ProjectID: projectID, + User: user, + Endpoint: depEndpoint, + }, + result: expectedResult{ + expectedResult: ctrl.Result{}, + expectedError: nil, + }, + }, + "success: delete existing secret": { + ids: ConnSecretIdentifiers{ + ProjectName: projectName, + ClusterName: cluster, + DatabaseUsername: username, + }, + pair: ConnSecretPair{ + ProjectID: projectID, + User: user, + Endpoint: depEndpoint, + }, + result: expectedResult{ + expectedResult: ctrl.Result{}, + expectedError: nil, + }, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + req := ctrl.Request{ + NamespacedName: types.NamespacedName{ + Namespace: "test-ns", + Name: "any", + }, + } + + res, err := r.handleDelete(context.Background(), req, &tc.ids, &tc.pair) + assert.Equal(t, tc.result.expectedResult, res) + + if tc.result.expectedError != nil { + require.ErrorIs(t, err, tc.result.expectedError) + return + } + require.NoError(t, err) + + if tc.pair.Endpoint == nil && tc.pair.User == nil { + return + } + + resolvedProjectName := tc.ids.ProjectName + if resolvedProjectName == "" { + resolvedProjectName, _ = tc.pair.Endpoint.GetProjectName(context.Background()) + } + + var s corev1.Secret + secretName := CreateK8sFormat(resolvedProjectName, tc.ids.ClusterName, tc.ids.DatabaseUsername) + getErr := r.Client.Get(context.Background(), types.NamespacedName{Namespace: "test-ns", Name: secretName}, &s) + require.True(t, apiErrors.IsNotFound(getErr), "expected secret %s to be deleted", secretName) + }) + } +} + +func Test_handleUpsert(t *testing.T) { + type expectedResult struct { + expectedResult ctrl.Result + expectedError error + } + + const ( + ns = "test-ns" + cluster = "cluster1" + username = "admin" + projectID = "test-project-id" + projectName = "my-project-name" + ) + + type testCase struct { + ids ConnSecretIdentifiers + pair ConnSecretPair + result expectedResult + } + + r := createDummyEnv(t, nil) + + dep := &akov2.AtlasDeployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-deployment", + Namespace: ns, + }, + Spec: akov2.AtlasDeploymentSpec{ + ProjectDualReference: akov2.ProjectDualReference{ + ProjectRef: &common.ResourceRefNamespaced{ + Name: "test-project", + Namespace: ns, + }, + }, + DeploymentSpec: &akov2.AdvancedDeploymentSpec{Name: cluster}, + }, + Status: status.AtlasDeploymentStatus{ + ConnectionStrings: &status.ConnectionStrings{ + Standard: "mongodb://cluster1.mongodb.net/?authSource=admin", + StandardSrv: "mongodb+srv://cluster1.mongodb.net/?authSource=admin", + }, + }, + } + + user := &akov2.AtlasDatabaseUser{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-user", + Namespace: ns, + }, + Spec: akov2.AtlasDatabaseUserSpec{ + Username: username, + PasswordSecret: &common.ResourceRef{Name: "user-pass"}, + }, + } + + depEndpoint := DeploymentEndpoint{r: r, obj: dep} + + tests := map[string]testCase{ + "fail: upserting requires project resolution": { + ids: ConnSecretIdentifiers{ + ClusterName: cluster, + DatabaseUsername: username, + }, + pair: ConnSecretPair{ + ProjectID: projectID, + User: nil, + Endpoint: nil, + }, + result: expectedResult{ + expectedResult: ctrl.Result{}, + expectedError: ErrUnresolvedProjectName, + }, + }, + "fail: cannot build data": { + ids: ConnSecretIdentifiers{ + ProjectName: projectName, + ClusterName: cluster, + DatabaseUsername: username, + }, + pair: ConnSecretPair{ + ProjectID: projectID, + User: nil, + Endpoint: depEndpoint, + }, + result: expectedResult{ + expectedResult: ctrl.Result{}, + expectedError: ErrMissingPairing, + }, + }, + "success: upsert secret": { + ids: ConnSecretIdentifiers{ + ProjectName: projectName, + ClusterName: cluster, + DatabaseUsername: username, + ProjectID: projectID, + }, + pair: ConnSecretPair{ + ProjectID: projectID, + User: user, + Endpoint: depEndpoint, + }, + result: expectedResult{ + expectedResult: ctrl.Result{}, + expectedError: nil, + }, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + req := ctrl.Request{ + NamespacedName: types.NamespacedName{Namespace: ns, Name: "any"}, + } + + res, err := r.handleUpsert(context.Background(), req, &tc.ids, &tc.pair) + assert.Equal(t, tc.result.expectedResult, res) + + if tc.result.expectedError != nil { + require.Error(t, err) + assert.Contains(t, err.Error(), tc.result.expectedError.Error()) + return + } + require.NoError(t, err) + + if tc.pair.Endpoint == nil || tc.pair.User == nil { + return + } + resolvedProjectName := tc.ids.ProjectName + if resolvedProjectName == "" { + resolvedProjectName, _ = tc.pair.Endpoint.GetProjectName(context.Background()) + } + + var s corev1.Secret + secretName := CreateK8sFormat(resolvedProjectName, tc.ids.ClusterName, tc.ids.DatabaseUsername) + require.NoError(t, r.Client.Get(context.Background(), types.NamespacedName{Namespace: ns, Name: secretName}, &s)) + + require.Equal(t, CredLabelVal, s.Labels[TypeLabelKey]) + require.Equal(t, projectID, s.Labels[ProjectLabelKey]) + require.Equal(t, cluster, s.Labels[ClusterLabelKey]) + + require.Equal(t, username, string(s.Data[userNameKey])) + require.Equal(t, "secret", string(s.Data[passwordKey])) + }) + } +} + +func Test_ensureSecret(t *testing.T) { + type expectedResult struct { + expectedError error + } + + const ( + ns = "test-ns" + cluster = "cluster1" + username = "admin" + projectID = "test-project-id" + projectName = "my-project-name" + ) + + r := createDummyEnv(t, nil) + + user := &akov2.AtlasDatabaseUser{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-user", + Namespace: "test-ns", + }, Spec: akov2.AtlasDatabaseUserSpec{ + Username: "admin", + }, + } + + connData := ConnSecretData{ + DBUserName: username, + Password: "newpassword", + ConnURL: "mongodb://cluster1.mongodb.net/?authSource=admin", + SrvConnURL: "mongodb+srv://cluster1.mongodb.net/?authSource=admin", + PrivateConnURLs: []PrivateLinkConnURLs{ + { + PvtConnURL: "mongodb://pe1.mongodb.net", + PvtSrvConnURL: "mongodb+srv://pe1.mongodb.net", + PvtShardConnURL: "mongodb+srv://pe1-shard.mongodb.net", + }, + { + PvtConnURL: "mongodb://pe2.mongodb.net", + PvtSrvConnURL: "mongodb+srv://pe2.mongodb.net", + PvtShardConnURL: "mongodb+srv://pe2-shard.mongodb.net", + }, + }, + } + + tests := map[string]struct { + ids ConnSecretIdentifiers + pair ConnSecretPair + secrets []client.Object + data ConnSecretData + result expectedResult + }{ + "fail: invalid URL bubbles up and prevents creation": { + ids: ConnSecretIdentifiers{ + ProjectID: projectID, + ProjectName: projectName, + ClusterName: cluster, + DatabaseUsername: username, + }, + pair: ConnSecretPair{User: user}, + data: ConnSecretData{ + DBUserName: username, + Password: "test-pass", + ConnURL: "://\x00", + }, + result: expectedResult{expectedError: fmt.Errorf("parse \"://\\x00\": net/url: invalid control character in URL")}, + }, + "success: create with private endpoints": { + ids: ConnSecretIdentifiers{ + ProjectID: projectID, + ProjectName: "new-project-name", + ClusterName: cluster, + DatabaseUsername: username, + }, + pair: ConnSecretPair{User: user}, + data: connData, + result: expectedResult{expectedError: nil}, + }, + "success: update existing secret": { + ids: ConnSecretIdentifiers{ + ProjectID: projectID, + ProjectName: projectName, + ClusterName: cluster, + DatabaseUsername: username, + }, + pair: ConnSecretPair{User: user}, + data: connData, + result: expectedResult{expectedError: nil}, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + err := r.ensureSecret(context.Background(), &tc.ids, &tc.pair, tc.data) + if tc.result.expectedError != nil { + require.Error(t, err) + return + } + require.NoError(t, err) + + secretName := CreateK8sFormat(tc.ids.ProjectName, tc.ids.ClusterName, tc.ids.DatabaseUsername) + var s corev1.Secret + getErr := r.Client.Get(context.Background(), types.NamespacedName{Namespace: ns, Name: secretName}, &s) + require.NoError(t, getErr) + + require.Equal(t, CredLabelVal, s.Labels[TypeLabelKey]) + require.Equal(t, projectID, s.Labels[ProjectLabelKey]) + require.Equal(t, cluster, s.Labels[ClusterLabelKey]) + + require.Equal(t, username, string(s.Data[userNameKey])) + require.Equal(t, tc.data.Password, string(s.Data[passwordKey])) + + urlsToCheck := map[string]string{ + standardKey: "mongodb://cluster1.mongodb.net/?authSource=admin", + standardKeySrv: "mongodb+srv://cluster1.mongodb.net/?authSource=admin", + } + + privateEndpoints := []status.PrivateEndpoint{ + { + ConnectionString: "mongodb://pe1.mongodb.net", + SRVConnectionString: "mongodb+srv://pe1.mongodb.net", + SRVShardOptimizedConnectionString: "mongodb+srv://pe1-shard.mongodb.net", + }, + { + ConnectionString: "mongodb://pe2.mongodb.net", + SRVConnectionString: "mongodb+srv://pe2.mongodb.net", + SRVShardOptimizedConnectionString: "mongodb+srv://pe2-shard.mongodb.net", + }, + } + + for i, pe := range privateEndpoints { + var suffix string + if i != 0 { + suffix = fmt.Sprint(i) + } + urlsToCheck[fmt.Sprintf("%s%s", privateKey, suffix)] = pe.ConnectionString + urlsToCheck[fmt.Sprintf("%s%s", privateSrvKey, suffix)] = pe.SRVConnectionString + urlsToCheck[fmt.Sprintf("%s%s", privateShardKey, suffix)] = pe.SRVShardOptimizedConnectionString + } + + for key, baseURL := range urlsToCheck { + want, _ := createURL(baseURL, username, tc.data.Password) + require.Equal(t, want, string(s.Data[key]), "mismatch for %s", key) + } + }) + } +} diff --git a/internal/controller/connsecrets-generic/endpoint_deployment.go b/internal/controller/connsecrets-generic/endpoint_deployment.go new file mode 100644 index 0000000000..a86ec34534 --- /dev/null +++ b/internal/controller/connsecrets-generic/endpoint_deployment.go @@ -0,0 +1,167 @@ +// Copyright 2025 MongoDB Inc +// +// 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 connsecretsgeneric + +import ( + "context" + "fmt" + + "k8s.io/apimachinery/pkg/fields" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/mongodb/mongodb-atlas-kubernetes/v2/api" + akov2 "github.com/mongodb/mongodb-atlas-kubernetes/v2/api/v1" + "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/indexer" + "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/kube" +) + +type DeploymentEndpoint struct { + obj *akov2.AtlasDeployment + r *ConnSecretReconciler +} + +// GetName resolves the endpoints name from the spec +func (e DeploymentEndpoint) GetName() string { + if e.obj == nil { + return "" + } + return e.obj.GetDeploymentName() +} + +// IsReady returns true if the endpoint is ready +func (e DeploymentEndpoint) IsReady() bool { + return e.obj != nil && api.HasReadyCondition(e.obj.Status.Conditions) +} + +// GetScopeType returns the scope type of the endpoint to match with the ones from AtlasDatabaseUser +func (e DeploymentEndpoint) GetScopeType() akov2.ScopeType { + return akov2.DeploymentScopeType +} + +// GetProjectID resolves parent project's id (ProjectRef or ExternalRef) +func (e DeploymentEndpoint) GetProjectID(ctx context.Context) (string, error) { + if e.obj == nil { + return "", fmt.Errorf("nil deployment") + } + if e.obj.Spec.ExternalProjectRef != nil && e.obj.Spec.ExternalProjectRef.ID != "" { + return e.obj.Spec.ExternalProjectRef.ID, nil + } + if e.obj.Spec.ProjectRef != nil && e.obj.Spec.ProjectRef.Name != "" && e.r != nil && e.r.Client != nil { + proj := &akov2.AtlasProject{} + if err := e.r.Client.Get(ctx, e.obj.AtlasProjectObjectKey(), proj); err != nil { + return "", err + } + return proj.ID(), nil + } + return "", ErrUnresolvedProjectID +} + +// GetProjectName returns the parent project's name (either by getting K8s AtlasProject or SDK calls) +func (e DeploymentEndpoint) GetProjectName(ctx context.Context) (string, error) { + if e.obj == nil { + return "", fmt.Errorf("nil deployment") + } + if e.obj.Spec.ProjectRef != nil && e.obj.Spec.ProjectRef.Name != "" && e.r != nil && e.r.Client != nil { + proj := &akov2.AtlasProject{} + if err := e.r.Client.Get(ctx, e.obj.AtlasProjectObjectKey(), proj); err != nil { + return "", err + } + if proj.Spec.Name != "" { + return kube.NormalizeIdentifier(proj.Spec.Name), nil + } + } + + if e.r != nil { + cfg, err := e.r.ResolveConnectionConfig(ctx, e.obj) + if err != nil { + return "", err + } + sdk, err := e.r.AtlasProvider.SdkClientSet(ctx, cfg.Credentials, e.r.Log) + if err != nil { + return "", err + } + ap, err := e.r.ResolveProject(ctx, sdk.SdkClient20250312002, e.obj) + if err != nil { + return "", err + } + return kube.NormalizeIdentifier(ap.Name), nil + } + return "", ErrUnresolvedProjectName +} + +// Defines the list type +func (DeploymentEndpoint) ListObj() client.ObjectList { return &akov2.AtlasDeploymentList{} } + +// Defines the selector to use for indexer when trying to retrieve all endpoints by project +func (DeploymentEndpoint) SelectorByProject(projectRef string) fields.Selector { + return fields.OneTermEqualSelector(indexer.AtlasDeploymentByProject, projectRef) +} + +// Defines the selector to use for indexer when trying to retrieve all endpoints by project and spec name +func (DeploymentEndpoint) SelectorByProjectAndName(ids *ConnSecretIdentifiers) fields.Selector { + return fields.OneTermEqualSelector(indexer.AtlasDeploymentBySpecNameAndProjectID, ids.ProjectID+"-"+ids.ClusterName) +} + +// ExtractList creates a list of Endpoint types to preserve the abstraction +func (e DeploymentEndpoint) ExtractList(ol client.ObjectList) ([]Endpoint, error) { + l, ok := ol.(*akov2.AtlasDeploymentList) + if !ok { + return nil, fmt.Errorf("unexpected list type %T", ol) + } + out := make([]Endpoint, 0, len(l.Items)) + for i := range l.Items { + out = append(out, DeploymentEndpoint{obj: &l.Items[i], r: e.r}) + } + return out, nil +} + +// BuildConnData defines the specific function/way for building the ConnSecretData given this type of endpoint +// AtlasDeployment stores connection strings in the status field +func (e DeploymentEndpoint) BuildConnData(ctx context.Context, user *akov2.AtlasDatabaseUser) (ConnSecretData, error) { + if user == nil || e.obj == nil { + return ConnSecretData{}, ErrMissingPairing + } + password, err := user.ReadPassword(ctx, e.r.Client) + if err != nil { + return ConnSecretData{}, fmt.Errorf("failed to read password for user %q: %w", user.Spec.Username, err) + } + data := ConnSecretData{ + DBUserName: user.Spec.Username, + Password: password, + } + + if e.obj.Status.ConnectionStrings == nil { + return data, nil + } + + conn := e.obj.Status.ConnectionStrings + data.ConnURL = conn.Standard + data.SrvConnURL = conn.StandardSrv + if conn.Private != "" { + data.PrivateConnURLs = append(data.PrivateConnURLs, PrivateLinkConnURLs{ + PvtConnURL: conn.Private, + PvtSrvConnURL: conn.PrivateSrv, + }) + } + for _, pe := range conn.PrivateEndpoint { + data.PrivateConnURLs = append(data.PrivateConnURLs, PrivateLinkConnURLs{ + PvtConnURL: pe.ConnectionString, + PvtSrvConnURL: pe.SRVConnectionString, + PvtShardConnURL: pe.SRVShardOptimizedConnectionString, + }) + } + + return data, nil +} diff --git a/internal/controller/connsecrets-generic/endpoint_deployment_test.go b/internal/controller/connsecrets-generic/endpoint_deployment_test.go new file mode 100644 index 0000000000..be4f760427 --- /dev/null +++ b/internal/controller/connsecrets-generic/endpoint_deployment_test.go @@ -0,0 +1,357 @@ +// Copyright 2025 MongoDB Inc +// +// 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 connsecretsgeneric + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/fields" + + "github.com/mongodb/mongodb-atlas-kubernetes/v2/api" + akov2 "github.com/mongodb/mongodb-atlas-kubernetes/v2/api/v1" + "github.com/mongodb/mongodb-atlas-kubernetes/v2/api/v1/common" + "github.com/mongodb/mongodb-atlas-kubernetes/v2/api/v1/status" + "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/indexer" +) + +func setupDeploymentTest(t *testing.T) (*ConnSecretReconciler, *akov2.AtlasDeployment, *akov2.AtlasDeployment) { + t.Helper() + + // Returns a valid Deployment + dep := &akov2.AtlasDeployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-deployment", + Namespace: "test-ns", + }, + Spec: akov2.AtlasDeploymentSpec{ + ProjectDualReference: akov2.ProjectDualReference{ + ProjectRef: &common.ResourceRefNamespaced{ + Name: "test-project", + Namespace: "test-ns", + }, + }, + DeploymentSpec: &akov2.AdvancedDeploymentSpec{ + Name: "my-cluster", + }, + }, + } + + // Returns a valid Deployment with an external Ref + dep_external := &akov2.AtlasDeployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-deployment-ext", + Namespace: "test-ns", + }, + Spec: akov2.AtlasDeploymentSpec{ + ProjectDualReference: akov2.ProjectDualReference{ + ExternalProjectRef: &akov2.ExternalProjectReference{ID: "test-project-id"}, + ConnectionSecret: &api.LocalObjectReference{Name: "sdk-creds"}, + }, + DeploymentSpec: &akov2.AdvancedDeploymentSpec{ + Name: "my-cluster", + }, + }, + } + + r := createDummyEnv(t, nil) + return r, dep, dep_external +} + +func TestDeploymentEndpoint_GetName(t *testing.T) { + eNil := DeploymentEndpoint{obj: nil} + assert.Equal(t, "", eNil.GetName()) + + dep := &akov2.AtlasDeployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-delp", + Namespace: "test-ns", + }, + Spec: akov2.AtlasDeploymentSpec{ + DeploymentSpec: &akov2.AdvancedDeploymentSpec{Name: "my-depl-name"}, + }, + } + e := DeploymentEndpoint{obj: dep} + assert.Equal(t, "my-depl-name", e.GetName()) +} + +func TestDeploymentEndpoint_IsReady(t *testing.T) { + eNil := DeploymentEndpoint{obj: nil} + assert.False(t, eNil.IsReady()) + + notReady := &akov2.AtlasDeployment{ + Status: status.AtlasDeploymentStatus{ + Common: api.Common{ + Conditions: []api.Condition{{Type: api.ReadyType, Status: "False"}}, + }, + }, + } + assert.False(t, DeploymentEndpoint{obj: notReady}.IsReady()) + + ready := &akov2.AtlasDeployment{ + Status: status.AtlasDeploymentStatus{ + Common: api.Common{ + Conditions: []api.Condition{{Type: api.ReadyType, Status: "True"}}, + }, + }, + } + assert.True(t, DeploymentEndpoint{obj: ready}.IsReady()) +} + +func TestDeploymentEndpoint_GetScopeType(t *testing.T) { + e := DeploymentEndpoint{} + assert.Equal(t, akov2.DeploymentScopeType, e.GetScopeType()) +} + +func TestDeploymentEndpoint_GetProjectID(t *testing.T) { + r, dep, dep_external := setupDeploymentTest(t) + + tests := map[string]struct { + endpoint DeploymentEndpoint + want string + wantErr bool + }{ + "fail: nil deployment": { + endpoint: DeploymentEndpoint{obj: nil, r: r}, + wantErr: true, + }, + "fail: project ref missing": { + endpoint: DeploymentEndpoint{ + obj: &akov2.AtlasDeployment{Spec: akov2.AtlasDeploymentSpec{}}, + r: r, + }, + wantErr: true, + }, + "fail: k8s project ref but project not found": { + endpoint: DeploymentEndpoint{ + obj: &akov2.AtlasDeployment{ + ObjectMeta: metav1.ObjectMeta{Namespace: "test-ns"}, + Spec: akov2.AtlasDeploymentSpec{ + ProjectDualReference: akov2.ProjectDualReference{ + ProjectRef: &common.ResourceRefNamespaced{ + Name: "missing-proj", + Namespace: "test-ns", + }, + }, + }, + }, + r: r, + }, + wantErr: true, + }, + "success: external project ID": { + endpoint: DeploymentEndpoint{obj: dep_external, r: r}, + want: "test-project-id", + }, + "success: k8s project ref": { + endpoint: DeploymentEndpoint{obj: dep, r: r}, + want: "test-project-id", + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + got, err := tc.endpoint.GetProjectID(context.Background()) + if tc.wantErr { + assert.Error(t, err) + return + } + assert.NoError(t, err) + assert.Equal(t, tc.want, got) + }) + } +} + +func TestDeploymentEndpoint_GetProjectName(t *testing.T) { + r, dep, dep_external := setupDeploymentTest(t) + + tests := map[string]struct { + endpoint DeploymentEndpoint + want string + wantErr bool + }{ + "fail: nil deployment": { + endpoint: DeploymentEndpoint{obj: nil, r: r}, + wantErr: true, + }, + "fail: neither k8s nor SDK available": { + endpoint: DeploymentEndpoint{ + obj: &akov2.AtlasDeployment{Spec: akov2.AtlasDeploymentSpec{}}, + r: r, + }, + wantErr: true, + }, + "success: k8s project ref returns normalized name": { + endpoint: DeploymentEndpoint{obj: dep, r: r}, + want: "my-project-name", + }, + "success: SDK fallback when project.Spec.Name empty": { + endpoint: DeploymentEndpoint{obj: dep_external, r: r}, + want: "my-project-name", + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + got, err := tc.endpoint.GetProjectName(context.Background()) + if tc.wantErr { + assert.Error(t, err) + return + } + assert.NoError(t, err) + assert.Equal(t, tc.want, got) + }) + } +} + +func TestDeploymentEndpoint_ListObj(t *testing.T) { + e := DeploymentEndpoint{} + l := e.ListObj() + _, ok := l.(*akov2.AtlasDeploymentList) + assert.True(t, ok) +} + +func TestDeploymentEndpoint_SelectorByProject(t *testing.T) { + e := DeploymentEndpoint{} + s := e.SelectorByProject("p-1") + assert.True(t, s.Matches(fields.Set{indexer.AtlasDeploymentByProject: "p-1"})) + assert.False(t, s.Matches(fields.Set{indexer.AtlasDeploymentByProject: "other"})) +} + +func TestDeploymentEndpoint_SelectorByProjectAndName(t *testing.T) { + e := DeploymentEndpoint{} + ids := &ConnSecretIdentifiers{ProjectID: "pX", ClusterName: "cY"} + s := e.SelectorByProjectAndName(ids) + assert.True(t, s.Matches(fields.Set{indexer.AtlasDeploymentBySpecNameAndProjectID: "pX-cY"})) + assert.False(t, s.Matches(fields.Set{indexer.AtlasDeploymentBySpecNameAndProjectID: "pX-cZ"})) +} + +func TestDeploymentEndpoint_ExtractList(t *testing.T) { + r, _, _ := setupDeploymentTest(t) + + list := &akov2.AtlasDeploymentList{ + Items: []akov2.AtlasDeployment{ + {Spec: akov2.AtlasDeploymentSpec{DeploymentSpec: &akov2.AdvancedDeploymentSpec{Name: "a"}}}, + {Spec: akov2.AtlasDeploymentSpec{DeploymentSpec: &akov2.AdvancedDeploymentSpec{Name: "b"}}}, + }, + } + + e := DeploymentEndpoint{r: r} + out, err := e.ExtractList(list) + assert.NoError(t, err) + if assert.Len(t, out, 2) { + assert.Equal(t, "a", out[0].GetName()) + assert.Equal(t, "b", out[1].GetName()) + } + + _, err = e.ExtractList(&akov2.AtlasProjectList{}) + assert.Error(t, err) +} + +func TestDeploymentEndpoint_BuildConnData(t *testing.T) { + r, dep, _ := setupDeploymentTest(t) + + user := &akov2.AtlasDatabaseUser{ + ObjectMeta: metav1.ObjectMeta{Name: "test-user", Namespace: "test-ns"}, + Spec: akov2.AtlasDatabaseUserSpec{ + Username: "theuser", + PasswordSecret: &common.ResourceRef{ + Name: "user-pass", + }, + }, + } + + userNoPass := &akov2.AtlasDatabaseUser{ + ObjectMeta: metav1.ObjectMeta{Name: "test-user-nopass", Namespace: "test-ns"}, + Spec: akov2.AtlasDatabaseUserSpec{ + Username: "theuser", + PasswordSecret: &common.ResourceRef{ + Name: "missing-proj", + }, + }, + } + + dep.Status.ConnectionStrings = &status.ConnectionStrings{ + Standard: "mongodb://std:27017", + StandardSrv: "mongodb+srv://std", + Private: "mongodb://priv:27017", + PrivateSrv: "mongodb+srv://priv", + PrivateEndpoint: []status.PrivateEndpoint{ + { + ConnectionString: "mongodb://pe1:27017", + SRVConnectionString: "mongodb+srv://pe1", + SRVShardOptimizedConnectionString: "mongodb+srv://pe1-shard", + }, + { + ConnectionString: "mongodb://pe2:27017", + SRVConnectionString: "mongodb+srv://pe2", + SRVShardOptimizedConnectionString: "mongodb+srv://pe2-shard", + }, + }, + } + + tests := map[string]struct { + endpoint *akov2.AtlasDeployment + user *akov2.AtlasDatabaseUser + want ConnSecretData + wantErr bool + }{ + "fail: nil endpoint and user": { + endpoint: nil, + user: nil, + wantErr: true, + }, + "fail: missing password": { + endpoint: dep, + user: userNoPass, + wantErr: true, + }, + "success: builds from deployment connection strings": { + endpoint: dep, + user: user, + want: ConnSecretData{ + DBUserName: "theuser", + Password: "secret", + ConnURL: "mongodb://std:27017", + SrvConnURL: "mongodb+srv://std", + PrivateConnURLs: []PrivateLinkConnURLs{ + {PvtConnURL: "mongodb://priv:27017", PvtSrvConnURL: "mongodb+srv://priv"}, + {PvtConnURL: "mongodb://pe1:27017", PvtSrvConnURL: "mongodb+srv://pe1", PvtShardConnURL: "mongodb+srv://pe1-shard"}, + {PvtConnURL: "mongodb://pe2:27017", PvtSrvConnURL: "mongodb+srv://pe2", PvtShardConnURL: "mongodb+srv://pe2-shard"}, + }, + }, + wantErr: false, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + e := DeploymentEndpoint{obj: tc.endpoint, r: r} + got, err := e.BuildConnData(context.Background(), tc.user) + if tc.wantErr { + assert.Error(t, err) + return + } + assert.NoError(t, err) + assert.Equal(t, tc.want.DBUserName, got.DBUserName) + assert.Equal(t, tc.want.Password, got.Password) + assert.Equal(t, tc.want.ConnURL, got.ConnURL) + assert.Equal(t, tc.want.SrvConnURL, got.SrvConnURL) + assert.Equal(t, tc.want.PrivateConnURLs, got.PrivateConnURLs) + }) + } +} diff --git a/internal/controller/connsecrets-generic/endpoint_federation.go b/internal/controller/connsecrets-generic/endpoint_federation.go new file mode 100644 index 0000000000..3bd40d3e94 --- /dev/null +++ b/internal/controller/connsecrets-generic/endpoint_federation.go @@ -0,0 +1,165 @@ +// Copyright 2025 MongoDB Inc +// +// 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 connsecretsgeneric + +import ( + "context" + "fmt" + "net/url" + "strings" + + "k8s.io/apimachinery/pkg/fields" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/mongodb/mongodb-atlas-kubernetes/v2/api" + akov2 "github.com/mongodb/mongodb-atlas-kubernetes/v2/api/v1" + "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/controller/reconciler" + "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/indexer" + "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/kube" + "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/translation/datafederation" +) + +type FederationEndpoint struct { + obj *akov2.AtlasDataFederation + r *ConnSecretReconciler +} + +// GetName resolves the endpoints name from the spec +func (e FederationEndpoint) GetName() string { + if e.obj == nil { + return "" + } + return e.obj.Spec.Name +} + +// IsReady returns true if the endpoint is ready +func (e FederationEndpoint) IsReady() bool { + return e.obj != nil && api.HasReadyCondition(e.obj.Status.Conditions) +} + +// GetScopeType returns the scope type of the endpoint to match with the ones from AtlasDatabaseUser +func (e FederationEndpoint) GetScopeType() akov2.ScopeType { + return akov2.DataLakeScopeType +} + +// GetProjectID resolves parent project's id (only ProjectRef) +func (e FederationEndpoint) GetProjectID(ctx context.Context) (string, error) { + if e.obj == nil { + return "", fmt.Errorf("nil federation") + } + if e.obj.Spec.Project.Name != "" { + proj := &akov2.AtlasProject{} + if err := e.r.Client.Get(ctx, e.obj.AtlasProjectObjectKey(), proj); err != nil { + return "", err + } + return proj.ID(), nil + } + + return "", ErrUnresolvedProjectID +} + +// GetProjectName returns the parent project's name (only by getting K8s AtlasProject) +func (e FederationEndpoint) GetProjectName(ctx context.Context) (string, error) { + if e.obj == nil { + return "", fmt.Errorf("nil federation") + } + if e.obj.Spec.Project.Name != "" { + proj := &akov2.AtlasProject{} + if err := e.r.Client.Get(ctx, e.obj.AtlasProjectObjectKey(), proj); err != nil { + return "", err + } + if proj.Spec.Name != "" { + return kube.NormalizeIdentifier(proj.Spec.Name), nil + } + } + + return "", ErrUnresolvedProjectName +} + +// Defines the list type +func (FederationEndpoint) ListObj() client.ObjectList { return &akov2.AtlasDataFederationList{} } + +// Defines the selector to use for indexer when trying to retrieve all endpoints by project +func (FederationEndpoint) SelectorByProject(projectRef string) fields.Selector { + return fields.OneTermEqualSelector(indexer.AtlasDataFederationByProject, projectRef) +} + +// Defines the selector to use for indexer when trying to retrieve all endpoints by project and spec name +func (FederationEndpoint) SelectorByProjectAndName(ids *ConnSecretIdentifiers) fields.Selector { + return fields.OneTermEqualSelector(indexer.AtlasDataFederationBySpecNameAndProjectID, ids.ProjectID+"-"+ids.ClusterName) +} + +// ExtractList creates a list of Endpoint types to preserve the abstraction +func (e FederationEndpoint) ExtractList(ol client.ObjectList) ([]Endpoint, error) { + l, ok := ol.(*akov2.AtlasDataFederationList) + if !ok { + return nil, fmt.Errorf("unexpected list type %T", ol) + } + out := make([]Endpoint, 0, len(l.Items)) + for i := range l.Items { + out = append(out, FederationEndpoint{obj: &l.Items[i], r: e.r}) + } + return out, nil +} + +// BuildConnData defines the specific function/way for building the ConnSecretData given this type of endpoint +// AtlasDataFederation uses SDK calls for getting the hostnames +func (e FederationEndpoint) BuildConnData(ctx context.Context, user *akov2.AtlasDatabaseUser) (ConnSecretData, error) { + if user == nil || e.obj == nil { + return ConnSecretData{}, ErrMissingPairing + } + password, err := user.ReadPassword(ctx, e.r.Client) + if err != nil { + return ConnSecretData{}, fmt.Errorf("failed to read password for user %q: %w", user.Spec.Username, err) + } + + project := &akov2.AtlasProject{} + if err := e.r.Client.Get(ctx, e.obj.AtlasProjectObjectKey(), project); err != nil { + return ConnSecretData{}, err + } + + connectionConfig, err := reconciler.GetConnectionConfig(ctx, e.r.Client, project.ConnectionSecretObjectKey(), &e.r.GlobalSecretRef) + if err != nil { + return ConnSecretData{}, err + } + clientSet, err := e.r.AtlasProvider.SdkClientSet(ctx, connectionConfig.Credentials, e.r.Log) + if err != nil { + return ConnSecretData{}, err + } + + dataFederationService := datafederation.NewAtlasDataFederation(clientSet.SdkClient20250312002.DataFederationApi) + df, err := dataFederationService.Get(ctx, project.ID(), e.obj.Spec.Name) + if err != nil { + return ConnSecretData{}, fmt.Errorf("atlas DF get: %w", err) + } + + if len(df.Hostnames) == 0 { + return ConnSecretData{}, fmt.Errorf("no DF hostnames") + } + + hostlist := strings.Join(df.Hostnames, ",") + u := &url.URL{ + Scheme: "mongodb", + Host: hostlist, + Path: "/", + RawQuery: "ssl=true", + } + + return ConnSecretData{ + DBUserName: user.Spec.Username, + Password: password, + ConnURL: u.String(), + }, nil +} diff --git a/internal/controller/connsecrets-generic/endpoint_federation_test.go b/internal/controller/connsecrets-generic/endpoint_federation_test.go new file mode 100644 index 0000000000..8e3fb8473c --- /dev/null +++ b/internal/controller/connsecrets-generic/endpoint_federation_test.go @@ -0,0 +1,319 @@ +// Copyright 2025 MongoDB Inc +// +// 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 connsecretsgeneric + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + admin "go.mongodb.org/atlas-sdk/v20250312002/admin" + "go.mongodb.org/atlas-sdk/v20250312002/mockadmin" + "go.uber.org/zap" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/fields" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/mongodb/mongodb-atlas-kubernetes/v2/api" + akov2 "github.com/mongodb/mongodb-atlas-kubernetes/v2/api/v1" + "github.com/mongodb/mongodb-atlas-kubernetes/v2/api/v1/common" + "github.com/mongodb/mongodb-atlas-kubernetes/v2/api/v1/status" + "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/controller/atlas" + "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/indexer" + atlasmock "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/mocks/atlas" +) + +func setupFederationTest(t *testing.T) (*ConnSecretReconciler, *akov2.AtlasDataFederation) { + t.Helper() + + // Returns a valid Federation + df := &akov2.AtlasDataFederation{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-df", + Namespace: "test-ns", + }, + Spec: akov2.DataFederationSpec{ + Name: "my-df-name", + Project: common.ResourceRefNamespaced{ + Name: "test-project", + Namespace: "test-ns", + }, + }, + } + + r := createDummyEnv(t, nil) + return r, df +} + +func runFederationProjectTest[T any](t *testing.T, method func(FederationEndpoint) (T, error), wantField string) { + r, df := setupFederationTest(t) + + tests := map[string]struct { + endpoint FederationEndpoint + want string + wantErr bool + }{ + "fail: nil federation": { + endpoint: FederationEndpoint{ + obj: nil, + r: r, + }, + wantErr: true, + }, + "fail: missing project ref": { + endpoint: FederationEndpoint{ + obj: &akov2.AtlasDataFederation{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-df", + Namespace: "test-ns", + }, + Spec: akov2.DataFederationSpec{ + Name: "mising-proj", + }, + }, + r: r, + }, + wantErr: true, + }, + "success": { + endpoint: FederationEndpoint{ + obj: df, + r: r, + }, + want: wantField}, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + got, err := method(tc.endpoint) + if tc.wantErr { + assert.Error(t, err) + return + } + assert.NoError(t, err) + assert.Equal(t, tc.want, got) + }) + } +} + +func TestFederationEndpoint_GetName(t *testing.T) { + eNil := FederationEndpoint{obj: nil} + assert.Equal(t, "", eNil.GetName()) + + e := FederationEndpoint{ + obj: &akov2.AtlasDataFederation{ + Spec: akov2.DataFederationSpec{Name: "my-df-name"}, + }, + } + assert.Equal(t, "my-df-name", e.GetName()) +} + +func TestFederationEndpoint_IsReady(t *testing.T) { + eNil := FederationEndpoint{obj: nil} + assert.False(t, eNil.IsReady()) + + eNotReady := FederationEndpoint{ + obj: &akov2.AtlasDataFederation{ + Status: status.DataFederationStatus{ + Common: api.Common{ + Conditions: []api.Condition{{Type: api.ReadyType, Status: "False"}}, + }, + }, + }, + } + assert.False(t, eNotReady.IsReady()) + + eReady := FederationEndpoint{ + obj: &akov2.AtlasDataFederation{ + Status: status.DataFederationStatus{ + Common: api.Common{ + Conditions: []api.Condition{{Type: api.ReadyType, Status: "True"}}, + }, + }, + }, + } + assert.True(t, eReady.IsReady()) +} + +func TestFederationEndpoint_GetScopeType(t *testing.T) { + e := FederationEndpoint{} + assert.Equal(t, akov2.DataLakeScopeType, e.GetScopeType()) +} + +func TestFederationEndpoint_GetProjectID(t *testing.T) { + runFederationProjectTest(t, + func(fe FederationEndpoint) (string, error) { + return fe.GetProjectID(context.Background()) + }, + "test-project-id", + ) +} + +func TestFederationEndpoint_GetProjectName(t *testing.T) { + runFederationProjectTest(t, + func(fe FederationEndpoint) (string, error) { + return fe.GetProjectName(context.Background()) + }, + "my-project-name", + ) +} + +func TestFederationEndpoint_ListObj(t *testing.T) { + e := FederationEndpoint{} + list := e.ListObj() + _, ok := list.(*akov2.AtlasDataFederationList) + assert.True(t, ok) +} + +func TestFederationEndpoint_SelectorByProject(t *testing.T) { + e := FederationEndpoint{} + s := e.SelectorByProject("p123") + assert.True(t, s.Matches(fields.Set{indexer.AtlasDataFederationByProject: "p123"})) + assert.False(t, s.Matches(fields.Set{indexer.AtlasDataFederationByProject: "other"})) +} + +func TestFederationEndpoint_SelectorByProjectAndName(t *testing.T) { + e := FederationEndpoint{} + ids := &ConnSecretIdentifiers{ProjectID: "pX", ClusterName: "dfY"} + s := e.SelectorByProjectAndName(ids) + assert.True(t, s.Matches(fields.Set{indexer.AtlasDataFederationBySpecNameAndProjectID: "pX-dfY"})) + assert.False(t, s.Matches(fields.Set{indexer.AtlasDataFederationBySpecNameAndProjectID: "pX-dfZ"})) +} + +func TestFederationEndpoint_ExtractList(t *testing.T) { + r := createDummyEnv(t, nil) + + dfList := &akov2.AtlasDataFederationList{ + Items: []akov2.AtlasDataFederation{ + {Spec: akov2.DataFederationSpec{Name: "a"}}, + {Spec: akov2.DataFederationSpec{Name: "b"}}, + }, + } + + e := FederationEndpoint{r: r} + out, err := e.ExtractList(dfList) + assert.NoError(t, err) + if assert.Len(t, out, 2) { + assert.Equal(t, "a", out[0].GetName()) + assert.Equal(t, "b", out[1].GetName()) + } + + _, err = e.ExtractList(&akov2.AtlasProjectList{}) + assert.Error(t, err) +} + +func TestFederationEndpoint_BuildConnData(t *testing.T) { + user := &akov2.AtlasDatabaseUser{ + ObjectMeta: metav1.ObjectMeta{Name: "test-user", Namespace: "test-ns"}, + Spec: akov2.AtlasDatabaseUserSpec{ + PasswordSecret: &common.ResourceRef{ + Name: "user-pass", + }, + Username: "theuser", + }, + } + + userNoPass := &akov2.AtlasDatabaseUser{ + ObjectMeta: metav1.ObjectMeta{Name: "test-user-nopass", Namespace: "test-ns"}, + Spec: akov2.AtlasDatabaseUserSpec{ + PasswordSecret: &common.ResourceRef{ + Name: "missing-secret", + }, + Username: "theuser", + }, + } + + dfNoProject := &akov2.AtlasDataFederation{ + ObjectMeta: metav1.ObjectMeta{Name: "df", Namespace: "test-ns"}, + Spec: akov2.DataFederationSpec{Name: "df"}, + } + + r, df := setupFederationTest(t) + + tests := map[string]struct { + objs []client.Object + override func(*ConnSecretReconciler) + endpoint *akov2.AtlasDataFederation + user *akov2.AtlasDatabaseUser + wantURL string + wantErr bool + }{ + "fail: nil endpoint and nil user": { + endpoint: nil, + user: nil, + wantErr: true, + }, + "fail: password is missing": { + endpoint: dfNoProject, + user: userNoPass, + wantErr: true, + }, + "fail: endpoint exists but project missing": { + endpoint: dfNoProject, + user: user, + wantErr: true, + }, + "success: builds URL from DF hostnames": { + override: func(r *ConnSecretReconciler) { + dfAPI := mockadmin.NewDataFederationApi(t) + + dfAPI.EXPECT(). + GetFederatedDatabase(mock.Anything, "test-project-id", "my-df-name"). + Return(admin.GetFederatedDatabaseApiRequest{ApiService: dfAPI}) + + dfAPI.EXPECT(). + GetFederatedDatabaseExecute(mock.AnythingOfType("admin.GetFederatedDatabaseApiRequest")). + Return(&admin.DataLakeTenant{ + Hostnames: &[]string{"h1.example.net", "h2.example.net"}, + }, nil, nil) + + r.AtlasProvider = &atlasmock.TestProvider{ + SdkClientSetFunc: func(ctx context.Context, creds *atlas.Credentials, log *zap.SugaredLogger) (*atlas.ClientSet, error) { + return &atlas.ClientSet{ + SdkClient20250312002: &admin.APIClient{ + DataFederationApi: dfAPI, + }, + }, nil + }, + IsSupportedFunc: func() bool { return true }, + IsCloudGovFunc: func() bool { return false }, + } + }, + endpoint: df, + user: user, + wantURL: "mongodb://h1.example.net,h2.example.net/?ssl=true", + wantErr: false, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + if tc.override != nil { + tc.override(r) + } + e := FederationEndpoint{obj: tc.endpoint, r: r} + got, err := e.BuildConnData(context.Background(), tc.user) + if tc.wantErr { + assert.Error(t, err) + return + } + assert.NoError(t, err) + assert.Equal(t, "theuser", got.DBUserName) + assert.Equal(t, "secret", got.Password) + assert.Equal(t, tc.wantURL, got.ConnURL) + }) + } +} diff --git a/internal/controller/connsecrets-generic/endpoint_user.go b/internal/controller/connsecrets-generic/endpoint_user.go new file mode 100644 index 0000000000..94d06af242 --- /dev/null +++ b/internal/controller/connsecrets-generic/endpoint_user.go @@ -0,0 +1,58 @@ +// Copyright 2025 MongoDB Inc +// +// 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 connsecretsgeneric + +import ( + "context" + "fmt" + + akov2 "github.com/mongodb/mongodb-atlas-kubernetes/v2/api/v1" + "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/kube" +) + +// GetUserProjectName retrives the project name from the AtlasDatabaseUser (either by getting K8s AtlasProject or SDK calls) +func (r *ConnSecretReconciler) GetUserProjectName(ctx context.Context, user *akov2.AtlasDatabaseUser) (string, error) { + if user == nil { + return "", fmt.Errorf("nil user") + } + + if user.Spec.ProjectRef != nil && user.Spec.ProjectRef.Name != "" { + proj := &akov2.AtlasProject{} + if err := r.Client.Get(ctx, user.AtlasProjectObjectKey(), proj); err != nil { + return "", err + } + if proj.Spec.Name != "" { + return kube.NormalizeIdentifier(proj.Spec.Name), nil + } + } + + cfg, err := r.ResolveConnectionConfig(ctx, user) + if err != nil { + return "", err + } + sdk, err := r.AtlasProvider.SdkClientSet(ctx, cfg.Credentials, r.Log) + if err != nil { + return "", err + } + ap, err := r.ResolveProject(ctx, sdk.SdkClient20250312002, user) + if err != nil { + return "", err + } + if ap.Name == "" { + return "", fmt.Errorf("project name not available") + } + + return kube.NormalizeIdentifier(ap.Name), nil +} diff --git a/internal/controller/connsecrets-generic/endpoint_user_test.go b/internal/controller/connsecrets-generic/endpoint_user_test.go new file mode 100644 index 0000000000..ecc1daaee1 --- /dev/null +++ b/internal/controller/connsecrets-generic/endpoint_user_test.go @@ -0,0 +1,249 @@ +// Copyright 2025 MongoDB Inc +// +// 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 connsecretsgeneric + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + admin "go.mongodb.org/atlas-sdk/v20250312002/admin" + "go.mongodb.org/atlas-sdk/v20250312002/mockadmin" + "go.uber.org/zap" + "go.uber.org/zap/zaptest" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/tools/record" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + "github.com/mongodb/mongodb-atlas-kubernetes/v2/api" + akov2 "github.com/mongodb/mongodb-atlas-kubernetes/v2/api/v1" + "github.com/mongodb/mongodb-atlas-kubernetes/v2/api/v1/common" + "github.com/mongodb/mongodb-atlas-kubernetes/v2/api/v1/status" + "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/controller/atlas" + "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/controller/reconciler" + "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/indexer" + atlasmock "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/mocks/atlas" + "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/pointer" +) + +// createDummyEnv creates a dummy environment with some objects already setup +func createDummyEnv(t *testing.T, objs []client.Object) *ConnSecretReconciler { + scheme := runtime.NewScheme() + assert.NoError(t, akov2.AddToScheme(scheme)) + assert.NoError(t, corev1.AddToScheme(scheme)) + + // Contains the project + project := &akov2.AtlasProject{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-project", + Namespace: "test-ns", + }, + Spec: akov2.AtlasProjectSpec{ + Name: "My Project Name", + ConnectionSecret: &common.ResourceRefNamespaced{ + Name: "sdk-creds", + }, + }, + Status: status.AtlasProjectStatus{ + ID: "test-project-id", + }, + } + + // SDK credentials + sdkSecret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "sdk-creds", + Namespace: "test-ns", + }, + Data: map[string][]byte{ + "orgId": []byte("test-pass"), + "publicApiKey": []byte("test-pass"), + "privateApiKey": []byte("test-pass"), + }, + } + + // Connection Secret + connSecret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-project-name-cluster1-admin", + Namespace: "test-ns", + Labels: map[string]string{ + ProjectLabelKey: "test-project-id", + ClusterLabelKey: "cluster1", + TypeLabelKey: "connection", + }, + }, + } + + // User password + userSecret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "user-pass", + Namespace: "test-ns", + }, + Data: map[string][]byte{"password": []byte("secret")}, + } + + cl := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(project, sdkSecret, connSecret, userSecret). + WithObjects(objs...). + WithIndex(&akov2.AtlasDeployment{}, indexer.AtlasDeploymentBySpecNameAndProjectID, func(obj client.Object) []string { + d := obj.(*akov2.AtlasDeployment) + return []string{"test-project-id" + "-" + d.Spec.DeploymentSpec.Name} + }). + WithIndex(&akov2.AtlasDataFederation{}, indexer.AtlasDataFederationBySpecNameAndProjectID, func(obj client.Object) []string { + df := obj.(*akov2.AtlasDataFederation) + return []string{"test-project-id" + "-" + df.Spec.Name} + }). + WithIndex(&akov2.AtlasDatabaseUser{}, indexer.AtlasDatabaseUserBySpecUsernameAndProjectID, func(obj client.Object) []string { + u := obj.(*akov2.AtlasDatabaseUser) + return []string{"test-project-id" + "-" + u.Spec.Username} + }). + Build() + + atlasProvider := &atlasmock.TestProvider{ + SdkClientSetFunc: func(ctx context.Context, creds *atlas.Credentials, log *zap.SugaredLogger) (*atlas.ClientSet, error) { + projectAPI := mockadmin.NewProjectsApi(t) + + projectAPI.EXPECT(). + GetProject(mock.Anything, "test-project-id"). + Return(admin.GetProjectApiRequest{ApiService: projectAPI}) + + projectAPI.EXPECT(). + GetProjectExecute(mock.AnythingOfType("admin.GetProjectApiRequest")). + Return(&admin.Group{ + Id: pointer.MakePtr("test-project-id"), + Name: "My Project Name", + }, nil, nil) + + return &atlas.ClientSet{ + SdkClient20250312002: &admin.APIClient{ + ProjectsApi: projectAPI, + }, + }, nil + }, + IsSupportedFunc: func() bool { return true }, + IsCloudGovFunc: func() bool { return false }, + } + + r := &ConnSecretReconciler{ + AtlasReconciler: reconciler.AtlasReconciler{ + Client: cl, + AtlasProvider: atlasProvider, + Log: zaptest.NewLogger(t).Sugar(), + }, + Scheme: scheme, + EventRecorder: record.NewFakeRecorder(10), + } + + return r +} + +func TestGetUserProjectName(t *testing.T) { + r := createDummyEnv(t, []client.Object{}) + + type testCase struct { + user *akov2.AtlasDatabaseUser + wantName string + wantErr bool + } + + tests := map[string]testCase{ + "fail: nil user returns error": { + user: nil, + wantErr: true, + }, + "fail: k8s project ref not found returns error": { + user: &akov2.AtlasDatabaseUser{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-user", + Namespace: "test-ns", + }, + Spec: akov2.AtlasDatabaseUserSpec{ + ProjectDualReference: akov2.ProjectDualReference{ + ProjectRef: &common.ResourceRefNamespaced{ + Name: "missing-proj", + Namespace: "test-ns", + }, + }, + }, + }, + wantErr: true, + }, + "fail: no project ref and nil receiver falls back to not available error": { + user: &akov2.AtlasDatabaseUser{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-user", + Namespace: "test-ns", + }, + Spec: akov2.AtlasDatabaseUserSpec{}, + }, + wantErr: true, + }, + "success: k8s project ref success returns normalized name by reference": { + user: &akov2.AtlasDatabaseUser{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-user", + Namespace: "test-ns", + }, + Spec: akov2.AtlasDatabaseUserSpec{ + ProjectDualReference: akov2.ProjectDualReference{ + ProjectRef: &common.ResourceRefNamespaced{ + Name: "test-project", + Namespace: "test-ns", + }, + }, + }, + }, + wantName: "my-project-name", + wantErr: false, + }, + "success: k8s project ref success returns normalized name by sdk": { + user: &akov2.AtlasDatabaseUser{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-user", + Namespace: "test-ns", + }, + Spec: akov2.AtlasDatabaseUserSpec{ + ProjectDualReference: akov2.ProjectDualReference{ + ExternalProjectRef: &akov2.ExternalProjectReference{ID: "test-project-id"}, + ConnectionSecret: &api.LocalObjectReference{Name: "sdk-creds"}, + }, + }, + }, + wantName: "my-project-name", + wantErr: false, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + receiver := r + + got, err := receiver.GetUserProjectName(context.Background(), tc.user) + if tc.wantErr { + assert.Error(t, err) + return + } + assert.NoError(t, err) + assert.Equal(t, tc.wantName, got) + }) + } +} diff --git a/internal/controller/registry.go b/internal/controller/registry.go index a68bec2b5a..20869e12ca 100644 --- a/internal/controller/registry.go +++ b/internal/controller/registry.go @@ -42,6 +42,7 @@ import ( "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/controller/atlassearchindexconfig" "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/controller/atlasstream" integrations "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/controller/atlasthirdpartyintegrations" + connsecretsgeneric "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/controller/connsecrets-generic" "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/controller/watch" "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/dryrun" "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/featureflags" @@ -128,6 +129,7 @@ func (r *Registry) registerControllers(c cluster.Cluster, ap atlas.Provider) { integrationsReconciler := integrations.NewAtlasThirdPartyIntegrationsReconciler(c, ap, r.deletionProtection, r.logger, r.globalSecretRef, r.reapplySupport) reconcilers = append(reconcilers, newCtrlStateReconciler(integrationsReconciler)) + reconcilers = append(reconcilers, connsecretsgeneric.NewConnectionSecretReconciler(c, r.defaultPredicates(), ap, r.logger, r.globalSecretRef)) if version.IsExperimental() { // Add experimental controllers here diff --git a/internal/controller/watch/predicates.go b/internal/controller/watch/predicates.go index e1fa0914f1..3f6c579155 100644 --- a/internal/controller/watch/predicates.go +++ b/internal/controller/watch/predicates.go @@ -84,3 +84,42 @@ func DefaultPredicates[T metav1.Object]() predicate.TypedPredicate[T] { IgnoreDeletedPredicate[T](), ) } + +type ReadyFunc[T any] func(obj T) bool + +// ReadyTransitionPredicate filters out only those objects where the previous +// oldObject was not ready but the new one it +func ReadyTransitionPredicate[T any](ready ReadyFunc[T]) predicate.Predicate { + return predicate.Funcs{ + CreateFunc: func(e event.CreateEvent) bool { return false }, + GenericFunc: func(e event.GenericEvent) bool { return false }, + DeleteFunc: func(e event.DeleteEvent) bool { return true }, + UpdateFunc: func(e event.UpdateEvent) bool { + newObj, ok := e.ObjectNew.(T) + if !ok { + return false + } + oldObj, ok := e.ObjectOld.(T) + if !ok { + return false + } + return !ready(oldObj) && ready(newObj) + }, + } +} + +// SecretLabelPredicate filters out secrets based on the required labels +func SecretLabelPredicate(requiredKeys ...string) predicate.Predicate { + return predicate.NewPredicateFuncs(func(obj client.Object) bool { + if obj == nil { + return false + } + labels := obj.GetLabels() + for _, k := range requiredKeys { + if _, ok := labels[k]; !ok { + return false + } + } + return true + }) +} diff --git a/internal/controller/workflow/reason.go b/internal/controller/workflow/reason.go index c56809ea06..5c5061bff0 100644 --- a/internal/controller/workflow/reason.go +++ b/internal/controller/workflow/reason.go @@ -193,3 +193,26 @@ const ( NetworkPeeringConnectionPending ConditionReason = "NetworkPeeringConnectionPending" NetworkPeeringConnectionClosing ConditionReason = "NetworkPeeringConnectionClosing" ) + +// ConnectionSecret reasons +const ( + ConnSecretInvalidName ConditionReason = "ConnSecretInvalidName" + ConnSecretAmbiguousResources ConditionReason = "ConnSecretAmbiguousResources" + ConnSecretInvalidResources ConditionReason = "ConnSecretInvalidResources" + ConnSecretOwnerMissing ConditionReason = "ConnSecretOwnerMissing" + ConnSecretUnresolvedProjectName ConditionReason = "ConnSecretUnresolvedProjectName" + ConnSecretFailedToResolveProjectName ConditionReason = "ConnSecretFailedToResolveProjectName" + ConnSecretFailedToBuildData ConditionReason = "ConnSecretFailedToBuildData" + ConnSecretFailedToFillData ConditionReason = "ConnSecretFailedToFillData" + ConnSecretFailedToSetOwnerReferences ConditionReason = "ConnSecretFailedToSetOwnerReferences" + ConnSecretFailedToGetSecret ConditionReason = "ConnSecretFailedToGetSecret" + ConnSecretFailedToCreateSecret ConditionReason = "ConnSecretFailedToCreateSecret" + ConnSecretFailedToUpdateSecret ConditionReason = "ConnSecretFailedToUpdateSecret" + ConnSecretFailedDeletion ConditionReason = "ConnSecretFailedDeletion" + ConnSecretNotReady ConditionReason = "ConnSecretNotReady" + ConnSecretUpsert ConditionReason = "ConnSecretUpsert" + ConnSecretDeleted ConditionReason = "ConnSecretDeleted" + ConnSecretUserExpired ConditionReason = "ConnSecretUserExpired" + ConnSecretInvalidScopes ConditionReason = "ConnSecretInvalidScopes" + ConnSecretCheckExpirationFailed ConditionReason = "ConnSecretCheckExpirationFailed" +) diff --git a/internal/indexer/atlasdatafederationbyspecname.go b/internal/indexer/atlasdatafederationbyspecname.go new file mode 100644 index 0000000000..4a374cad7a --- /dev/null +++ b/internal/indexer/atlasdatafederationbyspecname.go @@ -0,0 +1,93 @@ +// Copyright 2025 MongoDB Inc +// +// 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. + +//nolint:dupl +package indexer + +import ( + "context" + "fmt" + + "go.uber.org/zap" + "sigs.k8s.io/controller-runtime/pkg/client" + + akov2 "github.com/mongodb/mongodb-atlas-kubernetes/v2/api/v1" + "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/kube" +) + +// Index Format: +// - +// +// Where: +// - is resolved from either ExternalProjectRef.ID or the resolved AtlasProject.Status.ID +// - is produced via kube.NormalizeIdentifier(dataFederation.Spec.Name) +// +// Purpose: +// This index allows fast lookup of AtlasDataFederation resources by project ID and federation name, +// particularly useful for identifying which cluster a user has access to. + +const ( + AtlasDataFederationBySpecNameAndProjectID = "atlasdatafederation.projectID/spec.name" +) + +type AtlasDataFederationBySpecNameIndexer struct { + ctx context.Context + client client.Client + logger *zap.SugaredLogger +} + +func NewAtlasDataFederationBySpecNameIndexer(ctx context.Context, client client.Client, logger *zap.Logger) *AtlasDataFederationBySpecNameIndexer { + return &AtlasDataFederationBySpecNameIndexer{ + ctx: ctx, + client: client, + logger: logger.Named(AtlasDataFederationBySpecNameAndProjectID).Sugar(), + } +} + +func (*AtlasDataFederationBySpecNameIndexer) Object() client.Object { + return &akov2.AtlasDataFederation{} +} + +func (*AtlasDataFederationBySpecNameIndexer) Name() string { + return AtlasDataFederationBySpecNameAndProjectID +} + +func (a *AtlasDataFederationBySpecNameIndexer) Keys(object client.Object) []string { + df, ok := object.(*akov2.AtlasDataFederation) + if !ok { + a.logger.Errorf("expected *v1.AtlasDataFederation but got %T", object) + return nil + } + + name := df.Spec.Name + if name == "" { + return nil + } + name = kube.NormalizeIdentifier(name) + + if df.Spec.Project.Name != "" { + project := &akov2.AtlasProject{} + err := a.client.Get(a.ctx, *df.Spec.Project.GetObject(df.Namespace), project) + if err != nil { + a.logger.Errorf("unable to find project to index: %s", err) + return nil + } + + if project.ID() != "" { + return []string{fmt.Sprintf("%s-%s", project.ID(), name)} + } + } + + return nil +} diff --git a/internal/indexer/atlasdatafederationbyspecname_test.go b/internal/indexer/atlasdatafederationbyspecname_test.go new file mode 100644 index 0000000000..88f68a805f --- /dev/null +++ b/internal/indexer/atlasdatafederationbyspecname_test.go @@ -0,0 +1,163 @@ +// Copyright 2025 MongoDB Inc +// +// 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. + +//nolint:dupl +package indexer + +import ( + "context" + "sort" + "testing" + + "github.com/stretchr/testify/assert" + "go.uber.org/zap" + "go.uber.org/zap/zapcore" + "go.uber.org/zap/zaptest/observer" + 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" + + akov2 "github.com/mongodb/mongodb-atlas-kubernetes/v2/api/v1" + "github.com/mongodb/mongodb-atlas-kubernetes/v2/api/v1/common" + "github.com/mongodb/mongodb-atlas-kubernetes/v2/api/v1/status" + "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/kube" +) + +func TestAtlasDataFederationBySpecNameIndexer(t *testing.T) { + tests := map[string]struct { + object client.Object + expectedKeys []string + expectedLogs []observer.LoggedEntry + }{ + "should return nil on wrong type": { + object: &akov2.AtlasStreamInstance{}, + expectedLogs: []observer.LoggedEntry{ + { + Context: []zapcore.Field{}, + Entry: zapcore.Entry{ + LoggerName: AtlasDataFederationBySpecNameAndProjectID, + Level: zap.ErrorLevel, + Message: "expected *v1.AtlasDataFederation but got *v1.AtlasStreamInstance", + }, + }, + }, + }, + "should return nil when no name set": { + object: &akov2.AtlasDataFederation{ + Spec: akov2.DataFederationSpec{ + Name: "", + }, + }, + expectedLogs: []observer.LoggedEntry{}, + }, + "should return nil if name exists but no project refs": { + object: &akov2.AtlasDataFederation{ + Spec: akov2.DataFederationSpec{ + Name: "test-my-federation", + }, + }, + expectedLogs: []observer.LoggedEntry{}, + }, + "should return key from resolved ProjectRef": { + object: &akov2.AtlasDataFederation{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-datafederation", + Namespace: "test-ns", + }, + Spec: akov2.DataFederationSpec{ + Name: "test-my-federation", + Project: common.ResourceRefNamespaced{ + Name: "test-project", + Namespace: "test-ns", + }, + }, + }, + expectedKeys: []string{"test-project-id-" + kube.NormalizeIdentifier("test-my-federation")}, + expectedLogs: []observer.LoggedEntry{}, + }, + "should normalize federation name before indexing": { + object: &akov2.AtlasDataFederation{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-datafederation", + Namespace: "test-ns", + }, + Spec: akov2.DataFederationSpec{ + Name: "Test.Federation+123", + Project: common.ResourceRefNamespaced{ + Name: "test-project", + Namespace: "test-ns", + }, + }, + }, + expectedKeys: []string{"test-project-id-" + kube.NormalizeIdentifier("Test.Federation+123")}, + expectedLogs: []observer.LoggedEntry{}, + }, + "should log error if ProjectRef can't be resolved": { + object: &akov2.AtlasDataFederation{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-datafederation", + Namespace: "test-ns", + }, + Spec: akov2.DataFederationSpec{ + Name: "test-unknown-federation", + Project: common.ResourceRefNamespaced{ + Name: "nonexistent-project", + }, + }, + }, + expectedLogs: []observer.LoggedEntry{ + { + Context: []zapcore.Field{}, + Entry: zapcore.Entry{ + LoggerName: AtlasDataFederationBySpecNameAndProjectID, + Level: zap.ErrorLevel, + Message: "unable to find project to index: atlasprojects.atlas.mongodb.com \"nonexistent-project\" not found", + }, + }, + }, + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + project := &akov2.AtlasProject{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-project", + Namespace: "test-ns", + }, + Status: status.AtlasProjectStatus{ + ID: "test-project-id", + }, + } + + testScheme := runtime.NewScheme() + assert.NoError(t, akov2.AddToScheme(testScheme)) + k8sClient := fake.NewClientBuilder(). + WithScheme(testScheme). + WithObjects(project). + WithStatusSubresource(project). + Build() + + core, logs := observer.New(zap.DebugLevel) + + indexer := NewAtlasDataFederationBySpecNameIndexer(context.Background(), k8sClient, zap.New(core)) + keys := indexer.Keys(tt.object) + sort.Strings(keys) + + assert.Equal(t, tt.expectedKeys, keys) + assert.Equal(t, tt.expectedLogs, logs.AllUntimed()) + }) + } +} diff --git a/internal/indexer/indexer.go b/internal/indexer/indexer.go index a80200e54d..dbeca100e4 100644 --- a/internal/indexer/indexer.go +++ b/internal/indexer/indexer.go @@ -54,6 +54,7 @@ func RegisterAll(ctx context.Context, c cluster.Cluster, logger *zap.Logger) err NewAtlasDatabaseUserByProjectIndexer(ctx, c.GetClient(), logger), NewAtlasDatabaseUserBySpecUsernameIndexer(ctx, c.GetClient(), logger), NewAtlasDataFederationByProjectIndexer(logger), + NewAtlasDataFederationBySpecNameIndexer(ctx, c.GetClient(), logger), NewAtlasDeploymentByProjectIndexer(ctx, c.GetClient(), logger), NewAtlasDeploymentBySpecNameIndexer(ctx, c.GetClient(), logger), NewAtlasCustomRoleByCredentialIndexer(logger), diff --git a/internal/operator/builder_test.go b/internal/operator/builder_test.go index 3cbb4d2ba4..fed95d18e0 100644 --- a/internal/operator/builder_test.go +++ b/internal/operator/builder_test.go @@ -24,6 +24,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.uber.org/zap/zaptest" + corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/client-go/rest" "k8s.io/client-go/tools/record" @@ -161,6 +162,7 @@ func TestBuildManager(t *testing.T) { t.Run(name, func(t *testing.T) { akoScheme := runtime.NewScheme() require.NoError(t, akov2.AddToScheme(akoScheme)) + require.NoError(t, corev1.AddToScheme(akoScheme)) mgrMock := &managerMock{} builder := NewBuilder(mgrMock, akoScheme, 5*time.Minute) diff --git a/internal/timeutil/timeutil.go b/internal/timeutil/timeutil.go index c1dff522ba..e2cde0e652 100644 --- a/internal/timeutil/timeutil.go +++ b/internal/timeutil/timeutil.go @@ -61,3 +61,18 @@ func MustParseISO8601(dateTime string) time.Time { func FormatISO8601(dateTime time.Time) string { return dateTime.Format("2006-01-02T15:04:05.999Z") } + +// IsExpired parses the given ISO8601 date string and returns whether it is before now. +// Returns an error if the string cannot be parsed. +func IsExpired(deleteAfterDate string) (bool, error) { + if deleteAfterDate == "" { + return false, nil + } + + deleteAfter, err := ParseISO8601(deleteAfterDate) + if err != nil { + return false, err + } + + return deleteAfter.Before(time.Now()), nil +} diff --git a/test/int/databaseuser_unprotected_test.go b/test/int/databaseuser_unprotected_test.go index 552156037f..cc1d956ea4 100644 --- a/test/int/databaseuser_unprotected_test.go +++ b/test/int/databaseuser_unprotected_test.go @@ -826,7 +826,7 @@ func buildConnectionURL(connURL, userName, password string) string { return "" } - u, err := connectionsecret.AddCredentialsToConnectionURL(connURL, userName, password) + u, err := connectionsecret.CreateURL(connURL, userName, password) Expect(err).NotTo(HaveOccurred()) return u }