From 793f826b0830bb2d84e676a99b59c403fbe3ef53 Mon Sep 17 00:00:00 2001 From: andrpac Date: Tue, 5 Aug 2025 15:22:12 +0100 Subject: [PATCH 01/11] feat: scaffold the ConnectionSecret controller chore: create tests, cleanup old code, refactor controller --- Makefile | 2 +- .../atlasdatabaseuser_controller.go | 8 +- .../atlasdatabaseuser/databaseuser.go | 42 +- .../atlasdatabaseuser/databaseuser_test.go | 43 +- .../atlasdatafederation/connectionsecrets.go | 2 +- .../atlasdeployment/advanced_deployment.go | 95 +- .../advanced_deployment_test.go | 4 +- .../atlasdeployment_controller.go | 8 +- .../atlasdeployment/flex_deployment.go | 12 +- .../atlasdeployment/flex_deployment_test.go | 4 +- .../atlasdeployment/serverless_deployment.go | 12 +- .../serverless_deployment_test.go | 4 +- .../connectionsecret_controller.go | 300 ++++++ .../connectionsecret_controller_test.go | 956 ++++++++++++++++++ .../connectionsecret/connectionsecret_test.go | 735 ++++++++++++++ .../connectionsecret/connectionsecrets.go | 358 ++++--- .../connectionsecrets_test.go | 165 --- .../connectionsecret/ensuresecret.go | 149 --- .../connectionsecret/ensuresecret_test.go | 141 --- .../connectionsecret/listsecrets.go | 27 + .../connectionsecret/listsecrets_test.go | 15 + .../connectionsecret/requestname_extractor.go | 294 ++++++ .../requestname_extractor_test.go | 543 ++++++++++ .../connectionsecret/resource_manager.go | 71 ++ internal/controller/registry.go | 2 + internal/operator/builder_test.go | 2 + test/int/databaseuser_unprotected_test.go | 2 +- 27 files changed, 3186 insertions(+), 810 deletions(-) create mode 100644 internal/controller/connectionsecret/connectionsecret_controller.go create mode 100644 internal/controller/connectionsecret/connectionsecret_controller_test.go create mode 100644 internal/controller/connectionsecret/connectionsecret_test.go delete mode 100644 internal/controller/connectionsecret/connectionsecrets_test.go delete mode 100644 internal/controller/connectionsecret/ensuresecret.go delete mode 100644 internal/controller/connectionsecret/ensuresecret_test.go create mode 100644 internal/controller/connectionsecret/requestname_extractor.go create mode 100644 internal/controller/connectionsecret/requestname_extractor_test.go create mode 100644 internal/controller/connectionsecret/resource_manager.go 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/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..1a66365d66 100644 --- a/internal/controller/atlasdatabaseuser/databaseuser.go +++ b/internal/controller/atlasdatabaseuser/databaseuser.go @@ -25,7 +25,6 @@ import ( "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 +83,13 @@ 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 := IsExpired(atlasDatabaseUser) 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) + return r.unmanage(ctx, atlasDatabaseUser) } scopesAreValid, err := r.areDeploymentScopesValid(ctx, deploymentService, atlasProject.ID, atlasDatabaseUser) @@ -117,7 +111,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 +133,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 +172,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 +184,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 +194,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 +219,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,7 +274,7 @@ func (r *AtlasDatabaseUserReconciler) removeOldUser(ctx context.Context, dbUserS return err } -func isExpired(atlasDatabaseUser *akov2.AtlasDatabaseUser) (bool, error) { +func IsExpired(atlasDatabaseUser *akov2.AtlasDatabaseUser) (bool, error) { if atlasDatabaseUser.Spec.DeleteAfterDate == "" { return false, nil } diff --git a/internal/controller/atlasdatabaseuser/databaseuser_test.go b/internal/controller/atlasdatabaseuser/databaseuser_test.go index 6a13c4c8e0..d7d88e65e0 100644 --- a/internal/controller/atlasdatabaseuser/databaseuser_test.go +++ b/internal/controller/atlasdatabaseuser/databaseuser_test.go @@ -693,7 +693,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 +1212,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 +1657,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 +1682,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 +2086,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 := IsExpired(tt.dbUser) 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 index 66953016e3..746b5292c0 100644 --- a/internal/controller/atlasdatafederation/connectionsecrets.go +++ b/internal/controller/atlasdatafederation/connectionsecrets.go @@ -79,7 +79,7 @@ func (r *AtlasDataFederationReconciler) ensureConnectionSecrets(ctx *workflow.Co connURLs = append(connURLs, fmt.Sprintf("mongodb://%s:%s@%s?ssl=true", dbUser.Spec.Username, password, host)) } - data := connectionsecret.ConnectionData{ + data := connectionsecret.ConnSecretData{ DBUserName: dbUser.Spec.Username, Password: password, ConnURL: strings.Join(connURLs, ","), 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..767153b5b7 100644 --- a/internal/controller/atlasdeployment/atlasdeployment_controller.go +++ b/internal/controller/atlasdeployment/atlasdeployment_controller.go @@ -51,7 +51,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 +138,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 +174,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() 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..4e01ea3bb0 --- /dev/null +++ b/internal/controller/connectionsecret/connectionsecret_controller.go @@ -0,0 +1,300 @@ +// 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/event" + "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/atlasdatabaseuser" + "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" + "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/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 + strRequest := req.NamespacedName.String() + r.Log.Infof("Reconcile started for ConnectionSecret request with %s", strRequest) + + ids, err := LoadRequestIdentifiers(ctx, r.Client, req.NamespacedName) + if err != nil { + if apiErrors.IsNotFound(err) { + r.Log.Debugf("ConnectionSecret not found; assuming it was deleted %s", strRequest) + return workflow.TerminateSilently(nil).WithoutRetry().ReconcileResult() + } + + r.Log.Errorf("Failed to parse ConnectionSecret request with %s: %v", strRequest, err) + return workflow.Terminate("InvalidConnectionSecretName", err).ReconcileResult() + } + + r.Log.Debugf("Identifiers loaded for ConnectionSecret request with %s", strRequest) + + // Loads the pair of AtlasDeployment and AtlasDatabaseUser via the indexers + pair, err := LoadPairedResources(ctx, r.Client, ids, req.Namespace) + if err != nil { + switch { + // This means there's no owner resources; the secret will be garbage collected + case errors.Is(err, ErrNoPairedResourcesFound): + r.Log.Debugf("No paired resources for ConnectionSecret request with %s", strRequest) + 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): + r.Log.Infof("Paired resource missing for ConnectionSecret request with %s — scheduling deletion", strRequest) + return r.handleDelete(ctx, req, ids, pair) + + case errors.Is(err, ErrManyDeployments), errors.Is(err, ErrManyUsers): + r.Log.Errorf("Ambiguous pairing (more than one) for ConnectionSecret request with %s", strRequest) + return workflow.Terminate("AmbiguousConnectionResources", err).ReconcileResult() + + default: + r.Log.Errorf("Failed to get paired resources ConnectionSecret request with %s: %v", strRequest, err) + return workflow.Terminate("InvalidConnectionResources", err).ReconcileResult() + } + } + + r.Log.Debugf("Paired resource loaded for ConnectionSecret request with %s", strRequest) + + // If the user expired, delete connection secret + expired, err := atlasdatabaseuser.IsExpired(pair.User) + if err != nil { + r.Log.Errorf("Failed to check expiration date for ConnectionSecret request with %s", strRequest) + return workflow.Terminate("AmbiguousConnectionResources", err).ReconcileResult() + } + if expired { + r.Log.Infof("Expired user for paired resource for ConnectionSecret request with %s — scheduling deletion", strRequest) + return r.handleDelete(ctx, req, ids, pair) + } + + // If the scope became invalid, delete connection secret + if pair.InvalidScopes() { + r.Log.Infof("Invalid scope for paired resource for ConnectionSecret request with %s — scheduling deletion", strRequest) + return r.handleDelete(ctx, req, ids, pair) + } + + // Checks that AtlasDeployment and AtlasDatabaseUser are ready before proceeding + if ready, notReady := pair.IsReady(); !ready { + r.Log.Debugf("Waiting till paired resources are ready for ConnectionSecret request with %s", strRequest) + return workflow.InProgress("ConnectionSecretNotReady", fmt.Sprintf("Not ready: %s", strings.Join(notReady, ", "))).ReconcileResult() + } + + // Create or update the k8s connection secret + r.Log.Infof("Start create or update ConnectionSecret request with %s", strRequest) + return r.handleUpdate(ctx, req, ids, pair) +} + +func (r *ConnectionSecretReconciler) DeploymentWatcherPredicate() 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.(*akov2.AtlasDeployment) + if !ok { + return false + } + oldObj, ok := e.ObjectOld.(*akov2.AtlasDeployment) + if !ok { + return false + } + return !IsDeploymentReady(oldObj) && IsDeploymentReady(newObj) + }, + } +} + +func (r *ConnectionSecretReconciler) DatabaseUserWatcherPredicate() 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.(*akov2.AtlasDatabaseUser) + if !ok { + return false + } + oldObj, ok := e.ObjectOld.(*akov2.AtlasDatabaseUser) + if !ok { + return false + } + return !IsDatabaseUserReady(oldObj) && IsDatabaseUserReady(newObj) + }, + } +} + +func (r *ConnectionSecretReconciler) For() (client.Object, builder.Predicates) { + // Filter out connection secrets based on the required labels + labelPredicates := predicate.NewPredicateFuncs(func(obj client.Object) bool { + labels := obj.GetLabels() + _, hasType := labels[TypeLabelKey] + _, hasProject := labels[ProjectLabelKey] + _, hasCluster := labels[ClusterLabelKey] + return hasType && hasProject && hasCluster + }) + + predicates := append(r.GlobalPredicates, labelPredicates) + return &corev1.Secret{}, builder.WithPredicates(predicates...) +} + +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( + r.DeploymentWatcherPredicate(), + predicate.GenerationChangedPredicate{}, + )), + ). + Watches( + &akov2.AtlasDatabaseUser{}, + handler.EnqueueRequestsFromMapFunc(r.newDatabaseUserMapFunc), + builder.WithPredicates(predicate.Or( + r.DatabaseUserWatcherPredicate(), + 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) 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 := ResolveProjectIDFromDeployment(ctx, r.Client, deployment) + 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 := ResolveProjectIDFromDatabaseUser(ctx, r.Client, user) + 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..b6bf860146 --- /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 handleUpdate()": { + 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 := LoadRequestIdentifiers(ctx, compositeClient, 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 := LoadRequestIdentifiers(ctx, compositeClient, 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..ec3a4b5ab3 --- /dev/null +++ b/internal/controller/connectionsecret/connectionsecret_test.go @@ -0,0 +1,735 @@ +// 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_HandleUpdate(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.handleUpdate(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..1d35564171 100644 --- a/internal/controller/connectionsecret/connectionsecrets.go +++ b/internal/controller/connectionsecret/connectionsecrets.go @@ -17,198 +17,272 @@ 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/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" +) + +// 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 } - removedOrphanSecrets := []string{} - for _, secret := range secretList.Items { - clusterName, ok := secret.Labels[ClusterLabelKey] - if !ok { - continue + if pair.Deployment != nil && pair.Deployment.Spec.ProjectRef != nil { + projectName, err := pair.ResolveProjectNameK8s(ctx, r.Client, pair.Deployment.Namespace) + if err != nil { + return "", err } - if clusterExists := stringutil.Contains(projectDeploymentNames, clusterName); clusterExists { - continue + if projectName != "" { + return projectName, nil } - 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)) - } - } - 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) } - // ensure secrets for both deployments and advanced deployment. - if result := createOrUpdateConnectionSecretsFromDeploymentSecrets(ctx, k8sClient, recorder, project, dbUser, conns); !result.IsOk() { - return result + if pair.User != nil && pair.User.Spec.ProjectRef != nil { + projectName, err := pair.ResolveProjectNameK8s(ctx, r.Client, pair.User.Namespace) + if err != nil { + return "", err + } + if projectName != "" { + return projectName, nil + } } - return workflow.OK() -} - -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) - - for _, di := range conns { - scopes := dbUser.GetScopes(akov2.DeploymentScopeType) - if len(scopes) != 0 && !stringutil.Contains(scopes, di.Name) { - continue + 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) + 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) { + strRequest := req.NamespacedName.String() + + // 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 { + r.Log.Errorf("Failed to resolve project name for ConnectionSecret request with %s: %v", strRequest, err) + return workflow.Terminate("UnresolvedProjectName", err).ReconcileResult() } - if err := cleanupStaleSecrets(ctx, k8sClient, project.ID, dbUser); err != nil { - return workflow.Terminate(workflow.DatabaseUserStaleConnectionSecrets, err) + r.Log.Debugf("Project name resolved to delete for ConnectionSecret request with %s", strRequest) + 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) { + r.Log.Infof("No secret to delete for ConnectionSecret request with %s: %v", strRequest, err) + return workflow.TerminateSilently(nil).WithoutRetry().ReconcileResult() + } + + r.Log.Errorf("Unable to delete secret for ConnectionSecret request with %s: %v", strRequest, err) + return workflow.Terminate("FailedDeletion", err).ReconcileResult() } - return workflow.OK() + + r.Log.Infof("Secret deleted for ConnectionSecret request with %s", strRequest) + 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 +// handleUpdate manages the case where we will create or update the connection secret +func (r *ConnectionSecretReconciler) handleUpdate( + ctx context.Context, req ctrl.Request, ids ConnSecretIdentifiers, pair *ConnSecretPair) (ctrl.Result, error) { + strRequest := req.NamespacedName.String() + + // 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 { + r.Log.Errorf("Failed to resolve ProjectName for ConnectionSecret request with %s: %v", strRequest, err) + return workflow.Terminate("FailedToResolveProjectName", err).ReconcileResult() } - return nil -} + ids.ProjectName = projectName + r.Log.Debugf("Project name resolved to create/update for ConnectionSecret request with %s", strRequest) -// 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 - } - secrets, err := ListByUserName(ctx.Context, k8sClient, user.Namespace, projectID, user.Spec.Username) + // Build connection data + data, err := pair.BuildConnectionData(ctx, r.Client) if err != nil { - return err + r.Log.Errorf("Failed to build connection data for ConnectionSecret request with %s: %v", strRequest, err) + return workflow.Terminate("FailedToBuildConnectionData", err).ReconcileResult() } - 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 { - return err + + r.Log.Debugf("ConnectionData created for ConnectionSecret request with %s", strRequest) + + name := CreateK8sFormat(projectName, ids.ClusterName, ids.DatabaseUsername) + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: req.Namespace, + }, + } + + // Populate Secret data/labels + if err := fillConnSecretData(secret, ids, data); err != nil { + r.Log.Errorf("Failed to fill secret data for ConnectionSecret request with %s: %v", strRequest, err) + return workflow.Terminate("FailedToFillSecret", err).ReconcileResult() + } + + // Add owners + if err := controllerutil.SetOwnerReference(pair.User, secret, r.Scheme); err != nil { + r.Log.Errorf("Failed to set controller owner (DatabaseUser) for %s: %v", strRequest, err) + return workflow.Terminate("FailedToSetOwnerReferences", err).ReconcileResult() + } + + // Do not uncomment; we cannot create owners cross-namespaced. connection secret will live in the same namespace as the user + // if err := controllerutil.SetOwnerReference(pair.Deployment, secret, r.Scheme); err != nil { + // r.Log.Errorf("Failed to add Deployment owner for %s: %v", strRequest, err) + // return workflow.Terminate("FailedToSetOwnerReferences", err).ReconcileResult() + // } + + // Create or Update the 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 getErr := r.Client.Get(ctx, client.ObjectKeyFromObject(secret), current); getErr != nil { + r.Log.Errorf("Failed to fetch existing ConnectionSecret for request with %s: %v", strRequest, getErr) + return workflow.Terminate("FailedToGetSecret", getErr).ReconcileResult() } - ctx.Log.Debugw("Removed connection Secret as it's not referenced by the AtlasDatabaseUser anymore", "secretname", s.Name) + secret.ResourceVersion = current.ResourceVersion + if updErr := r.Client.Update(ctx, secret); updErr != nil { + r.Log.Errorf("Failed to update ConnectionSecret for request with %s: %v", strRequest, updErr) + return workflow.Terminate("FailedToUpdateSecret", updErr).ReconcileResult() + } + } else { + r.Log.Errorf("Failed to create ConnectionSecret for request with %s: %v", strRequest, err) + return workflow.Terminate("FailedToCreateSecret", err).ReconcileResult() } } - return nil + + r.Log.Infof("Secret created/updated for ConnectionSecret request with %s", strRequest) + r.EventRecorder.Event(secret, corev1.EventTypeNormal, "Updated", "ConnectionSecret updated") + return workflow.OK().ReconcileResult() } -// 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 { +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 +} + +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..7406df112d 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..4ae1113c0b --- /dev/null +++ b/internal/controller/connectionsecret/requestname_extractor.go @@ -0,0 +1,294 @@ +// 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 LoadRequestIdentifiers(ctx context.Context, c client.Client, req types.NamespacedName) (ConnSecretIdentifiers, error) { + var ids ConnSecretIdentifiers + + // === Internal format: $$ + if strings.Contains(req.Name, InternalSeparator) { + parts := strings.SplitN(req.Name, InternalSeparator, 3) + if len(parts) != 3 { + return ids, ErrInternalFormatPartsInvalid + } + if parts[0] == "" || parts[1] == "" || parts[2] == "" { + return ids, ErrInternalFormatPartEmpty + } + return ConnSecretIdentifiers{ + ProjectID: parts[0], + ClusterName: parts[1], + DatabaseUsername: parts[2], + }, nil + } + + // === K8s format: -- + var secret corev1.Secret + if err := c.Get(ctx, req, &secret); err != nil { + return ids, err + } + + labels := secret.GetLabels() + projectID, hasProject := labels[ProjectLabelKey] + clusterName, hasCluster := labels[ClusterLabelKey] + + // Missing labels or values + if !hasProject || !hasCluster { + return ids, ErrK8sLabelsMissing + } + if projectID == "" || clusterName == "" { + return ids, ErrK8sLabelEmpty + } + + sep := fmt.Sprintf("-%s-", clusterName) + parts := strings.SplitN(req.Name, sep, 2) + if len(parts) != 2 { + return ids, ErrK8sNameSplitInvalid + } + if parts[0] == "" || parts[1] == "" { + return ids, 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 LoadPairedResources(ctx context.Context, c client.Client, ids ConnSecretIdentifiers, namespace string) (*ConnSecretPair, error) { + compositeDeploymentKey := ids.ProjectID + "-" + ids.ClusterName + deployments := &akov2.AtlasDeploymentList{} + if err := c.List(ctx, deployments, &client.ListOptions{ + FieldSelector: fields.OneTermEqualSelector(indexer.AtlasDeploymentBySpecNameAndProjectID, compositeDeploymentKey), + // Namespace: namespace, // Do not uncomment; we should be able to create connection secrets cross-namespaced + }); err != nil { + return nil, err + } + + compositeUserKey := ids.ProjectID + "-" + ids.DatabaseUsername + users := &akov2.AtlasDatabaseUserList{} + if err := c.List(ctx, users, &client.ListOptions{ + FieldSelector: fields.OneTermEqualSelector(indexer.AtlasDatabaseUserBySpecUsernameAndProjectID, compositeUserKey), + Namespace: namespace, + }); 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 (p *ConnSecretPair) InvalidScopes() 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 (p *ConnSecretPair) IsReady() (bool, []string) { + notReady := []string{} + + if p.Deployment == nil || !IsDeploymentReady(p.Deployment) { + if p.Deployment != nil { + notReady = append(notReady, fmt.Sprintf("AtlasDeployment/%s", p.Deployment.GetName())) + } else { + notReady = append(notReady, "AtlasDeployment/") + } + } + if p.User == nil || !IsDatabaseUserReady(p.User) { + if p.User != nil { + notReady = append(notReady, fmt.Sprintf("AtlasDatabaseUser/%s", p.User.GetName())) + } else { + notReady = append(notReady, "AtlasDatabaseUser/") + } + } + + return len(notReady) == 0, notReady +} + +// ResolveProjectNameK8s retrieves the ProjectName by K8s AtlasProject resource +func (p *ConnSecretPair) ResolveProjectNameK8s(ctx context.Context, c client.Client, namespace string) (string, error) { + var name string + if p.Deployment != nil && p.Deployment.Spec.ProjectRef != nil { + name = p.Deployment.Spec.ProjectRef.Name + } else if p.User != nil && p.User.Spec.ProjectRef != nil { + name = p.User.Spec.ProjectRef.Name + } else { + return "", errors.New("no ProjectRef available on Deployment or User") + } + + proj := &akov2.AtlasProject{} + if err := c.Get(ctx, kube.ObjectKey(namespace, name), proj); err != nil { + return "", fmt.Errorf("failed to retrieve AtlasProject %q: %w", name, err) + } + + return kube.NormalizeIdentifier(proj.Spec.Name), nil +} + +// BuildConnectionData constructs the secret data that will be passed in the secret +func (p *ConnSecretPair) BuildConnectionData(ctx context.Context, c client.Client) (ConnSecretData, error) { + password, err := p.User.ReadPassword(ctx, c) + 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..279efa7497 --- /dev/null +++ b/internal/controller/connectionsecret/requestname_extractor_test.go @@ -0,0 +1,543 @@ +// 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/indexer" +) + +func TestCreateK8sFormat(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 TestLoadRequestIdentifiers(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() + + 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 := LoadRequestIdentifiers( + context.Background(), + client, + 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 TestPair_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 := p.IsReady() + 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 := p.IsReady() + assert.False(t, ok) + assert.Equal(t, []string{"AtlasDatabaseUser/user"}, notReady) + }) +} + +func TestPair_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() + + ids := ConnSecretIdentifiers{ + ProjectID: projectID, + ClusterName: tt.clusterName, + DatabaseUsername: tt.databaseUsername, + } + + pair, err := LoadPairedResources(context.Background(), cl, ids, ns) + + 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 := LoadPairedResources(context.Background(), cl, failIDs, ns) + assert.Error(t, failErr) + assert.Nil(t, failPair) + assert.ErrorIs(t, failErr, ErrNoPairedResourcesFound) + }) + } +} + +func TestPair_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() + + p := &ConnSecretPair{ + Deployment: deployment, + User: user, + ProjectID: "proj123", + } + + data, err := p.BuildConnectionData(context.Background(), client) + 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/connectionsecret/resource_manager.go b/internal/controller/connectionsecret/resource_manager.go new file mode 100644 index 0000000000..b985c8bcfe --- /dev/null +++ b/internal/controller/connectionsecret/resource_manager.go @@ -0,0 +1,71 @@ +// 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" + + corev1 "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" +) + +func HasReadyCondition(conditions []api.Condition) bool { + for _, c := range conditions { + if c.Type == api.ReadyType && c.Status == corev1.ConditionTrue { + return true + } + } + return false +} + +func IsDeploymentReady(d *akov2.AtlasDeployment) bool { + return HasReadyCondition(d.Status.Conditions) +} + +func IsDatabaseUserReady(u *akov2.AtlasDatabaseUser) bool { + return HasReadyCondition(u.Status.Conditions) +} + +func ResolveProjectIDFromDeployment(ctx context.Context, c client.Client, d *akov2.AtlasDeployment) (string, error) { + if d.Spec.ExternalProjectRef != nil && d.Spec.ExternalProjectRef.ID != "" { + return d.Spec.ExternalProjectRef.ID, nil + } + if d.Spec.ProjectRef != nil && d.Spec.ProjectRef.Name != "" { + project := &akov2.AtlasProject{} + if err := c.Get(ctx, *d.Spec.ProjectRef.GetObject(d.Namespace), 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 ResolveProjectIDFromDatabaseUser(ctx context.Context, c client.Client, u *akov2.AtlasDatabaseUser) (string, error) { + if u.Spec.ExternalProjectRef != nil && u.Spec.ExternalProjectRef.ID != "" { + return u.Spec.ExternalProjectRef.ID, nil + } + if u.Spec.ProjectRef != nil && u.Spec.ProjectRef.Name != "" { + project := &akov2.AtlasProject{} + if err := c.Get(ctx, *u.Spec.ProjectRef.GetObject(u.Namespace), project); err != nil { + return "", fmt.Errorf("failed to resolve projectRef from user: %w", err) + } + return project.ID(), nil + } + return "", fmt.Errorf("missing both external and internal project references") +} diff --git a/internal/controller/registry.go b/internal/controller/registry.go index a68bec2b5a..77e83e6132 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" + "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/controller/connectionsecret" "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, connectionsecret.NewConnectionSecretReconciler(c, r.deprecatedPredicates(), ap, r.logger, r.globalSecretRef)) if version.IsExperimental() { // Add experimental controllers here 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/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 } From 8b4db66da19a3d8387db0a516149094f91caf75b Mon Sep 17 00:00:00 2001 From: andrpac Date: Tue, 12 Aug 2025 17:25:19 +0100 Subject: [PATCH 02/11] chore: clean up loggin --- .../connectionsecret_controller.go | 72 +++++++------------ .../connectionsecret/connectionsecrets.go | 60 +++++++--------- .../connectionsecret/requestname_extractor.go | 2 - internal/controller/workflow/reason.go | 23 ++++++ 4 files changed, 77 insertions(+), 80 deletions(-) diff --git a/internal/controller/connectionsecret/connectionsecret_controller.go b/internal/controller/connectionsecret/connectionsecret_controller.go index 4e01ea3bb0..28f5b511b8 100644 --- a/internal/controller/connectionsecret/connectionsecret_controller.go +++ b/internal/controller/connectionsecret/connectionsecret_controller.go @@ -57,21 +57,20 @@ type ConnectionSecretReconciler struct { 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 - strRequest := req.NamespacedName.String() - r.Log.Infof("Reconcile started for ConnectionSecret request with %s", strRequest) + log := r.Log.With("ns", req.Namespace, "name", req.Name) + log.Debugw("reconcile started") ids, err := LoadRequestIdentifiers(ctx, r.Client, req.NamespacedName) if err != nil { if apiErrors.IsNotFound(err) { - r.Log.Debugf("ConnectionSecret not found; assuming it was deleted %s", strRequest) + log.Debugw("connectionsecret not found; assuming deleted") return workflow.TerminateSilently(nil).WithoutRetry().ReconcileResult() } - - r.Log.Errorf("Failed to parse ConnectionSecret request with %s: %v", strRequest, err) - return workflow.Terminate("InvalidConnectionSecretName", err).ReconcileResult() + log.Errorw("failed to parse connectionsecret request", "reason", workflow.ConnSecretInvalidName, "error", err) + return workflow.Terminate(workflow.ConnSecretInvalidName, err).ReconcileResult() } - r.Log.Debugf("Identifiers loaded for ConnectionSecret request with %s", strRequest) + log.Debugw("identifiers loaded") // Loads the pair of AtlasDeployment and AtlasDatabaseUser via the indexers pair, err := LoadPairedResources(ctx, r.Client, ids, req.Namespace) @@ -79,88 +78,71 @@ func (r *ConnectionSecretReconciler) Reconcile(ctx context.Context, req ctrl.Req switch { // This means there's no owner resources; the secret will be garbage collected case errors.Is(err, ErrNoPairedResourcesFound): - r.Log.Debugf("No paired resources for ConnectionSecret request with %s", strRequest) + 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): - r.Log.Infof("Paired resource missing for ConnectionSecret request with %s — scheduling deletion", strRequest) + 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): - r.Log.Errorf("Ambiguous pairing (more than one) for ConnectionSecret request with %s", strRequest) - return workflow.Terminate("AmbiguousConnectionResources", err).ReconcileResult() + log.Errorw("ambiguous pairing; multiple matches", "reason", workflow.ConnSecretAmbiguousResources, "error", err) + return workflow.Terminate(workflow.ConnSecretAmbiguousResources, err).ReconcileResult() default: - r.Log.Errorf("Failed to get paired resources ConnectionSecret request with %s: %v", strRequest, err) - return workflow.Terminate("InvalidConnectionResources", err).ReconcileResult() + log.Errorw("failed to load paired resources", "reason", workflow.ConnSecretInvalidResources, "error", err) + return workflow.Terminate(workflow.ConnSecretInvalidResources, err).ReconcileResult() } } - r.Log.Debugf("Paired resource loaded for ConnectionSecret request with %s", strRequest) + log.Debugw("paired resources loaded") // If the user expired, delete connection secret expired, err := atlasdatabaseuser.IsExpired(pair.User) if err != nil { - r.Log.Errorf("Failed to check expiration date for ConnectionSecret request with %s", strRequest) - return workflow.Terminate("AmbiguousConnectionResources", err).ReconcileResult() + log.Errorw("failed to check expiration date", "reason", workflow.ConnSecretCheckExpirationFailed, "error", err) + return workflow.Terminate(workflow.ConnSecretCheckExpirationFailed, err).ReconcileResult() } if expired { - r.Log.Infof("Expired user for paired resource for ConnectionSecret request with %s — scheduling deletion", strRequest) + 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 pair.InvalidScopes() { - r.Log.Infof("Invalid scope for paired resource for ConnectionSecret request with %s — scheduling deletion", strRequest) + 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 := pair.IsReady(); !ready { - r.Log.Debugf("Waiting till paired resources are ready for ConnectionSecret request with %s", strRequest) - return workflow.InProgress("ConnectionSecretNotReady", fmt.Sprintf("Not ready: %s", strings.Join(notReady, ", "))).ReconcileResult() + 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 - r.Log.Infof("Start create or update ConnectionSecret request with %s", strRequest) + log.Infow("creating/updating connection secret", "reason", workflow.ConnSecretUpsert) return r.handleUpdate(ctx, req, ids, pair) } -func (r *ConnectionSecretReconciler) DeploymentWatcherPredicate() 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.(*akov2.AtlasDeployment) - if !ok { - return false - } - oldObj, ok := e.ObjectOld.(*akov2.AtlasDeployment) - if !ok { - return false - } - return !IsDeploymentReady(oldObj) && IsDeploymentReady(newObj) - }, - } -} +type ReadyFunc[T any] func(obj T) bool -func (r *ConnectionSecretReconciler) DatabaseUserWatcherPredicate() predicate.Predicate { +func GenericWatcherPredicate[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.(*akov2.AtlasDatabaseUser) + newObj, ok := e.ObjectNew.(T) if !ok { return false } - oldObj, ok := e.ObjectOld.(*akov2.AtlasDatabaseUser) + oldObj, ok := e.ObjectOld.(T) if !ok { return false } - return !IsDatabaseUserReady(oldObj) && IsDatabaseUserReady(newObj) + return !ready(oldObj) && ready(newObj) }, } } @@ -187,7 +169,7 @@ func (r *ConnectionSecretReconciler) SetupWithManager(mgr ctrl.Manager, skipName &akov2.AtlasDeployment{}, handler.EnqueueRequestsFromMapFunc(r.newDeploymentMapFunc), builder.WithPredicates(predicate.Or( - r.DeploymentWatcherPredicate(), + GenericWatcherPredicate(IsDeploymentReady), predicate.GenerationChangedPredicate{}, )), ). @@ -195,7 +177,7 @@ func (r *ConnectionSecretReconciler) SetupWithManager(mgr ctrl.Manager, skipName &akov2.AtlasDatabaseUser{}, handler.EnqueueRequestsFromMapFunc(r.newDatabaseUserMapFunc), builder.WithPredicates(predicate.Or( - r.DatabaseUserWatcherPredicate(), + GenericWatcherPredicate(IsDatabaseUserReady), predicate.GenerationChangedPredicate{}, )), ). diff --git a/internal/controller/connectionsecret/connectionsecrets.go b/internal/controller/connectionsecret/connectionsecrets.go index 1d35564171..c47884be38 100644 --- a/internal/controller/connectionsecret/connectionsecrets.go +++ b/internal/controller/connectionsecret/connectionsecrets.go @@ -112,7 +112,7 @@ func (r *ConnectionSecretReconciler) resolveProjectName(ctx context.Context, ids // 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) { - strRequest := req.NamespacedName.String() + 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) @@ -120,11 +120,12 @@ func (r *ConnectionSecretReconciler) handleDelete( err = fmt.Errorf("project name is empty") } if err != nil { - r.Log.Errorf("Failed to resolve project name for ConnectionSecret request with %s: %v", strRequest, err) - return workflow.Terminate("UnresolvedProjectName", err).ReconcileResult() + log.Errorw("failed to resolve project name", "reason", workflow.ConnSecretUnresolvedProjectName, "error", err) + return workflow.Terminate(workflow.ConnSecretUnresolvedProjectName, err).ReconcileResult() } - r.Log.Debugf("Project name resolved to delete for ConnectionSecret request with %s", strRequest) + log.Debugw("project name resolved for delete") + name := CreateK8sFormat(projectName, ids.ClusterName, ids.DatabaseUsername) secret := &corev1.Secret{ ObjectMeta: metav1.ObjectMeta{ @@ -136,15 +137,14 @@ func (r *ConnectionSecretReconciler) handleDelete( // Delete the secret if err := r.Client.Delete(ctx, secret); err != nil { if apierrors.IsNotFound(err) { - r.Log.Infof("No secret to delete for ConnectionSecret request with %s: %v", strRequest, err) + log.Debugw("no secret to delete; already gone") return workflow.TerminateSilently(nil).WithoutRetry().ReconcileResult() } - - r.Log.Errorf("Unable to delete secret for ConnectionSecret request with %s: %v", strRequest, err) - return workflow.Terminate("FailedDeletion", err).ReconcileResult() + log.Errorw("unable to delete secret", "reason", workflow.ConnSecretFailedDeletion, "error", err) + return workflow.Terminate(workflow.ConnSecretFailedDeletion, err).ReconcileResult() } - r.Log.Infof("Secret deleted for ConnectionSecret request with %s", strRequest) + log.Infow("secret deleted", "reason", workflow.ConnSecretDeleted) r.EventRecorder.Event(secret, corev1.EventTypeNormal, "Deleted", "ConnectionSecret deleted") return workflow.TerminateSilently(nil).WithoutRetry().ReconcileResult() } @@ -152,7 +152,7 @@ func (r *ConnectionSecretReconciler) handleDelete( // handleUpdate manages the case where we will create or update the connection secret func (r *ConnectionSecretReconciler) handleUpdate( ctx context.Context, req ctrl.Request, ids ConnSecretIdentifiers, pair *ConnSecretPair) (ctrl.Result, error) { - strRequest := req.NamespacedName.String() + 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) @@ -160,20 +160,20 @@ func (r *ConnectionSecretReconciler) handleUpdate( err = fmt.Errorf("project name is empty") } if err != nil { - r.Log.Errorf("Failed to resolve ProjectName for ConnectionSecret request with %s: %v", strRequest, err) - return workflow.Terminate("FailedToResolveProjectName", err).ReconcileResult() + log.Errorw("failed to resolve project name", "reason", workflow.ConnSecretFailedToResolveProjectName, "error", err) + return workflow.Terminate(workflow.ConnSecretFailedToResolveProjectName, err).ReconcileResult() } ids.ProjectName = projectName - r.Log.Debugf("Project name resolved to create/update for ConnectionSecret request with %s", strRequest) + log.Debugw("project name resolved for upsert") // Build connection data data, err := pair.BuildConnectionData(ctx, r.Client) if err != nil { - r.Log.Errorf("Failed to build connection data for ConnectionSecret request with %s: %v", strRequest, err) - return workflow.Terminate("FailedToBuildConnectionData", err).ReconcileResult() + log.Errorw("failed to build connection data", "reason", workflow.ConnSecretFailedToBuildData, "error", err) + return workflow.Terminate(workflow.ConnSecretFailedToBuildData, err).ReconcileResult() } - r.Log.Debugf("ConnectionData created for ConnectionSecret request with %s", strRequest) + log.Debugw("connection data built") name := CreateK8sFormat(projectName, ids.ClusterName, ids.DatabaseUsername) secret := &corev1.Secret{ @@ -185,43 +185,37 @@ func (r *ConnectionSecretReconciler) handleUpdate( // Populate Secret data/labels if err := fillConnSecretData(secret, ids, data); err != nil { - r.Log.Errorf("Failed to fill secret data for ConnectionSecret request with %s: %v", strRequest, err) - return workflow.Terminate("FailedToFillSecret", err).ReconcileResult() + log.Errorw("failed to fill secret data", "reason", workflow.ConnSecretFailedToFillData, "error", err) + return workflow.Terminate(workflow.ConnSecretFailedToFillData, err).ReconcileResult() } // Add owners if err := controllerutil.SetOwnerReference(pair.User, secret, r.Scheme); err != nil { - r.Log.Errorf("Failed to set controller owner (DatabaseUser) for %s: %v", strRequest, err) - return workflow.Terminate("FailedToSetOwnerReferences", err).ReconcileResult() + log.Errorw("failed to set controller owner (DatabaseUser)", "reason", workflow.ConnSecretFailedToSetOwnerReferences, "error", err) + return workflow.Terminate(workflow.ConnSecretFailedToSetOwnerReferences, err).ReconcileResult() } - // Do not uncomment; we cannot create owners cross-namespaced. connection secret will live in the same namespace as the user - // if err := controllerutil.SetOwnerReference(pair.Deployment, secret, r.Scheme); err != nil { - // r.Log.Errorf("Failed to add Deployment owner for %s: %v", strRequest, err) - // return workflow.Terminate("FailedToSetOwnerReferences", err).ReconcileResult() - // } - // Create or Update the 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 getErr := r.Client.Get(ctx, client.ObjectKeyFromObject(secret), current); getErr != nil { - r.Log.Errorf("Failed to fetch existing ConnectionSecret for request with %s: %v", strRequest, getErr) - return workflow.Terminate("FailedToGetSecret", getErr).ReconcileResult() + log.Errorw("failed to fetch existing secret", "reason", workflow.ConnSecretFailedToGetSecret, "error", getErr) + return workflow.Terminate(workflow.ConnSecretFailedToGetSecret, getErr).ReconcileResult() } secret.ResourceVersion = current.ResourceVersion if updErr := r.Client.Update(ctx, secret); updErr != nil { - r.Log.Errorf("Failed to update ConnectionSecret for request with %s: %v", strRequest, updErr) - return workflow.Terminate("FailedToUpdateSecret", updErr).ReconcileResult() + log.Errorw("failed to update secret", "reason", workflow.ConnSecretFailedToUpdateSecret, "error", updErr) + return workflow.Terminate(workflow.ConnSecretFailedToUpdateSecret, updErr).ReconcileResult() } } else { - r.Log.Errorf("Failed to create ConnectionSecret for request with %s: %v", strRequest, err) - return workflow.Terminate("FailedToCreateSecret", err).ReconcileResult() + log.Errorw("failed to create secret", "reason", workflow.ConnSecretFailedToCreateSecret, "error", err) + return workflow.Terminate(workflow.ConnSecretFailedToCreateSecret, err).ReconcileResult() } } - r.Log.Infof("Secret created/updated for ConnectionSecret request with %s", strRequest) + log.Infow("secret created/updated", "reason", workflow.ConnSecretUpsert) r.EventRecorder.Event(secret, corev1.EventTypeNormal, "Updated", "ConnectionSecret updated") return workflow.OK().ReconcileResult() } diff --git a/internal/controller/connectionsecret/requestname_extractor.go b/internal/controller/connectionsecret/requestname_extractor.go index 4ae1113c0b..a77f249f95 100644 --- a/internal/controller/connectionsecret/requestname_extractor.go +++ b/internal/controller/connectionsecret/requestname_extractor.go @@ -166,7 +166,6 @@ func LoadPairedResources(ctx context.Context, c client.Client, ids ConnSecretIde deployments := &akov2.AtlasDeploymentList{} if err := c.List(ctx, deployments, &client.ListOptions{ FieldSelector: fields.OneTermEqualSelector(indexer.AtlasDeploymentBySpecNameAndProjectID, compositeDeploymentKey), - // Namespace: namespace, // Do not uncomment; we should be able to create connection secrets cross-namespaced }); err != nil { return nil, err } @@ -175,7 +174,6 @@ func LoadPairedResources(ctx context.Context, c client.Client, ids ConnSecretIde users := &akov2.AtlasDatabaseUserList{} if err := c.List(ctx, users, &client.ListOptions{ FieldSelector: fields.OneTermEqualSelector(indexer.AtlasDatabaseUserBySpecUsernameAndProjectID, compositeUserKey), - Namespace: namespace, }); err != nil { return nil, err } 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" +) From 889d395d8a2aece8c47f4e835f24a2497672c39f Mon Sep 17 00:00:00 2001 From: andrpac Date: Wed, 13 Aug 2025 12:58:21 +0100 Subject: [PATCH 03/11] chore: cleanups after review --- api/condition.go | 9 ++ api/v1/atlasdatabaseuser_types.go | 5 + api/v1/atlasdeployment_types.go | 4 + .../atlasdatabaseuser/databaseuser.go | 24 +--- .../atlasdatabaseuser/databaseuser_test.go | 3 +- .../connectionsecret_controller.go | 82 +++++-------- .../connectionsecret_controller_test.go | 6 +- .../connectionsecret/connectionsecret_test.go | 16 +-- .../connectionsecret/connectionsecrets.go | 112 +++++++++++------- .../connectionsecret/listsecrets.go | 2 +- .../connectionsecret/requestname_extractor.go | 91 ++++++-------- .../requestname_extractor_test.go | 50 +++++--- .../connectionsecret/resource_manager.go | 71 ----------- internal/controller/registry.go | 2 +- internal/controller/watch/predicates.go | 39 ++++++ internal/timeutil/timeutil.go | 15 +++ 16 files changed, 259 insertions(+), 272 deletions(-) delete mode 100644 internal/controller/connectionsecret/resource_manager.go 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/databaseuser.go b/internal/controller/atlasdatabaseuser/databaseuser.go index 1a66365d66..7035dadbb4 100644 --- a/internal/controller/atlasdatabaseuser/databaseuser.go +++ b/internal/controller/atlasdatabaseuser/databaseuser.go @@ -18,7 +18,6 @@ import ( "context" "errors" "fmt" - "time" corev1 "k8s.io/api/core/v1" ctrl "sigs.k8s.io/controller-runtime" @@ -83,12 +82,14 @@ 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 { - ctx.SetConditionFromResult(api.DatabaseUserReadyType, workflow.Terminate(workflow.DatabaseUserExpired, errors.New("an expired user cannot be managed"))) + ctx.SetConditionFromResult(api.DatabaseUserReadyType, + workflow.Terminate(workflow.DatabaseUserExpired, errors.New("an expired user cannot be managed")), + ) return r.unmanage(ctx, atlasDatabaseUser) } @@ -274,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 d7d88e65e0..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" @@ -2086,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/connectionsecret/connectionsecret_controller.go b/internal/controller/connectionsecret/connectionsecret_controller.go index 28f5b511b8..4a74212f92 100644 --- a/internal/controller/connectionsecret/connectionsecret_controller.go +++ b/internal/controller/connectionsecret/connectionsecret_controller.go @@ -32,19 +32,19 @@ import ( "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/event" "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/atlasdatabaseuser" "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" ) @@ -60,7 +60,7 @@ func (r *ConnectionSecretReconciler) Reconcile(ctx context.Context, req ctrl.Req log := r.Log.With("ns", req.Namespace, "name", req.Name) log.Debugw("reconcile started") - ids, err := LoadRequestIdentifiers(ctx, r.Client, req.NamespacedName) + ids, err := r.loadRequestIdentifiers(ctx, req.NamespacedName) if err != nil { if apiErrors.IsNotFound(err) { log.Debugw("connectionsecret not found; assuming deleted") @@ -73,7 +73,7 @@ func (r *ConnectionSecretReconciler) Reconcile(ctx context.Context, req ctrl.Req log.Debugw("identifiers loaded") // Loads the pair of AtlasDeployment and AtlasDatabaseUser via the indexers - pair, err := LoadPairedResources(ctx, r.Client, ids, req.Namespace) + pair, err := r.loadPairedResources(ctx, ids) if err != nil { switch { // This means there's no owner resources; the secret will be garbage collected @@ -99,7 +99,7 @@ func (r *ConnectionSecretReconciler) Reconcile(ctx context.Context, req ctrl.Req log.Debugw("paired resources loaded") // If the user expired, delete connection secret - expired, err := atlasdatabaseuser.IsExpired(pair.User) + 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() @@ -110,55 +110,28 @@ func (r *ConnectionSecretReconciler) Reconcile(ctx context.Context, req ctrl.Req } // If the scope became invalid, delete connection secret - if pair.InvalidScopes() { + 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 := pair.IsReady(); !ready { + 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.handleUpdate(ctx, req, ids, pair) -} - -type ReadyFunc[T any] func(obj T) bool - -func GenericWatcherPredicate[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) - }, - } + return r.handleUpsert(ctx, req, ids, pair) } func (r *ConnectionSecretReconciler) For() (client.Object, builder.Predicates) { - // Filter out connection secrets based on the required labels - labelPredicates := predicate.NewPredicateFuncs(func(obj client.Object) bool { - labels := obj.GetLabels() - _, hasType := labels[TypeLabelKey] - _, hasProject := labels[ProjectLabelKey] - _, hasCluster := labels[ClusterLabelKey] - return hasType && hasProject && hasCluster - }) - - predicates := append(r.GlobalPredicates, labelPredicates) - return &corev1.Secret{}, builder.WithPredicates(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 { @@ -169,7 +142,7 @@ func (r *ConnectionSecretReconciler) SetupWithManager(mgr ctrl.Manager, skipName &akov2.AtlasDeployment{}, handler.EnqueueRequestsFromMapFunc(r.newDeploymentMapFunc), builder.WithPredicates(predicate.Or( - GenericWatcherPredicate(IsDeploymentReady), + watch.ReadyTransitionPredicate((*akov2.AtlasDeployment).IsDeploymentReady), predicate.GenerationChangedPredicate{}, )), ). @@ -177,7 +150,7 @@ func (r *ConnectionSecretReconciler) SetupWithManager(mgr ctrl.Manager, skipName &akov2.AtlasDatabaseUser{}, handler.EnqueueRequestsFromMapFunc(r.newDatabaseUserMapFunc), builder.WithPredicates(predicate.Or( - GenericWatcherPredicate(IsDatabaseUserReady), + watch.ReadyTransitionPredicate((*akov2.AtlasDatabaseUser).IsDatabaseUserReady), predicate.GenerationChangedPredicate{}, )), ). @@ -213,19 +186,31 @@ func (r *ConnectionSecretReconciler) generateConnectionSecretRequests( 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 := ResolveProjectIDFromDeployment(ctx, r.Client, deployment) + 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), @@ -233,23 +218,19 @@ func (r *ConnectionSecretReconciler) newDeploymentMapFunc(ctx context.Context, o 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 := ResolveProjectIDFromDatabaseUser(ctx, r.Client, user) + 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), @@ -257,7 +238,6 @@ func (r *ConnectionSecretReconciler) newDatabaseUserMapFunc(ctx context.Context, r.Log.Errorf("failed to list AtlasDeployments: %v", err) return nil } - return r.generateConnectionSecretRequests(projectID, deployments.Items, []akov2.AtlasDatabaseUser{*user}) } diff --git a/internal/controller/connectionsecret/connectionsecret_controller_test.go b/internal/controller/connectionsecret/connectionsecret_controller_test.go index b6bf860146..d01c671fa2 100644 --- a/internal/controller/connectionsecret/connectionsecret_controller_test.go +++ b/internal/controller/connectionsecret/connectionsecret_controller_test.go @@ -361,7 +361,7 @@ func TestConnectionSecretReconcile(t *testing.T) { return workflow.TerminateSilently(nil).WithoutRetry().ReconcileResult() }, }, - "success: pair ready will call handleUpdate()": { + "success: pair ready will call handleUpsert()": { reqName: "test-project-id$cluster1$admin", deployment: &akov2.AtlasDeployment{ ObjectMeta: metav1.ObjectMeta{ @@ -528,7 +528,7 @@ func TestConnectionSecretReconcile(t *testing.T) { } if tc.expectedUpdate { - ids, err := LoadRequestIdentifiers(ctx, compositeClient, req.NamespacedName) + ids, err := r.loadRequestIdentifiers(ctx, req.NamespacedName) require.NoError(t, err) ids.ProjectName = "myproject" @@ -542,7 +542,7 @@ func TestConnectionSecretReconcile(t *testing.T) { } if tc.expectedDeletion { - ids, err := LoadRequestIdentifiers(ctx, compositeClient, req.NamespacedName) + ids, err := r.loadRequestIdentifiers(ctx, req.NamespacedName) require.NoError(t, err) expectedName := CreateK8sFormat(ids.ProjectName, ids.ClusterName, ids.DatabaseUsername) diff --git a/internal/controller/connectionsecret/connectionsecret_test.go b/internal/controller/connectionsecret/connectionsecret_test.go index ec3a4b5ab3..9ec82b76a5 100644 --- a/internal/controller/connectionsecret/connectionsecret_test.go +++ b/internal/controller/connectionsecret/connectionsecret_test.go @@ -47,7 +47,7 @@ import ( "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/pointer" ) -func Test_ResolveProjectName(t *testing.T) { +func Test_resolveProjectName(t *testing.T) { type expectedResult struct { expectedProjectName string expectedError error @@ -310,11 +310,7 @@ func Test_ResolveProjectName(t *testing.T) { EventRecorder: record.NewFakeRecorder(10), } - gotName, err := r.resolveProjectName( - context.Background(), - tc.ids, - &tc.pair, - ) + gotName, err := r.resolveProjectName(context.Background(), &tc.ids, &tc.pair) require.Equal(t, tc.result.expectedProjectName, gotName) if tc.result.expectedError != nil { @@ -326,7 +322,7 @@ func Test_ResolveProjectName(t *testing.T) { } } -func Test_HandleDelete(t *testing.T) { +func Test_handleDelete(t *testing.T) { type expectedResult struct { expectedResult ctrl.Result expectedError error @@ -477,7 +473,7 @@ func Test_HandleDelete(t *testing.T) { NamespacedName: types.NamespacedName{Namespace: ns, Name: "any"}, } - res, err := r.handleDelete(context.Background(), req, tc.ids, &tc.pair) + 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()) @@ -501,7 +497,7 @@ func Test_HandleDelete(t *testing.T) { } } -func Test_HandleUpdate(t *testing.T) { +func Test_handleUpsert(t *testing.T) { type expectedResult struct { expectedResult ctrl.Result expectedError error @@ -676,7 +672,7 @@ func Test_HandleUpdate(t *testing.T) { req := ctrl.Request{NamespacedName: types.NamespacedName{Namespace: ns, Name: "any"}} - res, err := r.handleUpdate(context.Background(), req, tc.ids, &tc.pair) + 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()) diff --git a/internal/controller/connectionsecret/connectionsecrets.go b/internal/controller/connectionsecret/connectionsecrets.go index c47884be38..0dc271c6bc 100644 --- a/internal/controller/connectionsecret/connectionsecrets.go +++ b/internal/controller/connectionsecret/connectionsecrets.go @@ -26,7 +26,10 @@ import ( "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" ) const ( @@ -44,32 +47,40 @@ const ( privateShardKey string = "connectionStringPrivateShard" ) +// 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 + } + + if ref == nil { + return "", nil + } + + 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 kube.NormalizeIdentifier(proj.Spec.Name), nil +} + // 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) { +func (r *ConnectionSecretReconciler) resolveProjectName(ctx context.Context, ids *ConnSecretIdentifiers, pair *ConnSecretPair) (string, error) { if ids.ProjectName != "" { return ids.ProjectName, nil } - if pair.Deployment != nil && pair.Deployment.Spec.ProjectRef != nil { - projectName, err := pair.ResolveProjectNameK8s(ctx, r.Client, pair.Deployment.Namespace) - if err != nil { - return "", err - } - if projectName != "" { - return projectName, nil - } - } - - if pair.User != nil && pair.User.Spec.ProjectRef != nil { - projectName, err := pair.ResolveProjectNameK8s(ctx, r.Client, pair.User.Namespace) - if err != nil { - return "", err - } - if projectName != "" { - return 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 } + // SDK path from Deployment if pair.Deployment != nil { connCfg, err := r.ResolveConnectionConfig(ctx, pair.Deployment) if err != nil { @@ -88,6 +99,7 @@ func (r *ConnectionSecretReconciler) resolveProjectName(ctx context.Context, ids } } + // SDK path from User if pair.User != nil { connCfg, err := r.ResolveConnectionConfig(ctx, pair.User) if err != nil { @@ -111,7 +123,7 @@ func (r *ConnectionSecretReconciler) resolveProjectName(ctx context.Context, ids // 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) { + 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 @@ -149,9 +161,9 @@ func (r *ConnectionSecretReconciler) handleDelete( return workflow.TerminateSilently(nil).WithoutRetry().ReconcileResult() } -// handleUpdate manages the case where we will create or update the connection secret -func (r *ConnectionSecretReconciler) handleUpdate( - ctx context.Context, req ctrl.Request, ids ConnSecretIdentifiers, pair *ConnSecretPair) (ctrl.Result, error) { +// 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 @@ -167,60 +179,71 @@ func (r *ConnectionSecretReconciler) handleUpdate( log.Debugw("project name resolved for upsert") // Build connection data - data, err := pair.BuildConnectionData(ctx, r.Client) + 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") - name := CreateK8sFormat(projectName, ids.ClusterName, ids.DatabaseUsername) + 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() +} + +// 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: req.Namespace, + Namespace: namespace, }, } // 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 workflow.Terminate(workflow.ConnSecretFailedToFillData, err).ReconcileResult() + return err } // Add owners - if err := controllerutil.SetOwnerReference(pair.User, secret, r.Scheme); err != nil { - log.Errorw("failed to set controller owner (DatabaseUser)", "reason", workflow.ConnSecretFailedToSetOwnerReferences, "error", err) - return workflow.Terminate(workflow.ConnSecretFailedToSetOwnerReferences, err).ReconcileResult() + 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 } - // Create or Update the Secret + // 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 getErr := r.Client.Get(ctx, client.ObjectKeyFromObject(secret), current); getErr != nil { - log.Errorw("failed to fetch existing secret", "reason", workflow.ConnSecretFailedToGetSecret, "error", getErr) - return workflow.Terminate(workflow.ConnSecretFailedToGetSecret, getErr).ReconcileResult() + 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 updErr := r.Client.Update(ctx, secret); updErr != nil { - log.Errorw("failed to update secret", "reason", workflow.ConnSecretFailedToUpdateSecret, "error", updErr) - return workflow.Terminate(workflow.ConnSecretFailedToUpdateSecret, updErr).ReconcileResult() + if err := r.Client.Update(ctx, secret); err != nil { + log.Errorw("failed to update secret", "reason", workflow.ConnSecretFailedToUpdateSecret, "error", err) + return err } } else { log.Errorw("failed to create secret", "reason", workflow.ConnSecretFailedToCreateSecret, "error", err) - return workflow.Terminate(workflow.ConnSecretFailedToCreateSecret, err).ReconcileResult() + return err } } - - log.Infow("secret created/updated", "reason", workflow.ConnSecretUpsert) - r.EventRecorder.Event(secret, corev1.EventTypeNormal, "Updated", "ConnectionSecret updated") - return workflow.OK().ReconcileResult() + return nil } -func fillConnSecretData(secret *corev1.Secret, ids ConnSecretIdentifiers, data ConnSecretData) error { +// 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 @@ -271,6 +294,7 @@ func fillConnSecretData(secret *corev1.Secret, ids ConnSecretIdentifiers, data C 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 { diff --git a/internal/controller/connectionsecret/listsecrets.go b/internal/controller/connectionsecret/listsecrets.go index 7406df112d..5f8fd00ba3 100644 --- a/internal/controller/connectionsecret/listsecrets.go +++ b/internal/controller/connectionsecret/listsecrets.go @@ -37,7 +37,7 @@ func Ensure(ctx context.Context, client client.Client, namespace, projectName, p return "", getError } - ids := ConnSecretIdentifiers{ + ids := &ConnSecretIdentifiers{ ProjectID: projectID, ClusterName: kube.NormalizeIdentifier(clusterName), } diff --git a/internal/controller/connectionsecret/requestname_extractor.go b/internal/controller/connectionsecret/requestname_extractor.go index a77f249f95..d0d6cb7b73 100644 --- a/internal/controller/connectionsecret/requestname_extractor.go +++ b/internal/controller/connectionsecret/requestname_extractor.go @@ -105,53 +105,55 @@ func CreateInternalFormat(projectID string, clusterName string, databaseUsername // LoadRequestIdentifiers determines whether the request name is internal or K8s format // and extracts ProjectID, ClusterName, and DatabaseUsername. -func LoadRequestIdentifiers(ctx context.Context, c client.Client, req types.NamespacedName) (ConnSecretIdentifiers, error) { - var ids ConnSecretIdentifiers +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: $$ - if strings.Contains(req.Name, InternalSeparator) { - parts := strings.SplitN(req.Name, InternalSeparator, 3) - if len(parts) != 3 { - return ids, ErrInternalFormatPartsInvalid - } - if parts[0] == "" || parts[1] == "" || parts[2] == "" { - return ids, ErrInternalFormatPartEmpty - } - return ConnSecretIdentifiers{ - ProjectID: parts[0], - ClusterName: parts[1], - DatabaseUsername: parts[2], - }, nil + 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 := c.Get(ctx, req, &secret); err != nil { - return ids, err + 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 ids, ErrK8sLabelsMissing + return nil, ErrK8sLabelsMissing } if projectID == "" || clusterName == "" { - return ids, ErrK8sLabelEmpty + return nil, ErrK8sLabelEmpty } - sep := fmt.Sprintf("-%s-", clusterName) parts := strings.SplitN(req.Name, sep, 2) if len(parts) != 2 { - return ids, ErrK8sNameSplitInvalid + return nil, ErrK8sNameSplitInvalid } if parts[0] == "" || parts[1] == "" { - return ids, ErrK8sNameSplitEmpty + return nil, ErrK8sNameSplitEmpty } - - return ConnSecretIdentifiers{ + return &ConnSecretIdentifiers{ ProjectID: projectID, ProjectName: parts[0], ClusterName: clusterName, @@ -161,10 +163,10 @@ func LoadRequestIdentifiers(ctx context.Context, c client.Client, req types.Name // LoadPairedResources fetches the paired AtlasDeployment and AtlasDatabaseUser forming the ConnectionSecret // using the registered indexers -func LoadPairedResources(ctx context.Context, c client.Client, ids ConnSecretIdentifiers, namespace string) (*ConnSecretPair, error) { +func (r *ConnectionSecretReconciler) loadPairedResources(ctx context.Context, ids *ConnSecretIdentifiers) (*ConnSecretPair, error) { compositeDeploymentKey := ids.ProjectID + "-" + ids.ClusterName deployments := &akov2.AtlasDeploymentList{} - if err := c.List(ctx, deployments, &client.ListOptions{ + if err := r.Client.List(ctx, deployments, &client.ListOptions{ FieldSelector: fields.OneTermEqualSelector(indexer.AtlasDeploymentBySpecNameAndProjectID, compositeDeploymentKey), }); err != nil { return nil, err @@ -172,7 +174,7 @@ func LoadPairedResources(ctx context.Context, c client.Client, ids ConnSecretIde compositeUserKey := ids.ProjectID + "-" + ids.DatabaseUsername users := &akov2.AtlasDatabaseUserList{} - if err := c.List(ctx, users, &client.ListOptions{ + if err := r.Client.List(ctx, users, &client.ListOptions{ FieldSelector: fields.OneTermEqualSelector(indexer.AtlasDatabaseUserBySpecUsernameAndProjectID, compositeUserKey), }); err != nil { return nil, err @@ -207,7 +209,7 @@ func LoadPairedResources(ctx context.Context, c client.Client, ids ConnSecretIde } // InvalidScopes checks whether the Deployment and User have a common scope -func (p *ConnSecretPair) InvalidScopes() bool { +func invalidScopes(p *ConnSecretPair) bool { scopes := p.User.GetScopes(akov2.DeploymentScopeType) if len(scopes) != 0 && !stringutil.Contains(scopes, p.Deployment.GetDeploymentName()) { return true @@ -217,17 +219,17 @@ func (p *ConnSecretPair) InvalidScopes() bool { } // IsReady checks that both AtlasDeployment and AtlasDatabaseUser are ready -func (p *ConnSecretPair) IsReady() (bool, []string) { +func isReady(p *ConnSecretPair) (bool, []string) { notReady := []string{} - if p.Deployment == nil || !IsDeploymentReady(p.Deployment) { + 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 || !IsDatabaseUserReady(p.User) { + if p.User == nil || !p.User.IsDatabaseUserReady() { if p.User != nil { notReady = append(notReady, fmt.Sprintf("AtlasDatabaseUser/%s", p.User.GetName())) } else { @@ -238,28 +240,9 @@ func (p *ConnSecretPair) IsReady() (bool, []string) { return len(notReady) == 0, notReady } -// ResolveProjectNameK8s retrieves the ProjectName by K8s AtlasProject resource -func (p *ConnSecretPair) ResolveProjectNameK8s(ctx context.Context, c client.Client, namespace string) (string, error) { - var name string - if p.Deployment != nil && p.Deployment.Spec.ProjectRef != nil { - name = p.Deployment.Spec.ProjectRef.Name - } else if p.User != nil && p.User.Spec.ProjectRef != nil { - name = p.User.Spec.ProjectRef.Name - } else { - return "", errors.New("no ProjectRef available on Deployment or User") - } - - proj := &akov2.AtlasProject{} - if err := c.Get(ctx, kube.ObjectKey(namespace, name), proj); err != nil { - return "", fmt.Errorf("failed to retrieve AtlasProject %q: %w", name, err) - } - - return kube.NormalizeIdentifier(proj.Spec.Name), nil -} - // BuildConnectionData constructs the secret data that will be passed in the secret -func (p *ConnSecretPair) BuildConnectionData(ctx context.Context, c client.Client) (ConnSecretData, error) { - password, err := p.User.ReadPassword(ctx, c) +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) } diff --git a/internal/controller/connectionsecret/requestname_extractor_test.go b/internal/controller/connectionsecret/requestname_extractor_test.go index 279efa7497..04a25beede 100644 --- a/internal/controller/connectionsecret/requestname_extractor_test.go +++ b/internal/controller/connectionsecret/requestname_extractor_test.go @@ -31,10 +31,11 @@ import ( 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 TestCreateK8sFormat(t *testing.T) { +func Test_createK8sFormat(t *testing.T) { tests := map[string]struct { projectName string clusterName string @@ -104,7 +105,7 @@ func TestCreateInternalFormat(t *testing.T) { } } -func TestLoadRequestIdentifiers(t *testing.T) { +func Test_loadRequestIdentifiers(t *testing.T) { scheme := runtime.NewScheme() utilruntime.Must(corev1.AddToScheme(scheme)) @@ -178,6 +179,12 @@ func TestLoadRequestIdentifiers(t *testing.T) { ). Build() + r := &ConnectionSecretReconciler{ + AtlasReconciler: reconciler.AtlasReconciler{ + Client: client, + }, + } + tests := map[string]struct { name string namespace string @@ -239,9 +246,8 @@ func TestLoadRequestIdentifiers(t *testing.T) { for tn, tc := range tests { t.Run(tn, func(t *testing.T) { - ids, err := LoadRequestIdentifiers( + ids, err := r.loadRequestIdentifiers( context.Background(), - client, types.NamespacedName{Name: tc.name, Namespace: tc.namespace}, ) @@ -251,12 +257,12 @@ func TestLoadRequestIdentifiers(t *testing.T) { } assert.NoError(t, err) - assert.Equal(t, tc.expected, ids) + assert.Equal(t, tc.expected, *ids) }) } } -func TestPair_IsReady(t *testing.T) { +func Test_isReady(t *testing.T) { t.Run("Both ready", func(t *testing.T) { p := &ConnSecretPair{ Deployment: &akov2.AtlasDeployment{ @@ -277,7 +283,7 @@ func TestPair_IsReady(t *testing.T) { }, ProjectID: "proj123", } - ok, notReady := p.IsReady() + ok, notReady := isReady(p) assert.True(t, ok) assert.Empty(t, notReady) }) @@ -303,13 +309,13 @@ func TestPair_IsReady(t *testing.T) { }, ProjectID: "proj123", } - ok, notReady := p.IsReady() + ok, notReady := isReady(p) assert.False(t, ok) assert.Equal(t, []string{"AtlasDatabaseUser/user"}, notReady) }) } -func TestPair_LoadPairedResources(t *testing.T) { +func Test_loadPairedResources(t *testing.T) { scheme := runtime.NewScheme() utilruntime.Must(akov2.AddToScheme(scheme)) @@ -417,13 +423,19 @@ func TestPair_LoadPairedResources(t *testing.T) { }). Build() - ids := ConnSecretIdentifiers{ + r := &ConnectionSecretReconciler{ + AtlasReconciler: reconciler.AtlasReconciler{ + Client: cl, + }, + } + + ids := &ConnSecretIdentifiers{ ProjectID: projectID, ClusterName: tt.clusterName, DatabaseUsername: tt.databaseUsername, } - pair, err := LoadPairedResources(context.Background(), cl, ids, ns) + pair, err := r.loadPairedResources(context.Background(), ids) if tt.expectedErr == nil { assert.NoError(t, err) @@ -437,13 +449,13 @@ func TestPair_LoadPairedResources(t *testing.T) { } // When the projectID doesn't match the indexed keys, BOTH resources are missing -> special error. - failIDs := ConnSecretIdentifiers{ + failIDs := &ConnSecretIdentifiers{ ProjectID: otherprojectID, ClusterName: tt.clusterName, DatabaseUsername: tt.databaseUsername, } - failPair, failErr := LoadPairedResources(context.Background(), cl, failIDs, ns) + failPair, failErr := r.loadPairedResources(context.Background(), failIDs) assert.Error(t, failErr) assert.Nil(t, failPair) assert.ErrorIs(t, failErr, ErrNoPairedResourcesFound) @@ -451,7 +463,7 @@ func TestPair_LoadPairedResources(t *testing.T) { } } -func TestPair_BuildConnectionData(t *testing.T) { +func Test_buildConnectionData(t *testing.T) { const ( username = "admin" passwordValue = "p@ssw0rd" @@ -516,13 +528,19 @@ func TestPair_BuildConnectionData(t *testing.T) { WithObjects(secret, user, deployment). Build() - p := &ConnSecretPair{ + r := &ConnectionSecretReconciler{ + AtlasReconciler: reconciler.AtlasReconciler{ + Client: client, + }, + } + + pair := &ConnSecretPair{ Deployment: deployment, User: user, ProjectID: "proj123", } - data, err := p.BuildConnectionData(context.Background(), client) + data, err := r.buildConnectionData(context.Background(), pair) assert.NoError(t, err) assert.Equal(t, username, data.DBUserName) assert.Equal(t, passwordValue, data.Password) diff --git a/internal/controller/connectionsecret/resource_manager.go b/internal/controller/connectionsecret/resource_manager.go deleted file mode 100644 index b985c8bcfe..0000000000 --- a/internal/controller/connectionsecret/resource_manager.go +++ /dev/null @@ -1,71 +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" - - corev1 "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" -) - -func HasReadyCondition(conditions []api.Condition) bool { - for _, c := range conditions { - if c.Type == api.ReadyType && c.Status == corev1.ConditionTrue { - return true - } - } - return false -} - -func IsDeploymentReady(d *akov2.AtlasDeployment) bool { - return HasReadyCondition(d.Status.Conditions) -} - -func IsDatabaseUserReady(u *akov2.AtlasDatabaseUser) bool { - return HasReadyCondition(u.Status.Conditions) -} - -func ResolveProjectIDFromDeployment(ctx context.Context, c client.Client, d *akov2.AtlasDeployment) (string, error) { - if d.Spec.ExternalProjectRef != nil && d.Spec.ExternalProjectRef.ID != "" { - return d.Spec.ExternalProjectRef.ID, nil - } - if d.Spec.ProjectRef != nil && d.Spec.ProjectRef.Name != "" { - project := &akov2.AtlasProject{} - if err := c.Get(ctx, *d.Spec.ProjectRef.GetObject(d.Namespace), 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 ResolveProjectIDFromDatabaseUser(ctx context.Context, c client.Client, u *akov2.AtlasDatabaseUser) (string, error) { - if u.Spec.ExternalProjectRef != nil && u.Spec.ExternalProjectRef.ID != "" { - return u.Spec.ExternalProjectRef.ID, nil - } - if u.Spec.ProjectRef != nil && u.Spec.ProjectRef.Name != "" { - project := &akov2.AtlasProject{} - if err := c.Get(ctx, *u.Spec.ProjectRef.GetObject(u.Namespace), project); err != nil { - return "", fmt.Errorf("failed to resolve projectRef from user: %w", err) - } - return project.ID(), nil - } - return "", fmt.Errorf("missing both external and internal project references") -} diff --git a/internal/controller/registry.go b/internal/controller/registry.go index 77e83e6132..130543683a 100644 --- a/internal/controller/registry.go +++ b/internal/controller/registry.go @@ -129,7 +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, connectionsecret.NewConnectionSecretReconciler(c, r.deprecatedPredicates(), ap, r.logger, r.globalSecretRef)) + reconcilers = append(reconcilers, connectionsecret.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/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 +} From d162ae5b801648fb3fb2f535d7d123fef3bec519 Mon Sep 17 00:00:00 2001 From: andrpac Date: Thu, 14 Aug 2025 09:07:21 +0100 Subject: [PATCH 04/11] fix: introduce generic type --- internal/controller/connsecrets/connsecret.go | 295 ++++++++++++++++++ .../connsecrets/connsecret_controller.go | 228 ++++++++++++++ internal/controller/connsecrets/endpoints.go | 97 ++++++ internal/controller/connsecrets/strategy.go | 171 ++++++++++ 4 files changed, 791 insertions(+) create mode 100644 internal/controller/connsecrets/connsecret.go create mode 100644 internal/controller/connsecrets/connsecret_controller.go create mode 100644 internal/controller/connsecrets/endpoints.go create mode 100644 internal/controller/connsecrets/strategy.go diff --git a/internal/controller/connsecrets/connsecret.go b/internal/controller/connsecrets/connsecret.go new file mode 100644 index 0000000000..7744ea0899 --- /dev/null +++ b/internal/controller/connsecrets/connsecret.go @@ -0,0 +1,295 @@ +package connsecrets + +import ( + "context" + "fmt" + "net/url" + "strings" + + "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/controller/workflow" + "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/kube" + 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" +) + +const ( + 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" +) + +type ConnSecretIdentifiers struct { + ProjectID string + ProjectName string + ClusterName string + DatabaseUsername 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) +} + +func (r *ConnSecretReconciler) LoadIdentifiers(ctx context.Context, req types.NamespacedName) (*ConnSecretIdentifiers, error) { + // === Internal format: $$ + if strings.Contains(req.Name, InternalSeparator) { + parts := strings.Split(req.Name, InternalSeparator) + if len(parts) != 3 { + return nil, fmt.Errorf("internal format expected 3 parts separated by %q", InternalSeparator) + } + if parts[0] == "" || parts[1] == "" || parts[2] == "" { + return nil, fmt.Errorf("internal format got empty value in one or more parts") + } + return &ConnSecretIdentifiers{ + ProjectID: parts[0], + ClusterName: parts[1], + DatabaseUsername: parts[2], + }, nil + } + + // === 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] + if !hasProject || !hasCluster { + return nil, fmt.Errorf("k8s format got a missing required label(s)") + } + if projectID == "" || clusterName == "" { + return nil, fmt.Errorf("k8s format got label present but empty") + } + + sep := fmt.Sprintf("-%s-", clusterName) + parts := strings.SplitN(req.Name, sep, 2) + if len(parts) != 2 { + return nil, fmt.Errorf("k8s format expected to separate across --") + } + if parts[0] == "" || parts[1] == "" { + return nil, fmt.Errorf("k8s format got empty value in one or more parts") + } + + return &ConnSecretIdentifiers{ + ProjectID: projectID, + ProjectName: parts[0], + ClusterName: clusterName, + DatabaseUsername: parts[1], + }, nil +} + +// handleDelete manages the case where we will delete the connection secret +func (r *ConnSecretReconciler) handleDelete( + ctx context.Context, + req ctrl.Request, + ids *ConnSecretIdentifiers, + pair *ConnSecretPair[any], + strategy AnyEndpointStrategy, +) (ctrl.Result, error) { + log := r.Log.With("ns", req.Namespace, "name", req.Name) + + projectName, err := strategy.ResolveProjectName(ctx, 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") + + name := CreateK8sFormat(projectName, ids.ClusterName, ids.DatabaseUsername) + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: req.Namespace, + }, + } + + 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.Infow("secret deleted", "reason", workflow.ConnSecretDeleted) + r.EventRecorder.Event(secret, corev1.EventTypeNormal, "Deleted", "ConnectionSecret deleted") + return workflow.TerminateSilently(nil).WithoutRetry().ReconcileResult() +} + +// handleUpsert manages the case where we will create or update the connection secret +func (r *ConnSecretReconciler) handleUpsert( + ctx context.Context, + req ctrl.Request, + ids *ConnSecretIdentifiers, + pair *ConnSecretPair[any], + strategy AnyEndpointStrategy, +) (ctrl.Result, error) { + log := r.Log.With("ns", req.Namespace, "name", req.Name) + + projectName, err := strategy.ResolveProjectName(ctx, pair) + if projectName == "" { + err = fmt.Errorf("project name is empty") + } + if err != nil { + log.Errorw("failed to resolve project name", "reason", workflow.ConnSecretFailedToResolveProjectName, "error", err) + return workflow.Terminate(workflow.ConnSecretFailedToResolveProjectName, err).ReconcileResult() + } + ids.ProjectName = projectName + log.Debugw("project name resolved for upsert") + + data, err := strategy.BuildConnectionData(ctx, r.Client, 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() +} + +// ensureSecret creates or updates the Secret for the given identifiers and connection data +func (r *ConnSecretReconciler) ensureSecret( + ctx context.Context, + ids *ConnSecretIdentifiers, + pair *ConnSecretPair[any], + 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, + }, + } + + if err := fillConnSecretData(secret, ids, data); err != nil { + log.Errorw("failed to fill secret data", "reason", workflow.ConnSecretFailedToFillData, "error", err) + return err + } + + // OwnerRef is set elsewhere in your flow via controllerutil.SetControllerReference(pair.User, ...) + + 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", "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 + } + } else { + log.Errorw("failed to create secret", "reason", workflow.ConnSecretFailedToCreateSecret, "error", err) + return err + } + } + return nil +} + +// fillConnSecretData populates secret labels and data +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 secrets urls for the data fields +func CreateURL(connURL, username, password string) (string, error) { + if connURL == "" { + return "", nil + } + u, err := url.Parse(connURL) + if err != nil { + return "", err + } + u.User = url.UserPassword(username, password) + return u.String(), nil +} diff --git a/internal/controller/connsecrets/connsecret_controller.go b/internal/controller/connsecrets/connsecret_controller.go new file mode 100644 index 0000000000..7de02311c7 --- /dev/null +++ b/internal/controller/connsecrets/connsecret_controller.go @@ -0,0 +1,228 @@ +package connsecrets + +import ( + "context" + "fmt" + + "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" + "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" +) + +type ConnSecretReconciler struct { + reconciler.AtlasReconciler + Scheme *runtime.Scheme + EventRecorder record.EventRecorder + GlobalPredicates []predicate.Predicate + EndpointStrategies []AnyEndpointStrategy +} + +func (r *ConnSecretReconciler) 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.LoadIdentifiers(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() + } + + var ( + pair *ConnSecretPair[any] + strategy AnyEndpointStrategy + ) + + // We would need to know if we use a Deployment or Federation as Endpoint + for _, s := range r.EndpointStrategies { + p, err := s.LoadPair(ctx, r.Client, ids) + if err == nil { + pair, strategy = p, s + break + } + if err == ErrNoEndpointFound || err == ErrNoPairedResourcesFound { + continue + } + + return ctrl.Result{}, err + } + + expired, err := timeutil.IsExpired(pair.User.Spec.DeleteAfterDate) + if err != nil { + return workflow.Terminate(workflow.ConnSecretCheckExpirationFailed, err).ReconcileResult() + } + if expired { + return r.handleDelete(ctx, req, ids, pair, strategy) + } + + if !strategy.ValidScopes(pair) { + log.Infow("invalid scope; scheduling deletion", "reason", workflow.ConnSecretInvalidScopes) + return r.handleDelete(ctx, req, ids, pair, strategy) + } + + // Checks that AtlasDeployment and AtlasDatabaseUser are ready before proceeding + if ready := strategy.Ready(pair); !ready { + return workflow.InProgress(workflow.ConnSecretNotReady, "not ready").ReconcileResult() + } + + // Create or update the k8s connection secret + log.Infow("creating/updating connection secret", "reason", workflow.ConnSecretUpsert) + return r.handleUpsert(ctx, req, ids, pair, strategy) +} + +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.newDeploymentMapFunc), + builder.WithPredicates(predicate.Or( + watch.ReadyTransitionPredicate(func(d *akov2.AtlasDeployment) 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 (r *ConnSecretReconciler) 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 + } + name := CreateInternalFormat(projectID, d.GetDeploymentName(), u.Spec.Username) + requests = append(requests, reconcile.Request{ + NamespacedName: types.NamespacedName{Namespace: u.Namespace, Name: name}, + }) + } + } + return requests +} + +func (r *ConnSecretReconciler) 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: %w", err) + } + return project.ID(), nil + } + return "", fmt.Errorf("missing both external and internal project references") +} + +func (r *ConnSecretReconciler) newDeploymentMapFunc(ctx context.Context, obj client.Object) []reconcile.Request { + d, ok := obj.(*akov2.AtlasDeployment) + if !ok { + return nil + } + projectID, err := r.ResolveProjectId(ctx, d.Spec.ProjectDualReference, d.GetNamespace()) + 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, []akov2.AtlasDeployment{*d}, users.Items) +} + +func (r *ConnSecretReconciler) newDatabaseUserMapFunc(ctx context.Context, obj client.Object) []reconcile.Request { + u, ok := obj.(*akov2.AtlasDatabaseUser) + if !ok { + return nil + } + projectID, err := r.ResolveProjectId(ctx, u.Spec.ProjectDualReference, u.GetNamespace()) + if err != nil || projectID == "" { + return nil + } + deps := &akov2.AtlasDeploymentList{} + if err := r.Client.List(ctx, deps, &client.ListOptions{ + FieldSelector: fields.OneTermEqualSelector(indexer.AtlasDeploymentByProject, projectID), + }); err != nil { + return nil + } + return r.generateConnectionSecretRequests(projectID, deps.Items, []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, + } + + r.EndpointStrategies = []AnyEndpointStrategy{ + NewAnyEndpointStrategy(r.NewDeploymentEndpoint()), + // NewAnyEndpointStrategy(df), + } + + return r +} diff --git a/internal/controller/connsecrets/endpoints.go b/internal/controller/connsecrets/endpoints.go new file mode 100644 index 0000000000..8f3164d866 --- /dev/null +++ b/internal/controller/connsecrets/endpoints.go @@ -0,0 +1,97 @@ +package connsecrets + +import ( + "context" + "fmt" + + "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/indexer" + "k8s.io/apimachinery/pkg/fields" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +type EndpointStrategy[T any] struct { + List client.ObjectList + Selector func(ids *ConnSecretIdentifiers) fields.Selector + ExtractList func(client.ObjectList) ([]T, error) + + GetName func(obj T) string + IsReady func(obj T) bool + GetConnStrings func(obj T) *status.ConnectionStrings + GetProjectID func(ctx context.Context, obj T) string + GetProjectName func(ctx context.Context, obj T) string +} + +// NewDeploymentEndpoint returns the EndpointStrategy for AtlasDeployment. +func (r *ConnSecretReconciler) NewDeploymentEndpoint() EndpointStrategy[*akov2.AtlasDeployment] { + return EndpointStrategy[*akov2.AtlasDeployment]{ + List: &akov2.AtlasDeploymentList{}, + Selector: func(ids *ConnSecretIdentifiers) fields.Selector { + return fields.OneTermEqualSelector( + indexer.AtlasDeploymentBySpecNameAndProjectID, + ids.ProjectID+"-"+ids.ClusterName, + ) + }, + ExtractList: func(ol client.ObjectList) ([]*akov2.AtlasDeployment, error) { + l, ok := ol.(*akov2.AtlasDeploymentList) + if !ok { + return nil, fmt.Errorf("unexpected list type %T", ol) + } + out := make([]*akov2.AtlasDeployment, 0, len(l.Items)) + for i := range l.Items { + out = append(out, &l.Items[i]) + } + return out, nil + }, + GetName: func(dpl *akov2.AtlasDeployment) string { + return dpl.GetDeploymentName() + }, + IsReady: func(dpl *akov2.AtlasDeployment) bool { + return api.HasReadyCondition(dpl.Status.Conditions) + }, + GetConnStrings: func(dpl *akov2.AtlasDeployment) *status.ConnectionStrings { + return dpl.Status.ConnectionStrings + }, + GetProjectID: func(ctx context.Context, dpl *akov2.AtlasDeployment) string { + if dpl.Spec.ExternalProjectRef != nil && dpl.Spec.ExternalProjectRef.ID != "" { + return dpl.Spec.ExternalProjectRef.ID + } + if dpl.Spec.ProjectRef != nil && dpl.Spec.ProjectRef.Name != "" { + ns := dpl.Spec.ProjectRef.Namespace + var proj akov2.AtlasProject + if err := r.Client.Get(ctx, types.NamespacedName{Namespace: ns, Name: dpl.Spec.ProjectRef.Name}, &proj); err == nil { + return proj.ID() + } + } + return "" + }, + GetProjectName: func(ctx context.Context, dpl *akov2.AtlasDeployment) string { + // Prefer K8s project name when ProjectRef is present + if dpl.Spec.ProjectRef != nil && dpl.Spec.ProjectRef.Name != "" { + ns := dpl.Spec.ProjectRef.Namespace + var proj akov2.AtlasProject + if err := r.Client.Get(ctx, types.NamespacedName{Namespace: ns, Name: dpl.Spec.ProjectRef.Name}, &proj); err == nil && proj.Spec.Name != "" { + return proj.Spec.Name + } + } + + // SDK fallback + connCfg, err := r.ResolveConnectionConfig(ctx, dpl) + if err != nil { + return "" + } + sdkClientSet, err := r.AtlasProvider.SdkClientSet(ctx, connCfg.Credentials, r.Log) + if err != nil { + return "" + } + ap, err := r.ResolveProject(ctx, sdkClientSet.SdkClient20250312002, dpl) + if err != nil { + return "" + } + return ap.Name + }, + } +} diff --git a/internal/controller/connsecrets/strategy.go b/internal/controller/connsecrets/strategy.go new file mode 100644 index 0000000000..7ee66e4762 --- /dev/null +++ b/internal/controller/connsecrets/strategy.go @@ -0,0 +1,171 @@ +package connsecrets + +import ( + "context" + "errors" + "fmt" + + "k8s.io/apimachinery/pkg/fields" + "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/stringutil" +) + +const InternalSeparator = "$" + +var ( + ErrNoPairedResourcesFound = errors.New("no endpoint and no AtlasDatabaseUser found") + ErrNoEndpointFound = errors.New("no endpoint found") + ErrManyEndpoints = errors.New("multiple endpoints found") + ErrNoUserFound = errors.New("no AtlasDatabaseUser found") + ErrManyUsers = errors.New("multiple AtlasDatabaseUsers found") +) + +type AnyEndpointStrategy interface { + LoadPair(ctx context.Context, c client.Client, ids *ConnSecretIdentifiers) (*ConnSecretPair[any], error) + Ready(p *ConnSecretPair[any]) bool + ValidScopes(p *ConnSecretPair[any]) bool + BuildConnectionData(ctx context.Context, c client.Client, p *ConnSecretPair[any]) (ConnSecretData, error) + ResolveProjectName(ctx context.Context, p *ConnSecretPair[any]) (string, error) +} + +type anyEndpointStrategy[T any] struct { + EndpointStrategy[T] +} + +type ConnSecretPair[T any] struct { + ProjectID string + User *akov2.AtlasDatabaseUser + Endpoint T +} + +type ConnSecretData struct { + DBUserName string + Password string + ConnURL string + SrvConnURL string + PrivateConnURLs []PrivateLinkConnURLs +} + +type PrivateLinkConnURLs struct { + PvtConnURL string + PvtSrvConnURL string + PvtShardConnURL string +} + +func NewAnyEndpointStrategy[T any](s EndpointStrategy[T]) AnyEndpointStrategy { + return &anyEndpointStrategy[T]{s} +} + +func (w *anyEndpointStrategy[T]) LoadPair(ctx context.Context, c client.Client, ids *ConnSecretIdentifiers) (*ConnSecretPair[any], error) { + if err := c.List(ctx, w.List, &client.ListOptions{FieldSelector: w.Selector(ids)}); err != nil { + return nil, err + } + eps, err := w.ExtractList(w.List) + if err != nil { + return nil, err + } + + users := &akov2.AtlasDatabaseUserList{} + userSel := fields.OneTermEqualSelector(indexer.AtlasDatabaseUserBySpecUsernameAndProjectID, ids.ProjectID+"-"+ids.DatabaseUsername) + if err := c.List(ctx, users, &client.ListOptions{FieldSelector: userSel}); err != nil { + return nil, err + } + + switch { + case len(eps) == 0 && len(users.Items) == 0: + return nil, ErrNoPairedResourcesFound + case len(eps) == 0: + return &ConnSecretPair[any]{ProjectID: ids.ProjectID, User: &users.Items[0], Endpoint: nil}, ErrNoEndpointFound + case len(users.Items) == 0: + return &ConnSecretPair[any]{ProjectID: ids.ProjectID, User: nil, Endpoint: eps[0]}, ErrNoUserFound + case len(eps) > 1: + return nil, ErrManyEndpoints + case len(users.Items) > 1: + return nil, ErrManyUsers + } + + return &ConnSecretPair[any]{ProjectID: ids.ProjectID, User: &users.Items[0], Endpoint: eps[0]}, nil +} + +func (w *anyEndpointStrategy[T]) ValidScopes(p *ConnSecretPair[any]) bool { + if p == nil || p.User == nil { + return false + } + scopes := p.User.GetScopes(akov2.DeploymentScopeType) + if len(scopes) == 0 { + return true + } + t, ok := p.Endpoint.(T) + if !ok || p.Endpoint == nil { + return false + } + name := w.GetName(t) + if name == "" { + return false + } + return stringutil.Contains(scopes, name) +} + +func (w *anyEndpointStrategy[T]) Ready(p *ConnSecretPair[any]) bool { + if p == nil || p.User == nil || !p.User.IsDatabaseUserReady() { + return false + } + t, ok := p.Endpoint.(T) + if !ok || p.Endpoint == nil { + return false + } + return w.IsReady(t) +} + +func (w *anyEndpointStrategy[T]) BuildConnectionData(ctx context.Context, c client.Client, p *ConnSecretPair[any]) (ConnSecretData, error) { + if p == nil || p.User == nil || p.Endpoint == nil { + return ConnSecretData{}, fmt.Errorf("invalid pair: nil user or endpoint") + } + + password, err := p.User.ReadPassword(ctx, c) + if err != nil { + return ConnSecretData{}, fmt.Errorf("failed to read password for user %q: %w", p.User.Spec.Username, err) + } + + t, ok := p.Endpoint.(T) + if !ok { + return ConnSecretData{}, fmt.Errorf("unexpected endpoint type") + } + + conn := w.GetConnStrings(t) + + 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 +} + +func (a *anyEndpointStrategy[T]) ResolveProjectName(ctx context.Context, p *ConnSecretPair[any]) (string, error) { + t, ok := p.Endpoint.(T) + if !ok || p.Endpoint == nil { + return "", fmt.Errorf("unexpected endpoint type") + } + return a.GetProjectName(ctx, t), nil +} From 5bde4efdb1bb54971c240e01e2e9ed47f2db167d Mon Sep 17 00:00:00 2001 From: andrpac Date: Thu, 14 Aug 2025 11:58:09 +0100 Subject: [PATCH 05/11] fix: generic type for deployment and federation --- .../connsecrets-generic/connectionsecret.go | 424 +++++++++++++ .../connectionsecret_controller.go | 279 +++++++++ .../endpoint_deployment.go | 111 ++++ .../endpoint_federation.go | 108 ++++ internal/controller/connsecrets/connsecret.go | 587 +++++++++--------- .../connsecrets/connsecret_controller.go | 453 +++++++------- internal/controller/connsecrets/endpoints.go | 181 +++--- internal/controller/connsecrets/strategy.go | 338 +++++----- internal/controller/registry.go | 4 +- 9 files changed, 1705 insertions(+), 780 deletions(-) create mode 100644 internal/controller/connsecrets-generic/connectionsecret.go create mode 100644 internal/controller/connsecrets-generic/connectionsecret_controller.go create mode 100644 internal/controller/connsecrets-generic/endpoint_deployment.go create mode 100644 internal/controller/connsecrets-generic/endpoint_federation.go diff --git a/internal/controller/connsecrets-generic/connectionsecret.go b/internal/controller/connsecrets-generic/connectionsecret.go new file mode 100644 index 0000000000..a36cf2e8af --- /dev/null +++ b/internal/controller/connsecrets-generic/connectionsecret.go @@ -0,0 +1,424 @@ +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" + "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/stringutil" +) + +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 ( + ErrNoPairedResourcesFound = errors.New("no paired resources found") + ErrNoEndpointFound = errors.New("no endpoint found") + ErrNoUserFound = errors.New("no user found") + ErrManyEndpoints = errors.New("multiple endpoints found") + ErrManyUsers = errors.New("multiple users found") +) + +type ConnSecretIdentifiers struct { + ProjectID string + ProjectName string + ClusterName string + DatabaseUsername string +} + +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) +} + +func (r *ConnSecretReconciler) LoadIdentifiers(ctx context.Context, req types.NamespacedName) (*ConnSecretIdentifiers, error) { + // === Internal format: $$ + if strings.Contains(req.Name, InternalSeparator) { + parts := strings.Split(req.Name, InternalSeparator) + if len(parts) != 3 { + return nil, fmt.Errorf("internal format expected 3 parts separated by %q", InternalSeparator) + } + if parts[0] == "" || parts[1] == "" || parts[2] == "" { + return nil, fmt.Errorf("internal format got empty value in one or more parts") + } + return &ConnSecretIdentifiers{ + ProjectID: parts[0], + ClusterName: parts[1], + DatabaseUsername: parts[2], + }, nil + } + + // === 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] + if !hasProject || !hasCluster { + return nil, fmt.Errorf("k8s format got a missing required label(s)") + } + if projectID == "" || clusterName == "" { + return nil, fmt.Errorf("k8s format got label present but empty") + } + + sep := fmt.Sprintf("-%s-", clusterName) + parts := strings.SplitN(req.Name, sep, 2) + if len(parts) != 2 { + return nil, fmt.Errorf("k8s format expected to separate across -%s-", clusterName) + } + if parts[0] == "" || parts[1] == "" { + return nil, fmt.Errorf("k8s format got empty value in one or more parts") + } + + return &ConnSecretIdentifiers{ + ProjectID: projectID, + ProjectName: parts[0], + ClusterName: clusterName, + DatabaseUsername: parts[1], + }, nil +} + +func (r *ConnSecretReconciler) LoadPair(ctx context.Context, ids *ConnSecretIdentifiers) (*ConnSecretPair, error) { + 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 + } + + totalEndpoints := 0 + var selected Endpoint + for _, kind := range r.EndpointKinds { + list := kind.ListObj() + if err := r.Client.List(ctx, list, &client.ListOptions{FieldSelector: kind.Selector(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) + } + + switch { + case totalEndpoints > 1: + return nil, ErrManyEndpoints + case len(users.Items) > 1: + return nil, ErrManyUsers + case totalEndpoints == 0 && len(users.Items) == 0: + return nil, ErrNoPairedResourcesFound + case totalEndpoints == 0: + return &ConnSecretPair{ + ProjectID: ids.ProjectID, + User: &users.Items[0], + Endpoint: nil, + }, ErrNoEndpointFound + case len(users.Items) == 0: + return &ConnSecretPair{ + ProjectID: ids.ProjectID, + User: nil, + Endpoint: selected, + }, ErrNoUserFound + } + + return &ConnSecretPair{ + ProjectID: ids.ProjectID, + User: &users.Items[0], + Endpoint: selected, + }, nil +} + +// handleDelete manages the case where we will delete the connection secret +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) + + projectName, err := pair.Endpoint.GetProjectName(ctx, r.Client, r.AtlasProvider, r.Log) + 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") + + name := CreateK8sFormat(projectName, ids.ClusterName, ids.DatabaseUsername) + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: req.Namespace, + }, + } + + 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.Infow("secret deleted", "reason", workflow.ConnSecretDeleted) + r.EventRecorder.Event(secret, corev1.EventTypeNormal, "Deleted", "ConnectionSecret deleted") + return workflow.TerminateSilently(nil).WithoutRetry().ReconcileResult() +} + +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) + + projectName, err := pair.Endpoint.GetProjectName(ctx, r.Client, r.AtlasProvider, r.Log) + if projectName == "" { + err = fmt.Errorf("project name is empty") + } + if err != nil { + log.Errorw("failed to resolve project name", "reason", workflow.ConnSecretFailedToResolveProjectName, "error", err) + return workflow.Terminate(workflow.ConnSecretFailedToResolveProjectName, err).ReconcileResult() + } + ids.ProjectName = projectName + log.Debugw("project name resolved for upsert") + + 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() +} + +// 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, + }, + } + + if err := fillConnSecretData(secret, ids, data); err != nil { + log.Errorw("failed to fill secret data", "reason", workflow.ConnSecretFailedToFillData, "error", err) + return err + } + + 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 + } + + 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", "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 + } + } else { + log.Errorw("failed to create secret", "reason", workflow.ConnSecretFailedToCreateSecret, "error", err) + return err + } + } + return nil +} + +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 +} + +func CreateURL(connURL, username, password string) (string, error) { + if connURL == "" { + return "", nil + } + u, err := url.Parse(connURL) + if err != nil { + return "", err + } + u.User = url.UserPassword(username, password) + return u.String(), nil +} + +func (r *ConnSecretReconciler) buildConnectionData(ctx context.Context, p *ConnSecretPair) (ConnSecretData, error) { + if p == nil || p.User == nil || p.Endpoint == nil { + return ConnSecretData{}, fmt.Errorf("invalid pair: nil user or endpoint") + } + + 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) + } + + data := ConnSecretData{ + DBUserName: p.User.Spec.Username, + Password: password, + } + + if conn := p.Endpoint.GetConnStrings(); conn != nil { + 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 +} + +func allowsByScopes(u *akov2.AtlasDatabaseUser, epName string) bool { + scopes := u.GetScopes(akov2.DeploymentScopeType) + if len(scopes) == 0 || stringutil.Contains(scopes, epName) { + return true + } + + return false +} diff --git a/internal/controller/connsecrets-generic/connectionsecret_controller.go b/internal/controller/connsecrets-generic/connectionsecret_controller.go new file mode 100644 index 0000000000..1b71d4250f --- /dev/null +++ b/internal/controller/connsecrets-generic/connectionsecret_controller.go @@ -0,0 +1,279 @@ +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/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/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/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 // Register all kinds of endpoints +} + +type Endpoint interface { + GetName() string + IsReady() bool + GetConnStrings() *status.ConnectionStrings + GetProjectID(ctx context.Context, r client.Reader) (string, error) + GetProjectName(ctx context.Context, r client.Reader, provider atlas.Provider, log *zap.SugaredLogger) (string, error) + + ListObj() client.ObjectList + Selector(ids *ConnSecretIdentifiers) fields.Selector + ExtractList(client.ObjectList) ([]Endpoint, error) +} + +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("ns", req.Namespace, "name", req.Name) + log.Debugw("reconcile started") + + ids, err := r.LoadIdentifiers(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("", err).ReconcileResult() + } + + pair, err := r.LoadPair(ctx, ids) + if err != nil { + switch { + case errors.Is(err, ErrNoPairedResourcesFound): + return workflow.TerminateSilently(nil).WithoutRetry().ReconcileResult() + case errors.Is(err, ErrNoEndpointFound), errors.Is(err, ErrNoUserFound): + return r.handleDelete(ctx, req, ids, pair) + case errors.Is(err, ErrManyEndpoints), errors.Is(err, ErrManyUsers): + return workflow.Terminate("", err).ReconcileResult() + default: + return workflow.Terminate("", err).ReconcileResult() + } + } + + expired, err := timeutil.IsExpired(pair.User.Spec.DeleteAfterDate) + if err != nil { + return workflow.Terminate(workflow.ConnSecretCheckExpirationFailed, err).ReconcileResult() + } + if expired { + if pair.Endpoint == nil { + return workflow.TerminateSilently(nil).WithoutRetry().ReconcileResult() + } + return r.handleDelete(ctx, req, ids, pair) + } + + if pair.Endpoint == nil { + return r.handleDelete(ctx, req, ids, pair) + } + + if !allowsByScopes(pair.User, pair.Endpoint.GetName()) { + r.Log.Infow("invalid scope; scheduling deletion", "reason", workflow.ConnSecretInvalidScopes) + return r.handleDelete(ctx, req, ids, pair) + } + + if !(pair.User.IsDatabaseUserReady() && pair.Endpoint.IsReady()) { + return workflow.InProgress(workflow.ConnSecretNotReady, "not ready").ReconcileResult() + } + + r.Log.Infow("creating/updating connection secret", "reason", workflow.ConnSecretUpsert) + 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.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 (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()) { + continue + } + name := CreateInternalFormat(projectID, ep.GetName(), u.Spec.Username) + reqs = append(reqs, reconcile.Request{ + NamespacedName: types.NamespacedName{Namespace: u.Namespace, Name: name}, + }) + } + } + return reqs +} + +func (r *ConnSecretReconciler) 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: %w", err) + } + return project.ID(), nil + } + return "", fmt.Errorf("missing both external and internal project references") +} + +func (r *ConnSecretReconciler) listEndpointsByProject(ctx context.Context, projectID string) ([]Endpoint, error) { + var out []Endpoint + for _, kind := range r.EndpointKinds { + list := kind.ListObj() + switch kind.(type) { + case DeploymentEndpoint: + if err := r.Client.List(ctx, list, &client.ListOptions{ + FieldSelector: fields.OneTermEqualSelector(indexer.AtlasDeploymentByProject, projectID), + }); err != nil { + return nil, err + } + // case FederationEndpoint: + // if err := r.Client.List(ctx, list, &client.ListOptions{ + // FieldSelector: fields.OneTermEqualSelector(indexer.AtlasDataFederationByProject, projectID), + // }); err != nil { + // return nil, err + // } + default: + continue + } + eps, err := kind.ExtractList(list) + if err != nil { + return nil, err + } + out = append(out, eps...) + } + return out, nil +} + +func (r *ConnSecretReconciler) newEndpointMapFunc(ctx context.Context, obj client.Object) []reconcile.Request { + var ep 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, r.Client) + 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) +} + +func (r *ConnSecretReconciler) newDatabaseUserMapFunc(ctx context.Context, obj client.Object) []reconcile.Request { + u, ok := obj.(*akov2.AtlasDatabaseUser) + if !ok { + return nil + } + projectID, err := r.ResolveProjectId(ctx, u.Spec.ProjectDualReference, u.GetNamespace()) + if err != nil || projectID == "" { + return nil + } + endpoints, err := r.listEndpointsByProject(ctx, 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 kinds to try (order matters) + r.EndpointKinds = []Endpoint{ + DeploymentEndpoint{r: r}, // obj=nil; used for discovery + // FederationEndpoint{r: r}, // obj=nil; used for discovery + } + + return r +} diff --git a/internal/controller/connsecrets-generic/endpoint_deployment.go b/internal/controller/connsecrets-generic/endpoint_deployment.go new file mode 100644 index 0000000000..0f584a5897 --- /dev/null +++ b/internal/controller/connsecrets-generic/endpoint_deployment.go @@ -0,0 +1,111 @@ +package connsecretsgeneric + +import ( + "context" + "fmt" + + "go.uber.org/zap" + "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/status" + "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/controller/atlas" + "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 +} + +// ---- instance methods ---- +func (e DeploymentEndpoint) GetName() string { + if e.obj == nil { + return "" + } + return e.obj.GetDeploymentName() +} + +func (e DeploymentEndpoint) IsReady() bool { + return e.obj != nil && api.HasReadyCondition(e.obj.Status.Conditions) +} + +func (e DeploymentEndpoint) GetConnStrings() *status.ConnectionStrings { + if e.obj == nil { + return nil + } + return e.obj.Status.ConnectionStrings +} + +func (e DeploymentEndpoint) GetProjectID(ctx context.Context, r client.Reader) (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 != "" { + proj := &akov2.AtlasProject{} + if err := r.Get(ctx, kube.ObjectKey(e.obj.Namespace, e.obj.Spec.ProjectRef.Name), proj); err != nil { + return "", err + } + return proj.ID(), nil + } + + return "", fmt.Errorf("project ID not available") +} + +func (e DeploymentEndpoint) GetProjectName(ctx context.Context, r client.Reader, provider atlas.Provider, log *zap.SugaredLogger) (string, error) { + if e.obj == nil { + return "", fmt.Errorf("nil deployment") + } + if e.obj.Spec.ProjectRef != nil && e.obj.Spec.ProjectRef.Name != "" { + proj := &akov2.AtlasProject{} + if err := r.Get(ctx, kube.ObjectKey(e.obj.Namespace, e.obj.Spec.ProjectRef.Name), proj); err != nil { + return "", err + } + if proj.Spec.Name != "" { + return kube.NormalizeIdentifier(proj.Spec.Name), nil + } + } + // SDK fallback (optional) + 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, 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 "", fmt.Errorf("project name not available") +} + +// ---- indexer methods (ignore e.obj) ---- +func (DeploymentEndpoint) ListObj() client.ObjectList { return &akov2.AtlasDeploymentList{} } + +func (DeploymentEndpoint) Selector(ids *ConnSecretIdentifiers) fields.Selector { + return fields.OneTermEqualSelector(indexer.AtlasDeploymentBySpecNameAndProjectID, ids.ProjectID+"-"+ids.ClusterName) +} + +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 { + // wrap each item as an Endpoint object + out = append(out, DeploymentEndpoint{obj: &l.Items[i], r: e.r}) + } + return out, nil +} diff --git a/internal/controller/connsecrets-generic/endpoint_federation.go b/internal/controller/connsecrets-generic/endpoint_federation.go new file mode 100644 index 0000000000..3c9ee5ba52 --- /dev/null +++ b/internal/controller/connsecrets-generic/endpoint_federation.go @@ -0,0 +1,108 @@ +package connsecretsgeneric + +// import ( +// "context" +// "fmt" + +// "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/atlas" +// "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/indexer" +// "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/kube" +// "go.uber.org/zap" +// "k8s.io/apimachinery/pkg/fields" +// "sigs.k8s.io/controller-runtime/pkg/client" +// ) + +// type FederationEndpoint struct { +// obj *akov2.AtlasDataFederation +// r *ConnSecretReconciler +// } + +// // ---- instance methods ---- +// func (e FederationEndpoint) GetName() string { +// if e.obj == nil { +// return "" +// } +// return e.obj.GetDFName() // adjust if your CR has a different getter +// } + +// func (e FederationEndpoint) IsReady() bool { +// return e.obj != nil && api.HasReadyCondition(e.obj.Status.Conditions) +// } + +// func (e FederationEndpoint) GetConnStrings() *status.ConnectionStrings { +// if e.obj == nil { +// return nil +// } +// return e.obj.Status.ConnectionStrings // or nil if federation doesn’t expose this +// } + +// func (e FederationEndpoint) GetProjectID(ctx context.Context, r client.Reader) (string, error) { +// if e.obj == nil { +// return "", fmt.Errorf("nil federation") +// } +// if e.obj.Spec.ProjectRef != nil && e.obj.Spec.ProjectRef.Name != "" { +// proj := &akov2.AtlasProject{} +// if err := r.Get(ctx, kube.ObjectKey(e.obj.Namespace, e.obj.Spec.ProjectRef.Name), proj); err != nil { +// return "", err +// } +// return proj.ID(), nil +// } +// if id := e.obj.Status.ProjectID; id != "" { +// return id, nil +// } +// return "", fmt.Errorf("project ID not available") +// } + +// func (e FederationEndpoint) GetProjectName(ctx context.Context, r client.Reader, provider atlas.Provider, log *zap.SugaredLogger) (string, error) { +// if e.obj == nil { +// return "", fmt.Errorf("nil federation") +// } +// if e.obj.Spec.ProjectRef != nil && e.obj.Spec.ProjectRef.Name != "" { +// proj := &akov2.AtlasProject{} +// if err := r.Get(ctx, kube.ObjectKey(e.obj.Namespace, e.obj.Spec.ProjectRef.Name), proj); err != nil { +// return "", err +// } +// if proj.Spec.Name != "" { +// return kube.NormalizeIdentifier(proj.Spec.Name), nil +// } +// } +// // SDK fallback (optional) +// 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, 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 "", fmt.Errorf("project name not available") +// } + +// // ---- indexer methods ---- +// func (FederationEndpoint) ListObj() client.ObjectList { return &akov2.AtlasDataFederationList{} } + +// func (FederationEndpoint) Selector(ids *ConnSecretIdentifiers) fields.Selector { +// return fields.OneTermEqualSelector(indexer.AtlasDataFederationBySpecNameAndProjectID, ids.ProjectID+"-"+ids.ClusterName) +// } + +// 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 +// } diff --git a/internal/controller/connsecrets/connsecret.go b/internal/controller/connsecrets/connsecret.go index 7744ea0899..34f943dbf0 100644 --- a/internal/controller/connsecrets/connsecret.go +++ b/internal/controller/connsecrets/connsecret.go @@ -1,295 +1,296 @@ package connsecrets -import ( - "context" - "fmt" - "net/url" - "strings" - - "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/controller/workflow" - "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/kube" - 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" -) - -const ( - 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" -) - -type ConnSecretIdentifiers struct { - ProjectID string - ProjectName string - ClusterName string - DatabaseUsername 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) -} - -func (r *ConnSecretReconciler) LoadIdentifiers(ctx context.Context, req types.NamespacedName) (*ConnSecretIdentifiers, error) { - // === Internal format: $$ - if strings.Contains(req.Name, InternalSeparator) { - parts := strings.Split(req.Name, InternalSeparator) - if len(parts) != 3 { - return nil, fmt.Errorf("internal format expected 3 parts separated by %q", InternalSeparator) - } - if parts[0] == "" || parts[1] == "" || parts[2] == "" { - return nil, fmt.Errorf("internal format got empty value in one or more parts") - } - return &ConnSecretIdentifiers{ - ProjectID: parts[0], - ClusterName: parts[1], - DatabaseUsername: parts[2], - }, nil - } - - // === 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] - if !hasProject || !hasCluster { - return nil, fmt.Errorf("k8s format got a missing required label(s)") - } - if projectID == "" || clusterName == "" { - return nil, fmt.Errorf("k8s format got label present but empty") - } - - sep := fmt.Sprintf("-%s-", clusterName) - parts := strings.SplitN(req.Name, sep, 2) - if len(parts) != 2 { - return nil, fmt.Errorf("k8s format expected to separate across --") - } - if parts[0] == "" || parts[1] == "" { - return nil, fmt.Errorf("k8s format got empty value in one or more parts") - } - - return &ConnSecretIdentifiers{ - ProjectID: projectID, - ProjectName: parts[0], - ClusterName: clusterName, - DatabaseUsername: parts[1], - }, nil -} - -// handleDelete manages the case where we will delete the connection secret -func (r *ConnSecretReconciler) handleDelete( - ctx context.Context, - req ctrl.Request, - ids *ConnSecretIdentifiers, - pair *ConnSecretPair[any], - strategy AnyEndpointStrategy, -) (ctrl.Result, error) { - log := r.Log.With("ns", req.Namespace, "name", req.Name) - - projectName, err := strategy.ResolveProjectName(ctx, 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") - - name := CreateK8sFormat(projectName, ids.ClusterName, ids.DatabaseUsername) - secret := &corev1.Secret{ - ObjectMeta: metav1.ObjectMeta{ - Name: name, - Namespace: req.Namespace, - }, - } - - 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.Infow("secret deleted", "reason", workflow.ConnSecretDeleted) - r.EventRecorder.Event(secret, corev1.EventTypeNormal, "Deleted", "ConnectionSecret deleted") - return workflow.TerminateSilently(nil).WithoutRetry().ReconcileResult() -} - -// handleUpsert manages the case where we will create or update the connection secret -func (r *ConnSecretReconciler) handleUpsert( - ctx context.Context, - req ctrl.Request, - ids *ConnSecretIdentifiers, - pair *ConnSecretPair[any], - strategy AnyEndpointStrategy, -) (ctrl.Result, error) { - log := r.Log.With("ns", req.Namespace, "name", req.Name) - - projectName, err := strategy.ResolveProjectName(ctx, pair) - if projectName == "" { - err = fmt.Errorf("project name is empty") - } - if err != nil { - log.Errorw("failed to resolve project name", "reason", workflow.ConnSecretFailedToResolveProjectName, "error", err) - return workflow.Terminate(workflow.ConnSecretFailedToResolveProjectName, err).ReconcileResult() - } - ids.ProjectName = projectName - log.Debugw("project name resolved for upsert") - - data, err := strategy.BuildConnectionData(ctx, r.Client, 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() -} - -// ensureSecret creates or updates the Secret for the given identifiers and connection data -func (r *ConnSecretReconciler) ensureSecret( - ctx context.Context, - ids *ConnSecretIdentifiers, - pair *ConnSecretPair[any], - 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, - }, - } - - if err := fillConnSecretData(secret, ids, data); err != nil { - log.Errorw("failed to fill secret data", "reason", workflow.ConnSecretFailedToFillData, "error", err) - return err - } - - // OwnerRef is set elsewhere in your flow via controllerutil.SetControllerReference(pair.User, ...) - - 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", "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 - } - } else { - log.Errorw("failed to create secret", "reason", workflow.ConnSecretFailedToCreateSecret, "error", err) - return err - } - } - return nil -} - -// fillConnSecretData populates secret labels and data -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 secrets urls for the data fields -func CreateURL(connURL, username, password string) (string, error) { - if connURL == "" { - return "", nil - } - u, err := url.Parse(connURL) - if err != nil { - return "", err - } - u.User = url.UserPassword(username, password) - return u.String(), nil -} +// import ( +// "context" +// "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/types" +// ctrl "sigs.k8s.io/controller-runtime" +// "sigs.k8s.io/controller-runtime/pkg/client" + +// "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/controller/workflow" +// "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" + +// userNameKey string = "username" +// passwordKey string = "password" +// standardKey string = "connectionStringStandard" +// standardKeySrv string = "connectionStringStandardSrv" +// privateKey string = "connectionStringPrivate" +// privateSrvKey string = "connectionStringPrivateSrv" +// privateShardKey string = "connectionStringPrivateShard" +// ) + +// type ConnSecretIdentifiers struct { +// ProjectID string +// ProjectName string +// ClusterName string +// DatabaseUsername 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) +// } + +// func (r *ConnSecretReconciler) LoadIdentifiers(ctx context.Context, req types.NamespacedName) (*ConnSecretIdentifiers, error) { +// // === Internal format: $$ +// if strings.Contains(req.Name, InternalSeparator) { +// parts := strings.Split(req.Name, InternalSeparator) +// if len(parts) != 3 { +// return nil, fmt.Errorf("internal format expected 3 parts separated by %q", InternalSeparator) +// } +// if parts[0] == "" || parts[1] == "" || parts[2] == "" { +// return nil, fmt.Errorf("internal format got empty value in one or more parts") +// } +// return &ConnSecretIdentifiers{ +// ProjectID: parts[0], +// ClusterName: parts[1], +// DatabaseUsername: parts[2], +// }, nil +// } + +// // === 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] +// if !hasProject || !hasCluster { +// return nil, fmt.Errorf("k8s format got a missing required label(s)") +// } +// if projectID == "" || clusterName == "" { +// return nil, fmt.Errorf("k8s format got label present but empty") +// } + +// sep := fmt.Sprintf("-%s-", clusterName) +// parts := strings.SplitN(req.Name, sep, 2) +// if len(parts) != 2 { +// return nil, fmt.Errorf("k8s format expected to separate across --") +// } +// if parts[0] == "" || parts[1] == "" { +// return nil, fmt.Errorf("k8s format got empty value in one or more parts") +// } + +// return &ConnSecretIdentifiers{ +// ProjectID: projectID, +// ProjectName: parts[0], +// ClusterName: clusterName, +// DatabaseUsername: parts[1], +// }, nil +// } + +// // handleDelete manages the case where we will delete the connection secret +// func (r *ConnSecretReconciler) handleDelete( +// ctx context.Context, +// req ctrl.Request, +// ids *ConnSecretIdentifiers, +// pair *ConnSecretPair[any], +// strategy AnyEndpointStrategy, +// ) (ctrl.Result, error) { +// log := r.Log.With("ns", req.Namespace, "name", req.Name) + +// projectName, err := strategy.ResolveProjectName(ctx, 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") + +// name := CreateK8sFormat(projectName, ids.ClusterName, ids.DatabaseUsername) +// secret := &corev1.Secret{ +// ObjectMeta: metav1.ObjectMeta{ +// Name: name, +// Namespace: req.Namespace, +// }, +// } + +// 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.Infow("secret deleted", "reason", workflow.ConnSecretDeleted) +// r.EventRecorder.Event(secret, corev1.EventTypeNormal, "Deleted", "ConnectionSecret deleted") +// return workflow.TerminateSilently(nil).WithoutRetry().ReconcileResult() +// } + +// // handleUpsert manages the case where we will create or update the connection secret +// func (r *ConnSecretReconciler) handleUpsert( +// ctx context.Context, +// req ctrl.Request, +// ids *ConnSecretIdentifiers, +// pair *ConnSecretPair[any], +// strategy AnyEndpointStrategy, +// ) (ctrl.Result, error) { +// log := r.Log.With("ns", req.Namespace, "name", req.Name) + +// projectName, err := strategy.ResolveProjectName(ctx, pair) +// if projectName == "" { +// err = fmt.Errorf("project name is empty") +// } +// if err != nil { +// log.Errorw("failed to resolve project name", "reason", workflow.ConnSecretFailedToResolveProjectName, "error", err) +// return workflow.Terminate(workflow.ConnSecretFailedToResolveProjectName, err).ReconcileResult() +// } +// ids.ProjectName = projectName +// log.Debugw("project name resolved for upsert") + +// data, err := strategy.BuildConnectionData(ctx, r.Client, 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() +// } + +// // ensureSecret creates or updates the Secret for the given identifiers and connection data +// func (r *ConnSecretReconciler) ensureSecret( +// ctx context.Context, +// ids *ConnSecretIdentifiers, +// pair *ConnSecretPair[any], +// 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, +// }, +// } + +// if err := fillConnSecretData(secret, ids, data); err != nil { +// log.Errorw("failed to fill secret data", "reason", workflow.ConnSecretFailedToFillData, "error", err) +// return err +// } + +// // OwnerRef is set elsewhere in your flow via controllerutil.SetControllerReference(pair.User, ...) + +// 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", "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 +// } +// } else { +// log.Errorw("failed to create secret", "reason", workflow.ConnSecretFailedToCreateSecret, "error", err) +// return err +// } +// } +// return nil +// } + +// // fillConnSecretData populates secret labels and data +// 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 secrets urls for the data fields +// func CreateURL(connURL, username, password string) (string, error) { +// if connURL == "" { +// return "", nil +// } +// u, err := url.Parse(connURL) +// if err != nil { +// return "", err +// } +// u.User = url.UserPassword(username, password) +// return u.String(), nil +// } diff --git a/internal/controller/connsecrets/connsecret_controller.go b/internal/controller/connsecrets/connsecret_controller.go index 7de02311c7..7106a6a2c5 100644 --- a/internal/controller/connsecrets/connsecret_controller.go +++ b/internal/controller/connsecrets/connsecret_controller.go @@ -1,228 +1,229 @@ package connsecrets -import ( - "context" - "fmt" - - "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" - "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" -) - -type ConnSecretReconciler struct { - reconciler.AtlasReconciler - Scheme *runtime.Scheme - EventRecorder record.EventRecorder - GlobalPredicates []predicate.Predicate - EndpointStrategies []AnyEndpointStrategy -} - -func (r *ConnSecretReconciler) 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.LoadIdentifiers(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() - } - - var ( - pair *ConnSecretPair[any] - strategy AnyEndpointStrategy - ) - - // We would need to know if we use a Deployment or Federation as Endpoint - for _, s := range r.EndpointStrategies { - p, err := s.LoadPair(ctx, r.Client, ids) - if err == nil { - pair, strategy = p, s - break - } - if err == ErrNoEndpointFound || err == ErrNoPairedResourcesFound { - continue - } - - return ctrl.Result{}, err - } - - expired, err := timeutil.IsExpired(pair.User.Spec.DeleteAfterDate) - if err != nil { - return workflow.Terminate(workflow.ConnSecretCheckExpirationFailed, err).ReconcileResult() - } - if expired { - return r.handleDelete(ctx, req, ids, pair, strategy) - } - - if !strategy.ValidScopes(pair) { - log.Infow("invalid scope; scheduling deletion", "reason", workflow.ConnSecretInvalidScopes) - return r.handleDelete(ctx, req, ids, pair, strategy) - } - - // Checks that AtlasDeployment and AtlasDatabaseUser are ready before proceeding - if ready := strategy.Ready(pair); !ready { - return workflow.InProgress(workflow.ConnSecretNotReady, "not ready").ReconcileResult() - } - - // Create or update the k8s connection secret - log.Infow("creating/updating connection secret", "reason", workflow.ConnSecretUpsert) - return r.handleUpsert(ctx, req, ids, pair, strategy) -} - -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.newDeploymentMapFunc), - builder.WithPredicates(predicate.Or( - watch.ReadyTransitionPredicate(func(d *akov2.AtlasDeployment) 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 (r *ConnSecretReconciler) 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 - } - name := CreateInternalFormat(projectID, d.GetDeploymentName(), u.Spec.Username) - requests = append(requests, reconcile.Request{ - NamespacedName: types.NamespacedName{Namespace: u.Namespace, Name: name}, - }) - } - } - return requests -} - -func (r *ConnSecretReconciler) 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: %w", err) - } - return project.ID(), nil - } - return "", fmt.Errorf("missing both external and internal project references") -} - -func (r *ConnSecretReconciler) newDeploymentMapFunc(ctx context.Context, obj client.Object) []reconcile.Request { - d, ok := obj.(*akov2.AtlasDeployment) - if !ok { - return nil - } - projectID, err := r.ResolveProjectId(ctx, d.Spec.ProjectDualReference, d.GetNamespace()) - 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, []akov2.AtlasDeployment{*d}, users.Items) -} - -func (r *ConnSecretReconciler) newDatabaseUserMapFunc(ctx context.Context, obj client.Object) []reconcile.Request { - u, ok := obj.(*akov2.AtlasDatabaseUser) - if !ok { - return nil - } - projectID, err := r.ResolveProjectId(ctx, u.Spec.ProjectDualReference, u.GetNamespace()) - if err != nil || projectID == "" { - return nil - } - deps := &akov2.AtlasDeploymentList{} - if err := r.Client.List(ctx, deps, &client.ListOptions{ - FieldSelector: fields.OneTermEqualSelector(indexer.AtlasDeploymentByProject, projectID), - }); err != nil { - return nil - } - return r.generateConnectionSecretRequests(projectID, deps.Items, []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, - } - - r.EndpointStrategies = []AnyEndpointStrategy{ - NewAnyEndpointStrategy(r.NewDeploymentEndpoint()), - // NewAnyEndpointStrategy(df), - } - - return r -} +// import ( +// "context" +// "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 +// EndpointStrategies []AnyEndpointStrategy +// } + +// func (r *ConnSecretReconciler) 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.LoadIdentifiers(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() +// } + +// var ( +// pair *ConnSecretPair[any] +// strategy AnyEndpointStrategy +// ) + +// // We would need to know if we use a Deployment or Federation as Endpoint +// for _, s := range r.EndpointStrategies { +// p, err := s.LoadPair(ctx, r.Client, ids) +// if err == nil { +// pair, strategy = p, s +// break +// } +// if err == ErrNoEndpointFound || err == ErrNoPairedResourcesFound { +// continue +// } + +// return ctrl.Result{}, err +// } + +// expired, err := timeutil.IsExpired(pair.User.Spec.DeleteAfterDate) +// if err != nil { +// return workflow.Terminate(workflow.ConnSecretCheckExpirationFailed, err).ReconcileResult() +// } +// if expired { +// return r.handleDelete(ctx, req, ids, pair, strategy) +// } + +// if !strategy.ValidScopes(pair) { +// log.Infow("invalid scope; scheduling deletion", "reason", workflow.ConnSecretInvalidScopes) +// return r.handleDelete(ctx, req, ids, pair, strategy) +// } + +// // Checks that AtlasDeployment and AtlasDatabaseUser are ready before proceeding +// if ready := strategy.Ready(pair); !ready { +// return workflow.InProgress(workflow.ConnSecretNotReady, "not ready").ReconcileResult() +// } + +// // Create or update the k8s connection secret +// log.Infow("creating/updating connection secret", "reason", workflow.ConnSecretUpsert) +// return r.handleUpsert(ctx, req, ids, pair, strategy) +// } + +// 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.newDeploymentMapFunc), +// builder.WithPredicates(predicate.Or( +// watch.ReadyTransitionPredicate(func(d *akov2.AtlasDeployment) 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 (r *ConnSecretReconciler) 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 +// } +// name := CreateInternalFormat(projectID, d.GetDeploymentName(), u.Spec.Username) +// requests = append(requests, reconcile.Request{ +// NamespacedName: types.NamespacedName{Namespace: u.Namespace, Name: name}, +// }) +// } +// } +// return requests +// } + +// func (r *ConnSecretReconciler) 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: %w", err) +// } +// return project.ID(), nil +// } +// return "", fmt.Errorf("missing both external and internal project references") +// } + +// func (r *ConnSecretReconciler) newDeploymentMapFunc(ctx context.Context, obj client.Object) []reconcile.Request { +// d, ok := obj.(*akov2.AtlasDeployment) +// if !ok { +// return nil +// } +// projectID, err := r.ResolveProjectId(ctx, d.Spec.ProjectDualReference, d.GetNamespace()) +// 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, []akov2.AtlasDeployment{*d}, users.Items) +// } + +// func (r *ConnSecretReconciler) newDatabaseUserMapFunc(ctx context.Context, obj client.Object) []reconcile.Request { +// u, ok := obj.(*akov2.AtlasDatabaseUser) +// if !ok { +// return nil +// } +// projectID, err := r.ResolveProjectId(ctx, u.Spec.ProjectDualReference, u.GetNamespace()) +// if err != nil || projectID == "" { +// return nil +// } +// deps := &akov2.AtlasDeploymentList{} +// if err := r.Client.List(ctx, deps, &client.ListOptions{ +// FieldSelector: fields.OneTermEqualSelector(indexer.AtlasDeploymentByProject, projectID), +// }); err != nil { +// return nil +// } +// return r.generateConnectionSecretRequests(projectID, deps.Items, []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, +// } + +// r.EndpointStrategies = []AnyEndpointStrategy{ +// NewAnyEndpointStrategy(r.NewDeploymentEndpoint()), +// // NewAnyEndpointStrategy(df), +// } + +// return r +// } diff --git a/internal/controller/connsecrets/endpoints.go b/internal/controller/connsecrets/endpoints.go index 8f3164d866..9e5a957b02 100644 --- a/internal/controller/connsecrets/endpoints.go +++ b/internal/controller/connsecrets/endpoints.go @@ -1,97 +1,98 @@ package connsecrets -import ( - "context" - "fmt" +// import ( +// "context" +// "fmt" - "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/indexer" - "k8s.io/apimachinery/pkg/fields" - "k8s.io/apimachinery/pkg/types" - "sigs.k8s.io/controller-runtime/pkg/client" -) +// "k8s.io/apimachinery/pkg/fields" +// "k8s.io/apimachinery/pkg/types" +// "sigs.k8s.io/controller-runtime/pkg/client" -type EndpointStrategy[T any] struct { - List client.ObjectList - Selector func(ids *ConnSecretIdentifiers) fields.Selector - ExtractList func(client.ObjectList) ([]T, error) +// "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/indexer" +// ) - GetName func(obj T) string - IsReady func(obj T) bool - GetConnStrings func(obj T) *status.ConnectionStrings - GetProjectID func(ctx context.Context, obj T) string - GetProjectName func(ctx context.Context, obj T) string -} +// type EndpointStrategy[T any] struct { +// List client.ObjectList +// Selector func(ids *ConnSecretIdentifiers) fields.Selector +// ExtractList func(client.ObjectList) ([]T, error) -// NewDeploymentEndpoint returns the EndpointStrategy for AtlasDeployment. -func (r *ConnSecretReconciler) NewDeploymentEndpoint() EndpointStrategy[*akov2.AtlasDeployment] { - return EndpointStrategy[*akov2.AtlasDeployment]{ - List: &akov2.AtlasDeploymentList{}, - Selector: func(ids *ConnSecretIdentifiers) fields.Selector { - return fields.OneTermEqualSelector( - indexer.AtlasDeploymentBySpecNameAndProjectID, - ids.ProjectID+"-"+ids.ClusterName, - ) - }, - ExtractList: func(ol client.ObjectList) ([]*akov2.AtlasDeployment, error) { - l, ok := ol.(*akov2.AtlasDeploymentList) - if !ok { - return nil, fmt.Errorf("unexpected list type %T", ol) - } - out := make([]*akov2.AtlasDeployment, 0, len(l.Items)) - for i := range l.Items { - out = append(out, &l.Items[i]) - } - return out, nil - }, - GetName: func(dpl *akov2.AtlasDeployment) string { - return dpl.GetDeploymentName() - }, - IsReady: func(dpl *akov2.AtlasDeployment) bool { - return api.HasReadyCondition(dpl.Status.Conditions) - }, - GetConnStrings: func(dpl *akov2.AtlasDeployment) *status.ConnectionStrings { - return dpl.Status.ConnectionStrings - }, - GetProjectID: func(ctx context.Context, dpl *akov2.AtlasDeployment) string { - if dpl.Spec.ExternalProjectRef != nil && dpl.Spec.ExternalProjectRef.ID != "" { - return dpl.Spec.ExternalProjectRef.ID - } - if dpl.Spec.ProjectRef != nil && dpl.Spec.ProjectRef.Name != "" { - ns := dpl.Spec.ProjectRef.Namespace - var proj akov2.AtlasProject - if err := r.Client.Get(ctx, types.NamespacedName{Namespace: ns, Name: dpl.Spec.ProjectRef.Name}, &proj); err == nil { - return proj.ID() - } - } - return "" - }, - GetProjectName: func(ctx context.Context, dpl *akov2.AtlasDeployment) string { - // Prefer K8s project name when ProjectRef is present - if dpl.Spec.ProjectRef != nil && dpl.Spec.ProjectRef.Name != "" { - ns := dpl.Spec.ProjectRef.Namespace - var proj akov2.AtlasProject - if err := r.Client.Get(ctx, types.NamespacedName{Namespace: ns, Name: dpl.Spec.ProjectRef.Name}, &proj); err == nil && proj.Spec.Name != "" { - return proj.Spec.Name - } - } +// GetName func(obj T) string +// IsReady func(obj T) bool +// GetConnStrings func(obj T) *status.ConnectionStrings +// GetProjectID func(ctx context.Context, obj T) string +// GetProjectName func(ctx context.Context, obj T) string +// } - // SDK fallback - connCfg, err := r.ResolveConnectionConfig(ctx, dpl) - if err != nil { - return "" - } - sdkClientSet, err := r.AtlasProvider.SdkClientSet(ctx, connCfg.Credentials, r.Log) - if err != nil { - return "" - } - ap, err := r.ResolveProject(ctx, sdkClientSet.SdkClient20250312002, dpl) - if err != nil { - return "" - } - return ap.Name - }, - } -} +// // NewDeploymentEndpoint returns the EndpointStrategy for AtlasDeployment. +// func (r *ConnSecretReconciler) NewDeploymentEndpoint() EndpointStrategy[*akov2.AtlasDeployment] { +// return EndpointStrategy[*akov2.AtlasDeployment]{ +// List: &akov2.AtlasDeploymentList{}, +// Selector: func(ids *ConnSecretIdentifiers) fields.Selector { +// return fields.OneTermEqualSelector( +// indexer.AtlasDeploymentBySpecNameAndProjectID, +// ids.ProjectID+"-"+ids.ClusterName, +// ) +// }, +// ExtractList: func(ol client.ObjectList) ([]*akov2.AtlasDeployment, error) { +// l, ok := ol.(*akov2.AtlasDeploymentList) +// if !ok { +// return nil, fmt.Errorf("unexpected list type %T", ol) +// } +// out := make([]*akov2.AtlasDeployment, 0, len(l.Items)) +// for i := range l.Items { +// out = append(out, &l.Items[i]) +// } +// return out, nil +// }, +// GetName: func(dpl *akov2.AtlasDeployment) string { +// return dpl.GetDeploymentName() +// }, +// IsReady: func(dpl *akov2.AtlasDeployment) bool { +// return api.HasReadyCondition(dpl.Status.Conditions) +// }, +// GetConnStrings: func(dpl *akov2.AtlasDeployment) *status.ConnectionStrings { +// return dpl.Status.ConnectionStrings +// }, +// GetProjectID: func(ctx context.Context, dpl *akov2.AtlasDeployment) string { +// if dpl.Spec.ExternalProjectRef != nil && dpl.Spec.ExternalProjectRef.ID != "" { +// return dpl.Spec.ExternalProjectRef.ID +// } +// if dpl.Spec.ProjectRef != nil && dpl.Spec.ProjectRef.Name != "" { +// ns := dpl.Spec.ProjectRef.Namespace +// var proj akov2.AtlasProject +// if err := r.Client.Get(ctx, types.NamespacedName{Namespace: ns, Name: dpl.Spec.ProjectRef.Name}, &proj); err == nil { +// return proj.ID() +// } +// } +// return "" +// }, +// GetProjectName: func(ctx context.Context, dpl *akov2.AtlasDeployment) string { +// // Prefer K8s project name when ProjectRef is present +// if dpl.Spec.ProjectRef != nil && dpl.Spec.ProjectRef.Name != "" { +// ns := dpl.Spec.ProjectRef.Namespace +// var proj akov2.AtlasProject +// if err := r.Client.Get(ctx, types.NamespacedName{Namespace: ns, Name: dpl.Spec.ProjectRef.Name}, &proj); err == nil && proj.Spec.Name != "" { +// return proj.Spec.Name +// } +// } + +// // SDK fallback +// connCfg, err := r.ResolveConnectionConfig(ctx, dpl) +// if err != nil { +// return "" +// } +// sdkClientSet, err := r.AtlasProvider.SdkClientSet(ctx, connCfg.Credentials, r.Log) +// if err != nil { +// return "" +// } +// ap, err := r.ResolveProject(ctx, sdkClientSet.SdkClient20250312002, dpl) +// if err != nil { +// return "" +// } +// return ap.Name +// }, +// } +// } diff --git a/internal/controller/connsecrets/strategy.go b/internal/controller/connsecrets/strategy.go index 7ee66e4762..846c83e72e 100644 --- a/internal/controller/connsecrets/strategy.go +++ b/internal/controller/connsecrets/strategy.go @@ -1,171 +1,171 @@ package connsecrets -import ( - "context" - "errors" - "fmt" - - "k8s.io/apimachinery/pkg/fields" - "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/stringutil" -) - -const InternalSeparator = "$" - -var ( - ErrNoPairedResourcesFound = errors.New("no endpoint and no AtlasDatabaseUser found") - ErrNoEndpointFound = errors.New("no endpoint found") - ErrManyEndpoints = errors.New("multiple endpoints found") - ErrNoUserFound = errors.New("no AtlasDatabaseUser found") - ErrManyUsers = errors.New("multiple AtlasDatabaseUsers found") -) - -type AnyEndpointStrategy interface { - LoadPair(ctx context.Context, c client.Client, ids *ConnSecretIdentifiers) (*ConnSecretPair[any], error) - Ready(p *ConnSecretPair[any]) bool - ValidScopes(p *ConnSecretPair[any]) bool - BuildConnectionData(ctx context.Context, c client.Client, p *ConnSecretPair[any]) (ConnSecretData, error) - ResolveProjectName(ctx context.Context, p *ConnSecretPair[any]) (string, error) -} - -type anyEndpointStrategy[T any] struct { - EndpointStrategy[T] -} - -type ConnSecretPair[T any] struct { - ProjectID string - User *akov2.AtlasDatabaseUser - Endpoint T -} - -type ConnSecretData struct { - DBUserName string - Password string - ConnURL string - SrvConnURL string - PrivateConnURLs []PrivateLinkConnURLs -} - -type PrivateLinkConnURLs struct { - PvtConnURL string - PvtSrvConnURL string - PvtShardConnURL string -} - -func NewAnyEndpointStrategy[T any](s EndpointStrategy[T]) AnyEndpointStrategy { - return &anyEndpointStrategy[T]{s} -} - -func (w *anyEndpointStrategy[T]) LoadPair(ctx context.Context, c client.Client, ids *ConnSecretIdentifiers) (*ConnSecretPair[any], error) { - if err := c.List(ctx, w.List, &client.ListOptions{FieldSelector: w.Selector(ids)}); err != nil { - return nil, err - } - eps, err := w.ExtractList(w.List) - if err != nil { - return nil, err - } - - users := &akov2.AtlasDatabaseUserList{} - userSel := fields.OneTermEqualSelector(indexer.AtlasDatabaseUserBySpecUsernameAndProjectID, ids.ProjectID+"-"+ids.DatabaseUsername) - if err := c.List(ctx, users, &client.ListOptions{FieldSelector: userSel}); err != nil { - return nil, err - } - - switch { - case len(eps) == 0 && len(users.Items) == 0: - return nil, ErrNoPairedResourcesFound - case len(eps) == 0: - return &ConnSecretPair[any]{ProjectID: ids.ProjectID, User: &users.Items[0], Endpoint: nil}, ErrNoEndpointFound - case len(users.Items) == 0: - return &ConnSecretPair[any]{ProjectID: ids.ProjectID, User: nil, Endpoint: eps[0]}, ErrNoUserFound - case len(eps) > 1: - return nil, ErrManyEndpoints - case len(users.Items) > 1: - return nil, ErrManyUsers - } - - return &ConnSecretPair[any]{ProjectID: ids.ProjectID, User: &users.Items[0], Endpoint: eps[0]}, nil -} - -func (w *anyEndpointStrategy[T]) ValidScopes(p *ConnSecretPair[any]) bool { - if p == nil || p.User == nil { - return false - } - scopes := p.User.GetScopes(akov2.DeploymentScopeType) - if len(scopes) == 0 { - return true - } - t, ok := p.Endpoint.(T) - if !ok || p.Endpoint == nil { - return false - } - name := w.GetName(t) - if name == "" { - return false - } - return stringutil.Contains(scopes, name) -} - -func (w *anyEndpointStrategy[T]) Ready(p *ConnSecretPair[any]) bool { - if p == nil || p.User == nil || !p.User.IsDatabaseUserReady() { - return false - } - t, ok := p.Endpoint.(T) - if !ok || p.Endpoint == nil { - return false - } - return w.IsReady(t) -} - -func (w *anyEndpointStrategy[T]) BuildConnectionData(ctx context.Context, c client.Client, p *ConnSecretPair[any]) (ConnSecretData, error) { - if p == nil || p.User == nil || p.Endpoint == nil { - return ConnSecretData{}, fmt.Errorf("invalid pair: nil user or endpoint") - } - - password, err := p.User.ReadPassword(ctx, c) - if err != nil { - return ConnSecretData{}, fmt.Errorf("failed to read password for user %q: %w", p.User.Spec.Username, err) - } - - t, ok := p.Endpoint.(T) - if !ok { - return ConnSecretData{}, fmt.Errorf("unexpected endpoint type") - } - - conn := w.GetConnStrings(t) - - 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 -} - -func (a *anyEndpointStrategy[T]) ResolveProjectName(ctx context.Context, p *ConnSecretPair[any]) (string, error) { - t, ok := p.Endpoint.(T) - if !ok || p.Endpoint == nil { - return "", fmt.Errorf("unexpected endpoint type") - } - return a.GetProjectName(ctx, t), nil -} +// import ( +// "context" +// "errors" +// "fmt" + +// "k8s.io/apimachinery/pkg/fields" +// "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/stringutil" +// ) + +// const InternalSeparator = "$" + +// var ( +// ErrNoPairedResourcesFound = errors.New("no endpoint and no AtlasDatabaseUser found") +// ErrNoEndpointFound = errors.New("no endpoint found") +// ErrManyEndpoints = errors.New("multiple endpoints found") +// ErrNoUserFound = errors.New("no AtlasDatabaseUser found") +// ErrManyUsers = errors.New("multiple AtlasDatabaseUsers found") +// ) + +// type AnyEndpointStrategy interface { +// LoadPair(ctx context.Context, c client.Client, ids *ConnSecretIdentifiers) (*ConnSecretPair[any], error) +// Ready(p *ConnSecretPair[any]) bool +// ValidScopes(p *ConnSecretPair[any]) bool +// BuildConnectionData(ctx context.Context, c client.Client, p *ConnSecretPair[any]) (ConnSecretData, error) +// ResolveProjectName(ctx context.Context, p *ConnSecretPair[any]) (string, error) +// } + +// type anyEndpointStrategy[T any] struct { +// EndpointStrategy[T] +// } + +// type ConnSecretPair[T any] struct { +// ProjectID string +// User *akov2.AtlasDatabaseUser +// Endpoint T +// } + +// type ConnSecretData struct { +// DBUserName string +// Password string +// ConnURL string +// SrvConnURL string +// PrivateConnURLs []PrivateLinkConnURLs +// } + +// type PrivateLinkConnURLs struct { +// PvtConnURL string +// PvtSrvConnURL string +// PvtShardConnURL string +// } + +// func NewAnyEndpointStrategy[T any](s EndpointStrategy[T]) AnyEndpointStrategy { +// return &anyEndpointStrategy[T]{s} +// } + +// func (w *anyEndpointStrategy[T]) LoadPair(ctx context.Context, c client.Client, ids *ConnSecretIdentifiers) (*ConnSecretPair[any], error) { +// if err := c.List(ctx, w.List, &client.ListOptions{FieldSelector: w.Selector(ids)}); err != nil { +// return nil, err +// } +// eps, err := w.ExtractList(w.List) +// if err != nil { +// return nil, err +// } + +// users := &akov2.AtlasDatabaseUserList{} +// userSel := fields.OneTermEqualSelector(indexer.AtlasDatabaseUserBySpecUsernameAndProjectID, ids.ProjectID+"-"+ids.DatabaseUsername) +// if err := c.List(ctx, users, &client.ListOptions{FieldSelector: userSel}); err != nil { +// return nil, err +// } + +// switch { +// case len(eps) == 0 && len(users.Items) == 0: +// return nil, ErrNoPairedResourcesFound +// case len(eps) == 0: +// return &ConnSecretPair[any]{ProjectID: ids.ProjectID, User: &users.Items[0], Endpoint: nil}, ErrNoEndpointFound +// case len(users.Items) == 0: +// return &ConnSecretPair[any]{ProjectID: ids.ProjectID, User: nil, Endpoint: eps[0]}, ErrNoUserFound +// case len(eps) > 1: +// return nil, ErrManyEndpoints +// case len(users.Items) > 1: +// return nil, ErrManyUsers +// } + +// return &ConnSecretPair[any]{ProjectID: ids.ProjectID, User: &users.Items[0], Endpoint: eps[0]}, nil +// } + +// func (w *anyEndpointStrategy[T]) ValidScopes(p *ConnSecretPair[any]) bool { +// if p == nil || p.User == nil { +// return false +// } +// scopes := p.User.GetScopes(akov2.DeploymentScopeType) +// if len(scopes) == 0 { +// return true +// } +// t, ok := p.Endpoint.(T) +// if !ok || p.Endpoint == nil { +// return false +// } +// name := w.GetName(t) +// if name == "" { +// return false +// } +// return stringutil.Contains(scopes, name) +// } + +// func (w *anyEndpointStrategy[T]) Ready(p *ConnSecretPair[any]) bool { +// if p == nil || p.User == nil || !p.User.IsDatabaseUserReady() { +// return false +// } +// t, ok := p.Endpoint.(T) +// if !ok || p.Endpoint == nil { +// return false +// } +// return w.IsReady(t) +// } + +// func (w *anyEndpointStrategy[T]) BuildConnectionData(ctx context.Context, c client.Client, p *ConnSecretPair[any]) (ConnSecretData, error) { +// if p == nil || p.User == nil || p.Endpoint == nil { +// return ConnSecretData{}, fmt.Errorf("invalid pair: nil user or endpoint") +// } + +// password, err := p.User.ReadPassword(ctx, c) +// if err != nil { +// return ConnSecretData{}, fmt.Errorf("failed to read password for user %q: %w", p.User.Spec.Username, err) +// } + +// t, ok := p.Endpoint.(T) +// if !ok { +// return ConnSecretData{}, fmt.Errorf("unexpected endpoint type") +// } + +// conn := w.GetConnStrings(t) + +// 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 +// } + +// func (a *anyEndpointStrategy[T]) ResolveProjectName(ctx context.Context, p *ConnSecretPair[any]) (string, error) { +// t, ok := p.Endpoint.(T) +// if !ok || p.Endpoint == nil { +// return "", fmt.Errorf("unexpected endpoint type") +// } +// return a.GetProjectName(ctx, t), nil +// } diff --git a/internal/controller/registry.go b/internal/controller/registry.go index 130543683a..20869e12ca 100644 --- a/internal/controller/registry.go +++ b/internal/controller/registry.go @@ -42,7 +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" - "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/controller/connectionsecret" + 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" @@ -129,7 +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, connectionsecret.NewConnectionSecretReconciler(c, r.defaultPredicates(), ap, r.logger, r.globalSecretRef)) + reconcilers = append(reconcilers, connsecretsgeneric.NewConnectionSecretReconciler(c, r.defaultPredicates(), ap, r.logger, r.globalSecretRef)) if version.IsExperimental() { // Add experimental controllers here From c1ce2beb98ef598e63db7d1601fc72c5fb3afea7 Mon Sep 17 00:00:00 2001 From: andrpac Date: Thu, 14 Aug 2025 13:03:56 +0100 Subject: [PATCH 06/11] fix: new indexers --- .../connsecrets-generic/connectionsecret.go | 48 ++-- .../connectionsecret_controller.go | 50 ++-- .../endpoint_deployment.go | 64 ++++- .../endpoint_federation.go | 258 +++++++++++------- .../indexer/atlasdatafederationbyspecname.go | 93 +++++++ 5 files changed, 348 insertions(+), 165 deletions(-) create mode 100644 internal/indexer/atlasdatafederationbyspecname.go diff --git a/internal/controller/connsecrets-generic/connectionsecret.go b/internal/controller/connsecrets-generic/connectionsecret.go index a36cf2e8af..4984d3bcbf 100644 --- a/internal/controller/connsecrets-generic/connectionsecret.go +++ b/internal/controller/connsecrets-generic/connectionsecret.go @@ -1,3 +1,17 @@ +// 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 ( @@ -149,7 +163,7 @@ func (r *ConnSecretReconciler) LoadPair(ctx context.Context, ids *ConnSecretIden var selected Endpoint for _, kind := range r.EndpointKinds { list := kind.ListObj() - if err := r.Client.List(ctx, list, &client.ListOptions{FieldSelector: kind.Selector(ids)}); err != nil { + if err := r.Client.List(ctx, list, &client.ListOptions{FieldSelector: kind.SelectorByProjectAndName(ids)}); err != nil { return nil, err } eps, err := kind.ExtractList(list) @@ -190,7 +204,6 @@ func (r *ConnSecretReconciler) LoadPair(ctx context.Context, ids *ConnSecretIden }, nil } -// handleDelete manages the case where we will delete the connection secret func (r *ConnSecretReconciler) handleDelete( ctx context.Context, req ctrl.Request, @@ -382,36 +395,7 @@ func (r *ConnSecretReconciler) buildConnectionData(ctx context.Context, p *ConnS return ConnSecretData{}, fmt.Errorf("invalid pair: nil user or endpoint") } - 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) - } - - data := ConnSecretData{ - DBUserName: p.User.Spec.Username, - Password: password, - } - - if conn := p.Endpoint.GetConnStrings(); conn != nil { - 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 + return p.Endpoint.BuildConnData(ctx, r.Client, r.AtlasProvider, r.Log, p.User) } func allowsByScopes(u *akov2.AtlasDatabaseUser, epName string) bool { diff --git a/internal/controller/connsecrets-generic/connectionsecret_controller.go b/internal/controller/connsecrets-generic/connectionsecret_controller.go index 1b71d4250f..7619a47e30 100644 --- a/internal/controller/connsecrets-generic/connectionsecret_controller.go +++ b/internal/controller/connsecrets-generic/connectionsecret_controller.go @@ -1,3 +1,17 @@ +// 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 ( @@ -23,7 +37,6 @@ import ( "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/atlas" "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/controller/reconciler" "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/controller/watch" @@ -45,13 +58,16 @@ type ConnSecretReconciler struct { type Endpoint interface { GetName() string IsReady() bool - GetConnStrings() *status.ConnectionStrings + GetProjectRef(ctx context.Context, r client.Reader) string GetProjectID(ctx context.Context, r client.Reader) (string, error) GetProjectName(ctx context.Context, r client.Reader, provider atlas.Provider, log *zap.SugaredLogger) (string, error) ListObj() client.ObjectList - Selector(ids *ConnSecretIdentifiers) fields.Selector ExtractList(client.ObjectList) ([]Endpoint, error) + SelectorByProject(projectRef string) fields.Selector + SelectorByProjectAndName(ids *ConnSecretIdentifiers) fields.Selector + + BuildConnData(ctx context.Context, c client.Client, provider atlas.Provider, log *zap.SugaredLogger, user *akov2.AtlasDatabaseUser) (ConnSecretData, error) } type ConnSecretPair struct { @@ -62,7 +78,6 @@ type ConnSecretPair struct { func (r *ConnSecretReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { log := r.Log.With("ns", req.Namespace, "name", req.Name) - log.Debugw("reconcile started") ids, err := r.LoadIdentifiers(ctx, req.NamespacedName) if err != nil { @@ -186,28 +201,21 @@ func (r *ConnSecretReconciler) listEndpointsByProject(ctx context.Context, proje var out []Endpoint for _, kind := range r.EndpointKinds { list := kind.ListObj() - switch kind.(type) { - case DeploymentEndpoint: - if err := r.Client.List(ctx, list, &client.ListOptions{ - FieldSelector: fields.OneTermEqualSelector(indexer.AtlasDeploymentByProject, projectID), - }); err != nil { - return nil, err - } - // case FederationEndpoint: - // if err := r.Client.List(ctx, list, &client.ListOptions{ - // FieldSelector: fields.OneTermEqualSelector(indexer.AtlasDataFederationByProject, projectID), - // }); err != nil { - // return nil, err - // } - default: - continue + + if err := r.Client.List(ctx, list, &client.ListOptions{ + FieldSelector: kind.SelectorByProject(projectID), + }); err != nil { + return nil, err } + eps, err := kind.ExtractList(list) if err != nil { return nil, err } + out = append(out, eps...) } + return out, nil } @@ -221,6 +229,7 @@ func (r *ConnSecretReconciler) newEndpointMapFunc(ctx context.Context, obj clien default: return nil } + projectID, err := ep.GetProjectID(ctx, r.Client) if err != nil || projectID == "" { return nil @@ -231,6 +240,7 @@ func (r *ConnSecretReconciler) newEndpointMapFunc(ctx context.Context, obj clien }); err != nil { return nil } + return r.generateConnectionSecretRequests(projectID, []Endpoint{ep}, users.Items) } @@ -243,10 +253,12 @@ func (r *ConnSecretReconciler) newDatabaseUserMapFunc(ctx context.Context, obj c if err != nil || projectID == "" { return nil } + // The user should connect to all endpoint types endpoints, err := r.listEndpointsByProject(ctx, projectID) if err != nil { return nil } + return r.generateConnectionSecretRequests(projectID, endpoints, []akov2.AtlasDatabaseUser{*u}) } diff --git a/internal/controller/connsecrets-generic/endpoint_deployment.go b/internal/controller/connsecrets-generic/endpoint_deployment.go index 0f584a5897..84424e19a5 100644 --- a/internal/controller/connsecrets-generic/endpoint_deployment.go +++ b/internal/controller/connsecrets-generic/endpoint_deployment.go @@ -1,3 +1,17 @@ +// 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 ( @@ -10,7 +24,6 @@ import ( "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/atlas" "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/indexer" "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/kube" @@ -33,11 +46,9 @@ func (e DeploymentEndpoint) IsReady() bool { return e.obj != nil && api.HasReadyCondition(e.obj.Status.Conditions) } -func (e DeploymentEndpoint) GetConnStrings() *status.ConnectionStrings { - if e.obj == nil { - return nil - } - return e.obj.Status.ConnectionStrings +func (e DeploymentEndpoint) GetProjectRef(ctx context.Context, r client.Reader) string { + ref, _ := e.GetProjectID(ctx, r) + return ref } func (e DeploymentEndpoint) GetProjectID(ctx context.Context, r client.Reader) (string, error) { @@ -90,10 +101,14 @@ func (e DeploymentEndpoint) GetProjectName(ctx context.Context, r client.Reader, return "", fmt.Errorf("project name not available") } -// ---- indexer methods (ignore e.obj) ---- +// ---- indexer methods ---- func (DeploymentEndpoint) ListObj() client.ObjectList { return &akov2.AtlasDeploymentList{} } -func (DeploymentEndpoint) Selector(ids *ConnSecretIdentifiers) fields.Selector { +func (DeploymentEndpoint) SelectorByProject(projectRef string) fields.Selector { + return fields.OneTermEqualSelector(indexer.AtlasDeploymentByProject, projectRef) +} + +func (DeploymentEndpoint) SelectorByProjectAndName(ids *ConnSecretIdentifiers) fields.Selector { return fields.OneTermEqualSelector(indexer.AtlasDeploymentBySpecNameAndProjectID, ids.ProjectID+"-"+ids.ClusterName) } @@ -109,3 +124,36 @@ func (e DeploymentEndpoint) ExtractList(ol client.ObjectList) ([]Endpoint, error } return out, nil } + +func (e DeploymentEndpoint) BuildConnData(ctx context.Context, c client.Client, _ atlas.Provider, _ *zap.SugaredLogger, user *akov2.AtlasDatabaseUser) (ConnSecretData, error) { + if user == nil || e.obj == nil { + return ConnSecretData{}, fmt.Errorf("invalid endpoint or user") + } + password, err := user.ReadPassword(ctx, c) + 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, + } + + 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_federation.go b/internal/controller/connsecrets-generic/endpoint_federation.go index 3c9ee5ba52..0490313dda 100644 --- a/internal/controller/connsecrets-generic/endpoint_federation.go +++ b/internal/controller/connsecrets-generic/endpoint_federation.go @@ -1,108 +1,154 @@ +// 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" - -// "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/atlas" -// "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/indexer" -// "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/kube" -// "go.uber.org/zap" -// "k8s.io/apimachinery/pkg/fields" -// "sigs.k8s.io/controller-runtime/pkg/client" -// ) - -// type FederationEndpoint struct { -// obj *akov2.AtlasDataFederation -// r *ConnSecretReconciler -// } - -// // ---- instance methods ---- -// func (e FederationEndpoint) GetName() string { -// if e.obj == nil { -// return "" -// } -// return e.obj.GetDFName() // adjust if your CR has a different getter -// } - -// func (e FederationEndpoint) IsReady() bool { -// return e.obj != nil && api.HasReadyCondition(e.obj.Status.Conditions) -// } - -// func (e FederationEndpoint) GetConnStrings() *status.ConnectionStrings { -// if e.obj == nil { -// return nil -// } -// return e.obj.Status.ConnectionStrings // or nil if federation doesn’t expose this -// } - -// func (e FederationEndpoint) GetProjectID(ctx context.Context, r client.Reader) (string, error) { -// if e.obj == nil { -// return "", fmt.Errorf("nil federation") -// } -// if e.obj.Spec.ProjectRef != nil && e.obj.Spec.ProjectRef.Name != "" { -// proj := &akov2.AtlasProject{} -// if err := r.Get(ctx, kube.ObjectKey(e.obj.Namespace, e.obj.Spec.ProjectRef.Name), proj); err != nil { -// return "", err -// } -// return proj.ID(), nil -// } -// if id := e.obj.Status.ProjectID; id != "" { -// return id, nil -// } -// return "", fmt.Errorf("project ID not available") -// } - -// func (e FederationEndpoint) GetProjectName(ctx context.Context, r client.Reader, provider atlas.Provider, log *zap.SugaredLogger) (string, error) { -// if e.obj == nil { -// return "", fmt.Errorf("nil federation") -// } -// if e.obj.Spec.ProjectRef != nil && e.obj.Spec.ProjectRef.Name != "" { -// proj := &akov2.AtlasProject{} -// if err := r.Get(ctx, kube.ObjectKey(e.obj.Namespace, e.obj.Spec.ProjectRef.Name), proj); err != nil { -// return "", err -// } -// if proj.Spec.Name != "" { -// return kube.NormalizeIdentifier(proj.Spec.Name), nil -// } -// } -// // SDK fallback (optional) -// 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, 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 "", fmt.Errorf("project name not available") -// } - -// // ---- indexer methods ---- -// func (FederationEndpoint) ListObj() client.ObjectList { return &akov2.AtlasDataFederationList{} } - -// func (FederationEndpoint) Selector(ids *ConnSecretIdentifiers) fields.Selector { -// return fields.OneTermEqualSelector(indexer.AtlasDataFederationBySpecNameAndProjectID, ids.ProjectID+"-"+ids.ClusterName) -// } - -// 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 -// } +import ( + "context" + "fmt" + "strings" + + "go.uber.org/zap" + "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/atlas" + "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 +} + +// ---- instance methods ---- +func (e FederationEndpoint) GetName() string { + if e.obj == nil { + return "" + } + return e.obj.Spec.Name +} + +func (e FederationEndpoint) IsReady() bool { + return e.obj != nil && api.HasReadyCondition(e.obj.Status.Conditions) +} + +func (e FederationEndpoint) GetProjectRef(ctx context.Context, r client.Reader) string { + return e.obj.Spec.Project.Name +} + +func (e FederationEndpoint) GetProjectID(ctx context.Context, r client.Reader) (string, error) { + if e.obj == nil { + return "", fmt.Errorf("nil federation") + } + if e.obj.Spec.Project.Name != "" { + proj := &akov2.AtlasProject{} + if err := r.Get(ctx, kube.ObjectKey(e.obj.Namespace, e.obj.Spec.Project.Name), proj); err != nil { + return "", err + } + return proj.ID(), nil + } + + return "", fmt.Errorf("project ID not available") +} + +func (e FederationEndpoint) GetProjectName(ctx context.Context, r client.Reader, provider atlas.Provider, log *zap.SugaredLogger) (string, error) { + if e.obj == nil { + return "", fmt.Errorf("nil federation") + } + if e.obj.Spec.Project.Name != "" { + proj := &akov2.AtlasProject{} + if err := r.Get(ctx, kube.ObjectKey(e.obj.Namespace, e.obj.Spec.Project.Name), proj); err != nil { + return "", err + } + if proj.Spec.Name != "" { + return kube.NormalizeIdentifier(proj.Spec.Name), nil + } + } + + return "", fmt.Errorf("project name not available") +} + +// ---- indexer methods ---- +func (FederationEndpoint) ListObj() client.ObjectList { return &akov2.AtlasDataFederationList{} } + +func (FederationEndpoint) SelectorByProject(projectRef string) fields.Selector { + return fields.OneTermEqualSelector(indexer.AtlasDataFederationByProject, projectRef) +} + +func (FederationEndpoint) SelectorByProjectAndName(ids *ConnSecretIdentifiers) fields.Selector { + return fields.OneTermEqualSelector(indexer.AtlasDataFederationBySpecNameAndProjectID, ids.ProjectID+"-"+ids.ClusterName) +} + +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 +} + +func (e FederationEndpoint) BuildConnData(ctx context.Context, c client.Client, provider atlas.Provider, log *zap.SugaredLogger, user *akov2.AtlasDatabaseUser) (ConnSecretData, error) { + if user == nil || e.obj == nil { + return ConnSecretData{}, fmt.Errorf("invalid endpoint or user") + } + password, err := user.ReadPassword(ctx, c) + if err != nil { + return ConnSecretData{}, fmt.Errorf("failed to read password for user %q: %w", user.Spec.Username, err) + } + + project := &akov2.AtlasProject{} + if err := c.Get(ctx, e.obj.AtlasProjectObjectKey(), project); err != nil { + return ConnSecretData{}, err + } + + connectionConfig, err := reconciler.GetConnectionConfig(ctx, c, project.ConnectionSecretObjectKey(), &e.r.GlobalSecretRef) + if err != nil { + return ConnSecretData{}, err + } + + clientSet, err := e.r.AtlasProvider.SdkClientSet(ctx, connectionConfig.Credentials, 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") + } + urls := make([]string, 0, len(df.Hostnames)) + for _, h := range df.Hostnames { + urls = append(urls, fmt.Sprintf("mongodb://%s:%s@%s?ssl=true", user.Spec.Username, password, h)) + } + + return ConnSecretData{ + DBUserName: user.Spec.Username, + Password: password, + ConnURL: strings.Join(urls, ","), + }, nil +} 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 +} From aaad51acab630d09d94941754cc6681a4aebeb58 Mon Sep 17 00:00:00 2001 From: andrpac Date: Thu, 14 Aug 2025 16:58:16 +0100 Subject: [PATCH 07/11] more fixes --- .../connsecrets-generic/connectionsecret.go | 69 ++++++++++++------- .../connectionsecret_controller.go | 58 ++++++++++------ .../endpoint_deployment.go | 21 +++--- .../endpoint_federation.go | 30 ++++---- internal/indexer/indexer.go | 1 + 5 files changed, 107 insertions(+), 72 deletions(-) diff --git a/internal/controller/connsecrets-generic/connectionsecret.go b/internal/controller/connsecrets-generic/connectionsecret.go index 4984d3bcbf..304820c928 100644 --- a/internal/controller/connsecrets-generic/connectionsecret.go +++ b/internal/controller/connsecrets-generic/connectionsecret.go @@ -150,6 +150,14 @@ func (r *ConnSecretReconciler) LoadIdentifiers(ctx context.Context, req types.Na }, nil } +func isMissingIndex(err error) bool { + if err == nil { + return false + } + s := strings.ToLower(err.Error()) + return strings.Contains(s, "index with name") && strings.Contains(s, "does not exist") +} + func (r *ConnSecretReconciler) LoadPair(ctx context.Context, ids *ConnSecretIdentifiers) (*ConnSecretPair, error) { compositeUserKey := ids.ProjectID + "-" + ids.DatabaseUsername users := &akov2.AtlasDatabaseUserList{} @@ -164,8 +172,11 @@ func (r *ConnSecretReconciler) LoadPair(ctx context.Context, ids *ConnSecretIden 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 + if !isMissingIndex(err) { + return nil, err + } } + eps, err := kind.ExtractList(list) if err != nil { return nil, err @@ -211,14 +222,23 @@ func (r *ConnSecretReconciler) handleDelete( pair *ConnSecretPair, ) (ctrl.Result, error) { log := r.Log.With("ns", req.Namespace, "name", req.Name) + projectName := ids.ProjectName + var err error - projectName, err := pair.Endpoint.GetProjectName(ctx, r.Client, r.AtlasProvider, r.Log) 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() + if pair == nil || pair.Endpoint == nil { + err = fmt.Errorf("endpoint is nil; cannot resolve project name for delete") + log.Errorw("failed to resolve project name", "reason", workflow.ConnSecretUnresolvedProjectName, "error", err) + return workflow.Terminate(workflow.ConnSecretUnresolvedProjectName, err).ReconcileResult() + } + projectName, err = pair.Endpoint.GetProjectName(ctx) // no shadowing + 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") @@ -251,20 +271,31 @@ func (r *ConnSecretReconciler) handleUpsert( ids *ConnSecretIdentifiers, pair *ConnSecretPair, ) (ctrl.Result, error) { + var err error log := r.Log.With("ns", req.Namespace, "name", req.Name) + projectName := ids.ProjectName - projectName, err := pair.Endpoint.GetProjectName(ctx, r.Client, r.AtlasProvider, r.Log) if projectName == "" { - err = fmt.Errorf("project name is empty") - } - if err != nil { - log.Errorw("failed to resolve project name", "reason", workflow.ConnSecretFailedToResolveProjectName, "error", err) - return workflow.Terminate(workflow.ConnSecretFailedToResolveProjectName, err).ReconcileResult() + if pair == nil || pair.Endpoint == nil { + err = fmt.Errorf("endpoint is nil; cannot resolve project name") + log.Errorw("failed to resolve project name", "reason", workflow.ConnSecretFailedToResolveProjectName, "error", err) + return workflow.Terminate(workflow.ConnSecretFailedToResolveProjectName, err).ReconcileResult() + } + + projectName, err = pair.Endpoint.GetProjectName(ctx) + if projectName == "" { + err = fmt.Errorf("project name is empty") + } + if err != nil { + log.Errorw("failed to resolve project name", "reason", workflow.ConnSecretFailedToResolveProjectName, "error", err) + return workflow.Terminate(workflow.ConnSecretFailedToResolveProjectName, err).ReconcileResult() + } } + ids.ProjectName = projectName - log.Debugw("project name resolved for upsert") + log.Debugw("project name resolved for upsert", "projectName", projectName) - data, err := r.buildConnectionData(ctx, pair) + data, err := pair.Endpoint.BuildConnData(ctx, pair.User) if err != nil { log.Errorw("failed to build connection data", "reason", workflow.ConnSecretFailedToBuildData, "error", err) return workflow.Terminate(workflow.ConnSecretFailedToBuildData, err).ReconcileResult() @@ -390,14 +421,6 @@ func CreateURL(connURL, username, password string) (string, error) { return u.String(), nil } -func (r *ConnSecretReconciler) buildConnectionData(ctx context.Context, p *ConnSecretPair) (ConnSecretData, error) { - if p == nil || p.User == nil || p.Endpoint == nil { - return ConnSecretData{}, fmt.Errorf("invalid pair: nil user or endpoint") - } - - return p.Endpoint.BuildConnData(ctx, r.Client, r.AtlasProvider, r.Log, p.User) -} - func allowsByScopes(u *akov2.AtlasDatabaseUser, epName string) bool { scopes := u.GetScopes(akov2.DeploymentScopeType) if len(scopes) == 0 || stringutil.Contains(scopes, epName) { diff --git a/internal/controller/connsecrets-generic/connectionsecret_controller.go b/internal/controller/connsecrets-generic/connectionsecret_controller.go index 7619a47e30..a930e7e769 100644 --- a/internal/controller/connsecrets-generic/connectionsecret_controller.go +++ b/internal/controller/connsecrets-generic/connectionsecret_controller.go @@ -58,16 +58,16 @@ type ConnSecretReconciler struct { type Endpoint interface { GetName() string IsReady() bool - GetProjectRef(ctx context.Context, r client.Reader) string - GetProjectID(ctx context.Context, r client.Reader) (string, error) - GetProjectName(ctx context.Context, r client.Reader, provider atlas.Provider, log *zap.SugaredLogger) (string, error) + GetProjectRef(ctx context.Context) string + 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, c client.Client, provider atlas.Provider, log *zap.SugaredLogger, user *akov2.AtlasDatabaseUser) (ConnSecretData, error) + BuildConnData(ctx context.Context, user *akov2.AtlasDatabaseUser) (ConnSecretData, error) } type ConnSecretPair struct { @@ -150,6 +150,16 @@ func (r *ConnSecretReconciler) SetupWithManager(mgr ctrl.Manager, skipNameValida 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), @@ -183,27 +193,33 @@ func (r *ConnSecretReconciler) generateConnectionSecretRequests(projectID string return reqs } -func (r *ConnSecretReconciler) ResolveProjectId(ctx context.Context, ref akov2.ProjectDualReference, parentNamespace string) (string, error) { +func (r *ConnSecretReconciler) ResolveProjectId(ctx context.Context, ref akov2.ProjectDualReference, parentNamespace string) (string, string, error) { if ref.ExternalProjectRef != nil && ref.ExternalProjectRef.ID != "" { - return ref.ExternalProjectRef.ID, nil + 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: %w", err) + return "", "", fmt.Errorf("failed to resolve projectRef: %w", err) } - return project.ID(), nil + return ref.ProjectRef.GetObject(parentNamespace).String(), project.ID(), nil } - return "", fmt.Errorf("missing both external and internal project references") + return "", "", fmt.Errorf("missing both external and internal project references") } -func (r *ConnSecretReconciler) listEndpointsByProject(ctx context.Context, projectID string) ([]Endpoint, error) { +func (r *ConnSecretReconciler) listEndpointsByProject(ctx context.Context, projectRef string, projectID string) ([]Endpoint, error) { var out []Endpoint for _, kind := range r.EndpointKinds { - list := kind.ListObj() + ref := kind.GetProjectRef(ctx) + if ref == "PROJECTID" { + ref = projectID // ProjectID used by deployment + } else { + ref = projectRef // ProjectRef used by federation + } + list := kind.ListObj() if err := r.Client.List(ctx, list, &client.ListOptions{ - FieldSelector: kind.SelectorByProject(projectID), + FieldSelector: kind.SelectorByProject(ref), }); err != nil { return nil, err } @@ -224,16 +240,17 @@ func (r *ConnSecretReconciler) newEndpointMapFunc(ctx context.Context, obj clien switch o := obj.(type) { case *akov2.AtlasDeployment: ep = DeploymentEndpoint{obj: o, r: r} - // case *akov2.AtlasDataFederation: - // ep = FederationEndpoint{obj: o, r: r} + case *akov2.AtlasDataFederation: + ep = FederationEndpoint{obj: o, r: r} default: return nil } - projectID, err := ep.GetProjectID(ctx, r.Client) + 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), @@ -249,12 +266,13 @@ func (r *ConnSecretReconciler) newDatabaseUserMapFunc(ctx context.Context, obj c if !ok { return nil } - projectID, err := r.ResolveProjectId(ctx, u.Spec.ProjectDualReference, u.GetNamespace()) - if err != nil || projectID == "" { + projectRef, projectID, err := r.ResolveProjectId(ctx, u.Spec.ProjectDualReference, u.GetNamespace()) + if err != nil { return nil } + // The user should connect to all endpoint types - endpoints, err := r.listEndpointsByProject(ctx, projectID) + endpoints, err := r.listEndpointsByProject(ctx, projectRef, projectID) if err != nil { return nil } @@ -283,8 +301,8 @@ func NewConnectionSecretReconciler( // Register kinds to try (order matters) r.EndpointKinds = []Endpoint{ - DeploymentEndpoint{r: r}, // obj=nil; used for discovery - // FederationEndpoint{r: r}, // obj=nil; used for discovery + DeploymentEndpoint{r: r}, + FederationEndpoint{r: r}, } return r diff --git a/internal/controller/connsecrets-generic/endpoint_deployment.go b/internal/controller/connsecrets-generic/endpoint_deployment.go index 84424e19a5..b65c9ec679 100644 --- a/internal/controller/connsecrets-generic/endpoint_deployment.go +++ b/internal/controller/connsecrets-generic/endpoint_deployment.go @@ -18,13 +18,11 @@ import ( "context" "fmt" - "go.uber.org/zap" "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/atlas" "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/indexer" "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/kube" ) @@ -46,12 +44,11 @@ func (e DeploymentEndpoint) IsReady() bool { return e.obj != nil && api.HasReadyCondition(e.obj.Status.Conditions) } -func (e DeploymentEndpoint) GetProjectRef(ctx context.Context, r client.Reader) string { - ref, _ := e.GetProjectID(ctx, r) - return ref +func (e DeploymentEndpoint) GetProjectRef(ctx context.Context) string { + return "PROJECTID" } -func (e DeploymentEndpoint) GetProjectID(ctx context.Context, r client.Reader) (string, error) { +func (e DeploymentEndpoint) GetProjectID(ctx context.Context) (string, error) { if e.obj == nil { return "", fmt.Errorf("nil deployment") } @@ -60,7 +57,7 @@ func (e DeploymentEndpoint) GetProjectID(ctx context.Context, r client.Reader) ( } if e.obj.Spec.ProjectRef != nil && e.obj.Spec.ProjectRef.Name != "" { proj := &akov2.AtlasProject{} - if err := r.Get(ctx, kube.ObjectKey(e.obj.Namespace, e.obj.Spec.ProjectRef.Name), proj); err != nil { + if err := e.r.Client.Get(ctx, kube.ObjectKey(e.obj.Namespace, e.obj.Spec.ProjectRef.Name), proj); err != nil { return "", err } return proj.ID(), nil @@ -69,13 +66,13 @@ func (e DeploymentEndpoint) GetProjectID(ctx context.Context, r client.Reader) ( return "", fmt.Errorf("project ID not available") } -func (e DeploymentEndpoint) GetProjectName(ctx context.Context, r client.Reader, provider atlas.Provider, log *zap.SugaredLogger) (string, error) { +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 != "" { proj := &akov2.AtlasProject{} - if err := r.Get(ctx, kube.ObjectKey(e.obj.Namespace, e.obj.Spec.ProjectRef.Name), proj); err != nil { + if err := e.r.Client.Get(ctx, kube.ObjectKey(e.obj.Namespace, e.obj.Spec.ProjectRef.Name), proj); err != nil { return "", err } if proj.Spec.Name != "" { @@ -88,7 +85,7 @@ func (e DeploymentEndpoint) GetProjectName(ctx context.Context, r client.Reader, if err != nil { return "", err } - sdk, err := e.r.AtlasProvider.SdkClientSet(ctx, cfg.Credentials, log) + sdk, err := e.r.AtlasProvider.SdkClientSet(ctx, cfg.Credentials, e.r.Log) if err != nil { return "", err } @@ -125,11 +122,11 @@ func (e DeploymentEndpoint) ExtractList(ol client.ObjectList) ([]Endpoint, error return out, nil } -func (e DeploymentEndpoint) BuildConnData(ctx context.Context, c client.Client, _ atlas.Provider, _ *zap.SugaredLogger, user *akov2.AtlasDatabaseUser) (ConnSecretData, error) { +func (e DeploymentEndpoint) BuildConnData(ctx context.Context, user *akov2.AtlasDatabaseUser) (ConnSecretData, error) { if user == nil || e.obj == nil { return ConnSecretData{}, fmt.Errorf("invalid endpoint or user") } - password, err := user.ReadPassword(ctx, c) + 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) } diff --git a/internal/controller/connsecrets-generic/endpoint_federation.go b/internal/controller/connsecrets-generic/endpoint_federation.go index 0490313dda..72570fc367 100644 --- a/internal/controller/connsecrets-generic/endpoint_federation.go +++ b/internal/controller/connsecrets-generic/endpoint_federation.go @@ -19,13 +19,11 @@ import ( "fmt" "strings" - "go.uber.org/zap" "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/atlas" "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" @@ -49,17 +47,17 @@ func (e FederationEndpoint) IsReady() bool { return e.obj != nil && api.HasReadyCondition(e.obj.Status.Conditions) } -func (e FederationEndpoint) GetProjectRef(ctx context.Context, r client.Reader) string { - return e.obj.Spec.Project.Name +func (e FederationEndpoint) GetProjectRef(ctx context.Context) string { + return "PROJECTREF" } -func (e FederationEndpoint) GetProjectID(ctx context.Context, r client.Reader) (string, error) { +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 := r.Get(ctx, kube.ObjectKey(e.obj.Namespace, e.obj.Spec.Project.Name), proj); err != nil { + if err := e.r.Client.Get(ctx, kube.ObjectKey(e.obj.Namespace, e.obj.Spec.Project.Name), proj); err != nil { return "", err } return proj.ID(), nil @@ -68,13 +66,13 @@ func (e FederationEndpoint) GetProjectID(ctx context.Context, r client.Reader) ( return "", fmt.Errorf("project ID not available") } -func (e FederationEndpoint) GetProjectName(ctx context.Context, r client.Reader, provider atlas.Provider, log *zap.SugaredLogger) (string, error) { +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 := r.Get(ctx, kube.ObjectKey(e.obj.Namespace, e.obj.Spec.Project.Name), proj); err != nil { + if err := e.r.Client.Get(ctx, kube.ObjectKey(e.obj.Namespace, e.obj.Spec.Project.Name), proj); err != nil { return "", err } if proj.Spec.Name != "" { @@ -108,26 +106,24 @@ func (e FederationEndpoint) ExtractList(ol client.ObjectList) ([]Endpoint, error return out, nil } -func (e FederationEndpoint) BuildConnData(ctx context.Context, c client.Client, provider atlas.Provider, log *zap.SugaredLogger, user *akov2.AtlasDatabaseUser) (ConnSecretData, error) { +func (e FederationEndpoint) BuildConnData(ctx context.Context, user *akov2.AtlasDatabaseUser) (ConnSecretData, error) { if user == nil || e.obj == nil { return ConnSecretData{}, fmt.Errorf("invalid endpoint or user") } - password, err := user.ReadPassword(ctx, c) + 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 := c.Get(ctx, e.obj.AtlasProjectObjectKey(), project); err != nil { + if err := e.r.Client.Get(ctx, e.obj.AtlasProjectObjectKey(), project); err != nil { return ConnSecretData{}, err } - - connectionConfig, err := reconciler.GetConnectionConfig(ctx, c, project.ConnectionSecretObjectKey(), &e.r.GlobalSecretRef) + 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, log) + clientSet, err := e.r.AtlasProvider.SdkClientSet(ctx, connectionConfig.Credentials, e.r.Log) if err != nil { return ConnSecretData{}, err } @@ -142,8 +138,8 @@ func (e FederationEndpoint) BuildConnData(ctx context.Context, c client.Client, return ConnSecretData{}, fmt.Errorf("no DF hostnames") } urls := make([]string, 0, len(df.Hostnames)) - for _, h := range df.Hostnames { - urls = append(urls, fmt.Sprintf("mongodb://%s:%s@%s?ssl=true", user.Spec.Username, password, h)) + for _, host := range df.Hostnames { + urls = append(urls, fmt.Sprintf("mongodb://%s:%s@%s?ssl=true", user.Spec.Username, password, host)) } return ConnSecretData{ 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), From 31a163bb0511ad3bb4d686d7ae5bef8982fc8ed6 Mon Sep 17 00:00:00 2001 From: andrpac Date: Thu, 14 Aug 2025 18:20:20 +0100 Subject: [PATCH 08/11] fix: gets the secrets --- internal/.DS_Store | Bin 0 -> 6148 bytes internal/controller/.DS_Store | Bin 0 -> 8196 bytes .../atlasdatafederation/connectionsecrets.go | 118 -------------- .../datafederation_controller.go | 4 - .../atlasdeployment_controller.go | 28 +--- .../connsecrets-generic/connectionsecret.go | 148 +++++++++--------- .../connectionsecret_controller.go | 52 +++--- .../endpoint_deployment.go | 20 ++- .../endpoint_federation.go | 28 ++-- .../connsecrets-generic/endpoint_user.go | 57 +++++++ 10 files changed, 186 insertions(+), 269 deletions(-) create mode 100644 internal/.DS_Store create mode 100644 internal/controller/.DS_Store delete mode 100644 internal/controller/atlasdatafederation/connectionsecrets.go create mode 100644 internal/controller/connsecrets-generic/endpoint_user.go diff --git a/internal/.DS_Store b/internal/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..9e237e78a1d8b20548f42099335c0a1c01fd64e0 GIT binary patch literal 6148 zcmeHKJ8Hu~5S>X>n50qZa<7mZEXFy3E?`K48zX_RQ>)6kalMHeKn17(6`%q)6|mk5+dKy{QUNMJ1+EI%_o2WIYvL5>pAG~c0e}tC zZdm&)0W6jP*2F0g8JGqY7*x#>LxYZd$-0_21qNL-hY!ssYfdQYPsjPi%SCG-BNd@-0V?pX6wqeh?RR*k?5&HJvtC=^2e{RI!_BaE3WB#|ptoae gtQ~K?DC&x>ala-`flf!>=|KJrm@YIbaBl^E0INC`#sB~S literal 0 HcmV?d00001 diff --git a/internal/controller/.DS_Store b/internal/controller/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..230015c040b1fbbd58ef391276b91aee1b998bd5 GIT binary patch literal 8196 zcmeHM!EO^V5FJA+MFdKrfGPn}exQx`gHV+NQpE*vhGdhbfixR!Hk4ja?)(G4z@KsF z1n-Sa7(1yd4xmc)R=cz7^?ROqGxo}?Qz z7n!{rmql+}_E!4lhO9tVAS;j+$O>cyu7(2mX7gxV@ZI-s^EE4w6}XlPaD8aur0oQ| z@K%=&TyzTnyMbmsaF2a}Vp_p=f?as4P;mO)g9}g>wiwRC@xI0Gu$^ER-g>w=4;L3k zc43F&badca+Fe}7+kDLmWCd0g;NE>xx>Cy+SG#}bM0$>M|EC@bef+1V^l)Fu2bs$h zwv$imUu3;^}H-k6itCV37qutX4eb1o#df!#wX7GVr z1E~^*FA2lW8ir%w`o!wH5j>eBEI$$!8wHV6qm*;RF@PdTEW<h9bpjRxIw3!LgFM z@S%_s9g{i!6m&U8OA-sSjC_Cd&0T`#!xamoa>eA@JG{iG! zu~uq2k2z-|*6rXoGXoWVqx6Y>rFtSSe}|vey`%Z8u8@Z#dE;jxu5I|pym5???1vhi ztM3)w9wMJy0m7V7#Hm)^5Z6BD^`232PgkEFxGJ!+T3PGur6z3cqVn4zT58TUX{?Ag ze7jT!#PKS`acN)hHJ7@wS_+M`UdaZkGg5PBJqoc7!J@MbF%@y8uvR0MEwI?NlUkH$ zVZ|nEsMh9ZQ=7NcQ$NQGjHL?2<7s7DOj9vK^yYe9p%(YyZyBRFqn$-2G zOF2vWb57g-D3mL4-1J`THep{}{QLiv@HIP;703$w8wK3P;p@W#aEHD1$|&4xw{X70 z$%FkC-YNta-HyZRb{uy34@2Cy(3O}@unTX|g5$sc5RkwBy044Ajrsd8*X{fN3r{Vb AumAu6 literal 0 HcmV?d00001 diff --git a/internal/controller/atlasdatafederation/connectionsecrets.go b/internal/controller/atlasdatafederation/connectionsecrets.go deleted file mode 100644 index 746b5292c0..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.ConnSecretData{ - 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/atlasdeployment_controller.go b/internal/controller/atlasdeployment/atlasdeployment_controller.go index 767153b5b7..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" @@ -238,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 @@ -252,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/connsecrets-generic/connectionsecret.go b/internal/controller/connsecrets-generic/connectionsecret.go index 304820c928..3dedd065f6 100644 --- a/internal/controller/connsecrets-generic/connectionsecret.go +++ b/internal/controller/connsecrets-generic/connectionsecret.go @@ -34,7 +34,6 @@ import ( "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" - "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/stringutil" ) const ( @@ -55,11 +54,8 @@ const ( ) var ( - ErrNoPairedResourcesFound = errors.New("no paired resources found") - ErrNoEndpointFound = errors.New("no endpoint found") - ErrNoUserFound = errors.New("no user found") - ErrManyEndpoints = errors.New("multiple endpoints found") - ErrManyUsers = errors.New("multiple users found") + ErrMissingPairing = errors.New("missing user/endpoint") + ErrAmbiguousPairing = errors.New("multiple users/endpoints with the same name found") ) type ConnSecretIdentifiers struct { @@ -150,33 +146,24 @@ func (r *ConnSecretReconciler) LoadIdentifiers(ctx context.Context, req types.Na }, nil } -func isMissingIndex(err error) bool { - if err == nil { - return false - } - s := strings.ToLower(err.Error()) - return strings.Contains(s, "index with name") && strings.Contains(s, "does not exist") -} - func (r *ConnSecretReconciler) LoadPair(ctx context.Context, ids *ConnSecretIdentifiers) (*ConnSecretPair, error) { 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 } + usersCount := len(users.Items) 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 { - if !isMissingIndex(err) { - return nil, err - } + return nil, err } - eps, err := kind.ExtractList(list) if err != nil { return nil, err @@ -187,32 +174,72 @@ func (r *ConnSecretReconciler) LoadPair(ctx context.Context, ids *ConnSecretIden totalEndpoints += len(eps) } - switch { - case totalEndpoints > 1: - return nil, ErrManyEndpoints - case len(users.Items) > 1: - return nil, ErrManyUsers - case totalEndpoints == 0 && len(users.Items) == 0: - return nil, ErrNoPairedResourcesFound - case totalEndpoints == 0: + // 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: nil, - }, ErrNoEndpointFound - case len(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, - }, ErrNoUserFound + }, ErrMissingPairing } - return &ConnSecretPair{ ProjectID: ids.ProjectID, User: &users.Items[0], - Endpoint: selected, - }, nil + Endpoint: nil, + }, ErrMissingPairing +} + +func (r *ConnSecretReconciler) resolveProject( + ctx context.Context, + ids *ConnSecretIdentifiers, + pair *ConnSecretPair, +) (string, error) { + if ids != nil && ids.ProjectName != "" { + return ids.ProjectName, nil + } + + if pair == nil { + return "", fmt.Errorf("project name cannot be resolved") + } + + var err error + var projectName string + if pair.Endpoint != nil { + projectName, err = pair.Endpoint.GetProjectName(ctx) + if projectName != "" { + return projectName, nil + } + } + + 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 = fmt.Errorf("project name cannot be resolved") + } + return "", err } func (r *ConnSecretReconciler) handleDelete( @@ -222,25 +249,15 @@ func (r *ConnSecretReconciler) handleDelete( pair *ConnSecretPair, ) (ctrl.Result, error) { log := r.Log.With("ns", req.Namespace, "name", req.Name) - projectName := ids.ProjectName - var err error - - if projectName == "" { - if pair == nil || pair.Endpoint == nil { - err = fmt.Errorf("endpoint is nil; cannot resolve project name for delete") - log.Errorw("failed to resolve project name", "reason", workflow.ConnSecretUnresolvedProjectName, "error", err) - return workflow.Terminate(workflow.ConnSecretUnresolvedProjectName, err).ReconcileResult() - } - projectName, err = pair.Endpoint.GetProjectName(ctx) // no shadowing - 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() - } + if pair == nil { + return workflow.TerminateSilently(nil).WithoutRetry().ReconcileResult() } + projectName, err := r.resolveProject(ctx, ids, pair) + 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") name := CreateK8sFormat(projectName, ids.ClusterName, ids.DatabaseUsername) @@ -271,27 +288,13 @@ func (r *ConnSecretReconciler) handleUpsert( ids *ConnSecretIdentifiers, pair *ConnSecretPair, ) (ctrl.Result, error) { - var err error log := r.Log.With("ns", req.Namespace, "name", req.Name) - projectName := ids.ProjectName - - if projectName == "" { - if pair == nil || pair.Endpoint == nil { - err = fmt.Errorf("endpoint is nil; cannot resolve project name") - log.Errorw("failed to resolve project name", "reason", workflow.ConnSecretFailedToResolveProjectName, "error", err) - return workflow.Terminate(workflow.ConnSecretFailedToResolveProjectName, err).ReconcileResult() - } - projectName, err = pair.Endpoint.GetProjectName(ctx) - if projectName == "" { - err = fmt.Errorf("project name is empty") - } - if err != nil { - log.Errorw("failed to resolve project name", "reason", workflow.ConnSecretFailedToResolveProjectName, "error", err) - return workflow.Terminate(workflow.ConnSecretFailedToResolveProjectName, err).ReconcileResult() - } + projectName, err := r.resolveProject(ctx, ids, pair) + if err != nil { + log.Errorw("failed to resolve project name", "reason", workflow.ConnSecretFailedToResolveProjectName, "error", err) + return workflow.Terminate(workflow.ConnSecretFailedToResolveProjectName, err).ReconcileResult() } - ids.ProjectName = projectName log.Debugw("project name resolved for upsert", "projectName", projectName) @@ -420,12 +423,3 @@ func CreateURL(connURL, username, password string) (string, error) { u.User = url.UserPassword(username, password) return u.String(), nil } - -func allowsByScopes(u *akov2.AtlasDatabaseUser, epName string) bool { - scopes := u.GetScopes(akov2.DeploymentScopeType) - if len(scopes) == 0 || stringutil.Contains(scopes, epName) { - return true - } - - return false -} diff --git a/internal/controller/connsecrets-generic/connectionsecret_controller.go b/internal/controller/connsecrets-generic/connectionsecret_controller.go index a930e7e769..b9b63bb897 100644 --- a/internal/controller/connsecrets-generic/connectionsecret_controller.go +++ b/internal/controller/connsecrets-generic/connectionsecret_controller.go @@ -43,6 +43,7 @@ import ( "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" ) @@ -52,13 +53,13 @@ type ConnSecretReconciler struct { Scheme *runtime.Scheme EventRecorder record.EventRecorder GlobalPredicates []predicate.Predicate - EndpointKinds []Endpoint // Register all kinds of endpoints + EndpointKinds []Endpoint } type Endpoint interface { GetName() string IsReady() bool - GetProjectRef(ctx context.Context) string + GetScopeType() akov2.ScopeType GetProjectID(ctx context.Context) (string, error) GetProjectName(ctx context.Context) (string, error) @@ -78,6 +79,7 @@ type ConnSecretPair struct { func (r *ConnSecretReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { log := r.Log.With("ns", req.Namespace, "name", req.Name) + r.Log.Infof("Start reconciliation for %s\n", req.NamespacedName.String()) ids, err := r.LoadIdentifiers(ctx, req.NamespacedName) if err != nil { @@ -92,11 +94,9 @@ func (r *ConnSecretReconciler) Reconcile(ctx context.Context, req ctrl.Request) pair, err := r.LoadPair(ctx, ids) if err != nil { switch { - case errors.Is(err, ErrNoPairedResourcesFound): - return workflow.TerminateSilently(nil).WithoutRetry().ReconcileResult() - case errors.Is(err, ErrNoEndpointFound), errors.Is(err, ErrNoUserFound): + case errors.Is(err, ErrMissingPairing): return r.handleDelete(ctx, req, ids, pair) - case errors.Is(err, ErrManyEndpoints), errors.Is(err, ErrManyUsers): + case errors.Is(err, ErrAmbiguousPairing): return workflow.Terminate("", err).ReconcileResult() default: return workflow.Terminate("", err).ReconcileResult() @@ -114,11 +114,7 @@ func (r *ConnSecretReconciler) Reconcile(ctx context.Context, req ctrl.Request) return r.handleDelete(ctx, req, ids, pair) } - if pair.Endpoint == nil { - return r.handleDelete(ctx, req, ids, pair) - } - - if !allowsByScopes(pair.User, pair.Endpoint.GetName()) { + if !allowsByScopes(pair.User, pair.Endpoint.GetName(), pair.Endpoint.GetScopeType()) { r.Log.Infow("invalid scope; scheduling deletion", "reason", workflow.ConnSecretInvalidScopes) return r.handleDelete(ctx, req, ids, pair) } @@ -181,9 +177,10 @@ func (r *ConnSecretReconciler) generateConnectionSecretRequests(projectID string var reqs []reconcile.Request for _, ep := range endpoints { for _, u := range users { - if !allowsByScopes(&u, ep.GetName()) { + 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}, @@ -193,16 +190,27 @@ func (r *ConnSecretReconciler) generateConnectionSecretRequests(projectID string return reqs } -func (r *ConnSecretReconciler) ResolveProjectId(ctx context.Context, ref akov2.ProjectDualReference, parentNamespace string) (string, string, error) { +func allowsByScopes(u *akov2.AtlasDatabaseUser, epName string, epType akov2.ScopeType) bool { + scopes := u.GetScopes(epType) + total_len := len(u.GetScopes(akov2.DataLakeScopeType)) + len(u.GetScopes(akov2.DeploymentScopeType)) + if total_len == 0 || stringutil.Contains(scopes, epName) { + return true + } + + return false +} + +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{} - if err := r.Client.Get(ctx, *ref.ProjectRef.GetObject(parentNamespace), project); err != nil { + 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 ref.ProjectRef.GetObject(parentNamespace).String(), project.ID(), nil + return key.String(), project.ID(), nil } return "", "", fmt.Errorf("missing both external and internal project references") } @@ -210,11 +218,13 @@ func (r *ConnSecretReconciler) ResolveProjectId(ctx context.Context, ref akov2.P func (r *ConnSecretReconciler) listEndpointsByProject(ctx context.Context, projectRef string, projectID string) ([]Endpoint, error) { var out []Endpoint for _, kind := range r.EndpointKinds { - ref := kind.GetProjectRef(ctx) - if ref == "PROJECTID" { - ref = projectID // ProjectID used by deployment + 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 // ProjectRef used by federation + ref = projectRef } list := kind.ListObj() @@ -266,7 +276,7 @@ func (r *ConnSecretReconciler) newDatabaseUserMapFunc(ctx context.Context, obj c if !ok { return nil } - projectRef, projectID, err := r.ResolveProjectId(ctx, u.Spec.ProjectDualReference, u.GetNamespace()) + projectRef, projectID, err := r.parseProject(ctx, u.Spec.ProjectDualReference, u.GetNamespace()) if err != nil { return nil } @@ -299,7 +309,7 @@ func NewConnectionSecretReconciler( GlobalPredicates: predicates, } - // Register kinds to try (order matters) + // Register all the endpoint types r.EndpointKinds = []Endpoint{ DeploymentEndpoint{r: r}, FederationEndpoint{r: r}, diff --git a/internal/controller/connsecrets-generic/endpoint_deployment.go b/internal/controller/connsecrets-generic/endpoint_deployment.go index b65c9ec679..808e18a32f 100644 --- a/internal/controller/connsecrets-generic/endpoint_deployment.go +++ b/internal/controller/connsecrets-generic/endpoint_deployment.go @@ -32,7 +32,6 @@ type DeploymentEndpoint struct { r *ConnSecretReconciler } -// ---- instance methods ---- func (e DeploymentEndpoint) GetName() string { if e.obj == nil { return "" @@ -44,8 +43,8 @@ func (e DeploymentEndpoint) IsReady() bool { return e.obj != nil && api.HasReadyCondition(e.obj.Status.Conditions) } -func (e DeploymentEndpoint) GetProjectRef(ctx context.Context) string { - return "PROJECTID" +func (e DeploymentEndpoint) GetScopeType() akov2.ScopeType { + return akov2.DeploymentScopeType } func (e DeploymentEndpoint) GetProjectID(ctx context.Context) (string, error) { @@ -55,14 +54,14 @@ func (e DeploymentEndpoint) GetProjectID(ctx context.Context) (string, error) { 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 != "" { + 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, kube.ObjectKey(e.obj.Namespace, e.obj.Spec.ProjectRef.Name), proj); err != nil { + key := e.obj.Spec.ProjectRef.GetObject(e.obj.GetNamespace()) + if err := e.r.Client.Get(ctx, *key, proj); err != nil { return "", err } return proj.ID(), nil } - return "", fmt.Errorf("project ID not available") } @@ -70,16 +69,17 @@ 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 != "" { + 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, kube.ObjectKey(e.obj.Namespace, e.obj.Spec.ProjectRef.Name), proj); err != nil { + key := e.obj.Spec.ProjectRef.GetObject(e.obj.GetNamespace()) + if err := e.r.Client.Get(ctx, *key, proj); err != nil { return "", err } if proj.Spec.Name != "" { return kube.NormalizeIdentifier(proj.Spec.Name), nil } } - // SDK fallback (optional) + if e.r != nil { cfg, err := e.r.ResolveConnectionConfig(ctx, e.obj) if err != nil { @@ -98,7 +98,6 @@ func (e DeploymentEndpoint) GetProjectName(ctx context.Context) (string, error) return "", fmt.Errorf("project name not available") } -// ---- indexer methods ---- func (DeploymentEndpoint) ListObj() client.ObjectList { return &akov2.AtlasDeploymentList{} } func (DeploymentEndpoint) SelectorByProject(projectRef string) fields.Selector { @@ -116,7 +115,6 @@ func (e DeploymentEndpoint) ExtractList(ol client.ObjectList) ([]Endpoint, error } out := make([]Endpoint, 0, len(l.Items)) for i := range l.Items { - // wrap each item as an Endpoint object out = append(out, DeploymentEndpoint{obj: &l.Items[i], r: e.r}) } return out, nil diff --git a/internal/controller/connsecrets-generic/endpoint_federation.go b/internal/controller/connsecrets-generic/endpoint_federation.go index 72570fc367..3f412824a6 100644 --- a/internal/controller/connsecrets-generic/endpoint_federation.go +++ b/internal/controller/connsecrets-generic/endpoint_federation.go @@ -17,6 +17,7 @@ package connsecretsgeneric import ( "context" "fmt" + "net/url" "strings" "k8s.io/apimachinery/pkg/fields" @@ -35,7 +36,6 @@ type FederationEndpoint struct { r *ConnSecretReconciler } -// ---- instance methods ---- func (e FederationEndpoint) GetName() string { if e.obj == nil { return "" @@ -47,17 +47,17 @@ func (e FederationEndpoint) IsReady() bool { return e.obj != nil && api.HasReadyCondition(e.obj.Status.Conditions) } -func (e FederationEndpoint) GetProjectRef(ctx context.Context) string { - return "PROJECTREF" +func (e FederationEndpoint) GetScopeType() akov2.ScopeType { + return akov2.DataLakeScopeType } - 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, kube.ObjectKey(e.obj.Namespace, e.obj.Spec.Project.Name), proj); err != nil { + key := e.obj.Spec.Project.GetObject(e.obj.GetNamespace()) + if err := e.r.Client.Get(ctx, *key, proj); err != nil { return "", err } return proj.ID(), nil @@ -72,7 +72,8 @@ func (e FederationEndpoint) GetProjectName(ctx context.Context) (string, error) } if e.obj.Spec.Project.Name != "" { proj := &akov2.AtlasProject{} - if err := e.r.Client.Get(ctx, kube.ObjectKey(e.obj.Namespace, e.obj.Spec.Project.Name), proj); err != nil { + key := e.obj.Spec.Project.GetObject(e.obj.GetNamespace()) + if err := e.r.Client.Get(ctx, *key, proj); err != nil { return "", err } if proj.Spec.Name != "" { @@ -83,7 +84,6 @@ func (e FederationEndpoint) GetProjectName(ctx context.Context) (string, error) return "", fmt.Errorf("project name not available") } -// ---- indexer methods ---- func (FederationEndpoint) ListObj() client.ObjectList { return &akov2.AtlasDataFederationList{} } func (FederationEndpoint) SelectorByProject(projectRef string) fields.Selector { @@ -119,6 +119,7 @@ func (e FederationEndpoint) BuildConnData(ctx context.Context, user *akov2.Atlas 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 @@ -137,14 +138,19 @@ func (e FederationEndpoint) BuildConnData(ctx context.Context, user *akov2.Atlas if len(df.Hostnames) == 0 { return ConnSecretData{}, fmt.Errorf("no DF hostnames") } - urls := make([]string, 0, len(df.Hostnames)) - for _, host := range df.Hostnames { - urls = append(urls, fmt.Sprintf("mongodb://%s:%s@%s?ssl=true", user.Spec.Username, password, host)) + + // mongodb://host1,host2,hoss3/user@password.com + 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: strings.Join(urls, ","), + ConnURL: u.String(), }, nil } diff --git a/internal/controller/connsecrets-generic/endpoint_user.go b/internal/controller/connsecrets-generic/endpoint_user.go new file mode 100644 index 0000000000..d632ccd268 --- /dev/null +++ b/internal/controller/connsecrets-generic/endpoint_user.go @@ -0,0 +1,57 @@ +// 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" +) + +func (r *ConnSecretReconciler) GetUserProjectName(ctx context.Context, user *akov2.AtlasDatabaseUser) (string, error) { + if user == nil { + return "", fmt.Errorf("nil deployment") + } + if user.Spec.ProjectRef != nil && user.Spec.ProjectRef.Name != "" { + proj := &akov2.AtlasProject{} + key := user.Spec.ProjectRef.GetObject(user.GetNamespace()) + if err := r.Client.Get(ctx, *key, proj); err != nil { + return "", err + } + if proj.Spec.Name != "" { + return kube.NormalizeIdentifier(proj.Spec.Name), nil + } + } + + if r != 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 + } + return kube.NormalizeIdentifier(ap.Name), nil + } + + return "", fmt.Errorf("project name not available") +} From 7220fef5267816dd67e749ff9043c6677a7cfe3a Mon Sep 17 00:00:00 2001 From: andrpac Date: Fri, 15 Aug 2025 13:24:28 +0100 Subject: [PATCH 09/11] chore: cleanup and adding comments --- .../connsecrets-generic/connectionsecret.go | 93 ++++++++++++------- .../connectionsecret_controller.go | 65 ++++++++----- 2 files changed, 102 insertions(+), 56 deletions(-) diff --git a/internal/controller/connsecrets-generic/connectionsecret.go b/internal/controller/connsecrets-generic/connectionsecret.go index 3dedd065f6..3113f7a60f 100644 --- a/internal/controller/connsecrets-generic/connectionsecret.go +++ b/internal/controller/connsecrets-generic/connectionsecret.go @@ -54,10 +54,14 @@ const ( ) var ( - ErrMissingPairing = errors.New("missing user/endpoint") - ErrAmbiguousPairing = errors.New("multiple users/endpoints with the same name found") + 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") ) +// 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 @@ -65,6 +69,8 @@ type ConnSecretIdentifiers struct { 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 @@ -73,6 +79,8 @@ type ConnSecretData struct { 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 @@ -97,24 +105,37 @@ func CreateInternalFormat(projectID string, clusterName string, databaseUsername }, InternalSeparator) } -func (r *ConnSecretReconciler) LoadIdentifiers(ctx context.Context, req types.NamespacedName) (*ConnSecretIdentifiers, error) { - // === Internal format: $$ +// 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) { - parts := strings.Split(req.Name, InternalSeparator) - if len(parts) != 3 { - return nil, fmt.Errorf("internal format expected 3 parts separated by %q", InternalSeparator) - } - if parts[0] == "" || parts[1] == "" || parts[2] == "" { - return nil, fmt.Errorf("internal format got empty value in one or more parts") - } - return &ConnSecretIdentifiers{ - ProjectID: parts[0], - ClusterName: parts[1], - DatabaseUsername: parts[2], - }, nil + return r.indetifiersFromInternalName(req) } - // === K8s format: -- + 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 @@ -123,21 +144,19 @@ func (r *ConnSecretReconciler) LoadIdentifiers(ctx context.Context, req types.Na projectID, hasProject := labels[ProjectLabelKey] clusterName, hasCluster := labels[ClusterLabelKey] if !hasProject || !hasCluster { - return nil, fmt.Errorf("k8s format got a missing required label(s)") + return nil, ErrK8SFormatErr } if projectID == "" || clusterName == "" { - return nil, fmt.Errorf("k8s format got label present but empty") + return nil, ErrK8SFormatErr } - sep := fmt.Sprintf("-%s-", clusterName) - parts := strings.SplitN(req.Name, sep, 2) + parts := strings.Split(req.Name, sep) if len(parts) != 2 { - return nil, fmt.Errorf("k8s format expected to separate across -%s-", clusterName) + return nil, ErrK8SFormatErr } if parts[0] == "" || parts[1] == "" { - return nil, fmt.Errorf("k8s format got empty value in one or more parts") + return nil, ErrK8SFormatErr } - return &ConnSecretIdentifiers{ ProjectID: projectID, ProjectName: parts[0], @@ -146,9 +165,12 @@ func (r *ConnSecretReconciler) LoadIdentifiers(ctx context.Context, req types.Na }, nil } -func (r *ConnSecretReconciler) LoadPair(ctx context.Context, ids *ConnSecretIdentifiers) (*ConnSecretPair, error) { +// 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), @@ -157,6 +179,7 @@ func (r *ConnSecretReconciler) LoadPair(ctx context.Context, ids *ConnSecretIden } usersCount := len(users.Items) + // Retrieve the Endpoints using the defined indexers totalEndpoints := 0 var selected Endpoint for _, kind := range r.EndpointKinds { @@ -206,7 +229,9 @@ func (r *ConnSecretReconciler) LoadPair(ctx context.Context, ids *ConnSecretIden }, ErrMissingPairing } -func (r *ConnSecretReconciler) resolveProject( +// 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, @@ -215,12 +240,14 @@ func (r *ConnSecretReconciler) resolveProject( return ids.ProjectName, nil } + // project name resolution requires at least on parent to be available if pair == nil { return "", fmt.Errorf("project name cannot be resolved") } 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 != "" { @@ -228,6 +255,7 @@ func (r *ConnSecretReconciler) resolveProject( } } + // Fallback, try resolving from the User if present if pair.User != nil { if name, uerr := r.GetUserProjectName(ctx, pair.User); name != "" { return name, nil @@ -242,6 +270,7 @@ func (r *ConnSecretReconciler) resolveProject( 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, @@ -249,17 +278,17 @@ func (r *ConnSecretReconciler) handleDelete( pair *ConnSecretPair, ) (ctrl.Result, error) { log := r.Log.With("ns", req.Namespace, "name", req.Name) + if pair == nil { return workflow.TerminateSilently(nil).WithoutRetry().ReconcileResult() } - projectName, err := r.resolveProject(ctx, ids, pair) + // project name is required for metadata.name + projectName, err := r.resolveProjectName(ctx, ids, pair) 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") - name := CreateK8sFormat(projectName, ids.ClusterName, ids.DatabaseUsername) secret := &corev1.Secret{ ObjectMeta: metav1.ObjectMeta{ @@ -268,6 +297,7 @@ func (r *ConnSecretReconciler) handleDelete( }, } + // delete from K8s if err := r.Client.Delete(ctx, secret); err != nil { if apiErrors.IsNotFound(err) { log.Debugw("no secret to delete; already gone") @@ -277,11 +307,12 @@ func (r *ConnSecretReconciler) handleDelete( return workflow.Terminate(workflow.ConnSecretFailedDeletion, err).ReconcileResult() } - log.Infow("secret deleted", "reason", workflow.ConnSecretDeleted) + 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, @@ -290,7 +321,7 @@ func (r *ConnSecretReconciler) handleUpsert( ) (ctrl.Result, error) { log := r.Log.With("ns", req.Namespace, "name", req.Name) - projectName, err := r.resolveProject(ctx, ids, pair) + projectName, err := r.resolveProjectName(ctx, ids, pair) if err != nil { log.Errorw("failed to resolve project name", "reason", workflow.ConnSecretFailedToResolveProjectName, "error", err) return workflow.Terminate(workflow.ConnSecretFailedToResolveProjectName, err).ReconcileResult() diff --git a/internal/controller/connsecrets-generic/connectionsecret_controller.go b/internal/controller/connsecrets-generic/connectionsecret_controller.go index b9b63bb897..87b0c76dcf 100644 --- a/internal/controller/connsecrets-generic/connectionsecret_controller.go +++ b/internal/controller/connsecrets-generic/connectionsecret_controller.go @@ -53,9 +53,10 @@ type ConnSecretReconciler struct { Scheme *runtime.Scheme EventRecorder record.EventRecorder GlobalPredicates []predicate.Predicate - EndpointKinds []Endpoint + EndpointKinds []Endpoint // Endpoints are generic } +// Each endpoint would have to implement this interface (e.g. AtlasDeployment, AtlasDataFederation) type Endpoint interface { GetName() string IsReady() bool @@ -71,6 +72,7 @@ type Endpoint interface { 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 @@ -78,52 +80,59 @@ type ConnSecretPair struct { } func (r *ConnSecretReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { - log := r.Log.With("ns", req.Namespace, "name", req.Name) - r.Log.Infof("Start reconciliation for %s\n", req.NamespacedName.String()) + log := r.Log.With("namespace", req.Namespace, "name", req.Name) + log.Info("reconciliation started") - ids, err := r.LoadIdentifiers(ctx, req.NamespacedName) + // Parse the request and load up the identifiers + ids, err := r.loadIdentifiers(ctx, req.NamespacedName) if err != nil { if apiErrors.IsNotFound(err) { - log.Debugw("connectionsecret not found; assuming deleted") + log.Debugw("Connection secret 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("", err).ReconcileResult() + log.Errorw("failed to parse connection secret request", "error", err) + return workflow.Terminate(workflow.ConnSecretInvalidName, err).ReconcileResult() } - pair, err := r.LoadPair(ctx, ids) + // 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): - return workflow.Terminate("", err).ReconcileResult() + 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 { - if pair.Endpoint == nil { - return workflow.TerminateSilently(nil).WithoutRetry().ReconcileResult() - } + 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()) { - r.Log.Infow("invalid scope; scheduling deletion", "reason", workflow.ConnSecretInvalidScopes) + 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, "not ready").ReconcileResult() } - r.Log.Infow("creating/updating connection secret", "reason", workflow.ConnSecretUpsert) return r.handleUpsert(ctx, req, ids, pair) } @@ -173,6 +182,17 @@ func (r *ConnSecretReconciler) SetupWithManager(mgr ctrl.Manager, skipNameValida Complete(r) } +// TODO: change this function according to Helders feedback +func allowsByScopes(u *akov2.AtlasDatabaseUser, epName string, epType akov2.ScopeType) bool { + scopes := u.GetScopes(epType) + total_len := len(u.GetScopes(akov2.DataLakeScopeType)) + len(u.GetScopes(akov2.DeploymentScopeType)) + if total_len == 0 || stringutil.Contains(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 { @@ -190,16 +210,7 @@ func (r *ConnSecretReconciler) generateConnectionSecretRequests(projectID string return reqs } -func allowsByScopes(u *akov2.AtlasDatabaseUser, epName string, epType akov2.ScopeType) bool { - scopes := u.GetScopes(epType) - total_len := len(u.GetScopes(akov2.DataLakeScopeType)) + len(u.GetScopes(akov2.DeploymentScopeType)) - if total_len == 0 || stringutil.Contains(scopes, epName) { - return true - } - - return false -} - +// 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 @@ -215,6 +226,7 @@ func (r *ConnSecretReconciler) parseProject(ctx context.Context, ref akov2.Proje 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 { @@ -245,8 +257,11 @@ func (r *ConnSecretReconciler) listEndpointsByProject(ctx context.Context, proje 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} @@ -271,6 +286,7 @@ func (r *ConnSecretReconciler) newEndpointMapFunc(ctx context.Context, obj clien 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 { @@ -281,7 +297,6 @@ func (r *ConnSecretReconciler) newDatabaseUserMapFunc(ctx context.Context, obj c return nil } - // The user should connect to all endpoint types endpoints, err := r.listEndpointsByProject(ctx, projectRef, projectID) if err != nil { return nil From d4c80f912e124a1f72c21dd43a7e40912956be8d Mon Sep 17 00:00:00 2001 From: andrpac Date: Fri, 15 Aug 2025 13:59:53 +0100 Subject: [PATCH 10/11] chore: major cleanup done (stable) --- internal/.DS_Store | Bin 6148 -> 0 bytes internal/controller/.DS_Store | Bin 8196 -> 0 bytes .../connsecrets-generic/connectionsecret.go | 37 ++- .../endpoint_deployment.go | 11 + .../endpoint_federation.go | 13 +- .../connsecrets-generic/endpoint_user.go | 4 +- internal/controller/connsecrets/connsecret.go | 296 ------------------ .../connsecrets/connsecret_controller.go | 229 -------------- internal/controller/connsecrets/endpoints.go | 98 ------ internal/controller/connsecrets/strategy.go | 171 ---------- 10 files changed, 46 insertions(+), 813 deletions(-) delete mode 100644 internal/.DS_Store delete mode 100644 internal/controller/.DS_Store delete mode 100644 internal/controller/connsecrets/connsecret.go delete mode 100644 internal/controller/connsecrets/connsecret_controller.go delete mode 100644 internal/controller/connsecrets/endpoints.go delete mode 100644 internal/controller/connsecrets/strategy.go diff --git a/internal/.DS_Store b/internal/.DS_Store deleted file mode 100644 index 9e237e78a1d8b20548f42099335c0a1c01fd64e0..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6148 zcmeHKJ8Hu~5S>X>n50qZa<7mZEXFy3E?`K48zX_RQ>)6kalMHeKn17(6`%q)6|mk5+dKy{QUNMJ1+EI%_o2WIYvL5>pAG~c0e}tC zZdm&)0W6jP*2F0g8JGqY7*x#>LxYZd$-0_21qNL-hY!ssYfdQYPsjPi%SCG-BNd@-0V?pX6wqeh?RR*k?5&HJvtC=^2e{RI!_BaE3WB#|ptoae gtQ~K?DC&x>ala-`flf!>=|KJrm@YIbaBl^E0INC`#sB~S diff --git a/internal/controller/.DS_Store b/internal/controller/.DS_Store deleted file mode 100644 index 230015c040b1fbbd58ef391276b91aee1b998bd5..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8196 zcmeHM!EO^V5FJA+MFdKrfGPn}exQx`gHV+NQpE*vhGdhbfixR!Hk4ja?)(G4z@KsF z1n-Sa7(1yd4xmc)R=cz7^?ROqGxo}?Qz z7n!{rmql+}_E!4lhO9tVAS;j+$O>cyu7(2mX7gxV@ZI-s^EE4w6}XlPaD8aur0oQ| z@K%=&TyzTnyMbmsaF2a}Vp_p=f?as4P;mO)g9}g>wiwRC@xI0Gu$^ER-g>w=4;L3k zc43F&badca+Fe}7+kDLmWCd0g;NE>xx>Cy+SG#}bM0$>M|EC@bef+1V^l)Fu2bs$h zwv$imUu3;^}H-k6itCV37qutX4eb1o#df!#wX7GVr z1E~^*FA2lW8ir%w`o!wH5j>eBEI$$!8wHV6qm*;RF@PdTEW<h9bpjRxIw3!LgFM z@S%_s9g{i!6m&U8OA-sSjC_Cd&0T`#!xamoa>eA@JG{iG! zu~uq2k2z-|*6rXoGXoWVqx6Y>rFtSSe}|vey`%Z8u8@Z#dE;jxu5I|pym5???1vhi ztM3)w9wMJy0m7V7#Hm)^5Z6BD^`232PgkEFxGJ!+T3PGur6z3cqVn4zT58TUX{?Ag ze7jT!#PKS`acN)hHJ7@wS_+M`UdaZkGg5PBJqoc7!J@MbF%@y8uvR0MEwI?NlUkH$ zVZ|nEsMh9ZQ=7NcQ$NQGjHL?2<7s7DOj9vK^yYe9p%(YyZyBRFqn$-2G zOF2vWb57g-D3mL4-1J`THep{}{QLiv@HIP;703$w8wK3P;p@W#aEHD1$|&4xw{X70 z$%FkC-YNta-HyZRb{uy34@2Cy(3O}@unTX|g5$sc5RkwBy044Ajrsd8*X{fN3r{Vb AumAu6 diff --git a/internal/controller/connsecrets-generic/connectionsecret.go b/internal/controller/connsecrets-generic/connectionsecret.go index 3113f7a60f..624e221ecb 100644 --- a/internal/controller/connsecrets-generic/connectionsecret.go +++ b/internal/controller/connsecrets-generic/connectionsecret.go @@ -78,9 +78,6 @@ type ConnSecretData struct { 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 @@ -286,7 +283,7 @@ func (r *ConnSecretReconciler) handleDelete( // project name is required for metadata.name projectName, err := r.resolveProjectName(ctx, ids, pair) if err != nil { - log.Errorw("failed to resolve project name", "reason", workflow.ConnSecretUnresolvedProjectName, "error", err) + log.Errorw("failed to resolve project name", "error", err) return workflow.Terminate(workflow.ConnSecretUnresolvedProjectName, err).ReconcileResult() } name := CreateK8sFormat(projectName, ids.ClusterName, ids.DatabaseUsername) @@ -297,7 +294,7 @@ func (r *ConnSecretReconciler) handleDelete( }, } - // delete from K8s + // 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") @@ -321,26 +318,27 @@ func (r *ConnSecretReconciler) handleUpsert( ) (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", "reason", workflow.ConnSecretFailedToResolveProjectName, "error", err) + 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", "reason", workflow.ConnSecretFailedToBuildData, "error", err) + 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("secret upserted", "reason", workflow.ConnSecretUpsert) + log.Infow("connection secret upserted") return workflow.OK().ReconcileResult() } @@ -362,16 +360,19 @@ func (r *ConnSecretReconciler) ensureSecret( }, } + // 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{} @@ -392,25 +393,26 @@ func (r *ConnSecretReconciler) ensureSecret( 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 { + if data.ConnURL, err = createURL(data.ConnURL, username, password); err != nil { return err } - if data.SrvConnURL, err = CreateURL(data.SrvConnURL, username, password); err != nil { + 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 { + 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 { + 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 { + if data.PrivateConnURLs[i].PvtShardConnURL, err = createURL(pe.PvtShardConnURL, username, password); err != nil { return err } } @@ -443,11 +445,12 @@ func fillConnSecretData(secret *corev1.Secret, ids *ConnSecretIdentifiers, data return nil } -func CreateURL(connURL, username, password string) (string, error) { - if connURL == "" { +// 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(connURL) + u, err := url.Parse(hostname) if err != nil { return "", err } diff --git a/internal/controller/connsecrets-generic/endpoint_deployment.go b/internal/controller/connsecrets-generic/endpoint_deployment.go index 808e18a32f..48aabb06a6 100644 --- a/internal/controller/connsecrets-generic/endpoint_deployment.go +++ b/internal/controller/connsecrets-generic/endpoint_deployment.go @@ -32,6 +32,7 @@ type DeploymentEndpoint struct { r *ConnSecretReconciler } +// GetName resolves the endpoints name from the spec func (e DeploymentEndpoint) GetName() string { if e.obj == nil { return "" @@ -39,14 +40,17 @@ func (e DeploymentEndpoint) GetName() string { 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") @@ -65,6 +69,7 @@ func (e DeploymentEndpoint) GetProjectID(ctx context.Context) (string, error) { return "", fmt.Errorf("project ID not available") } +// 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") @@ -98,16 +103,20 @@ func (e DeploymentEndpoint) GetProjectName(ctx context.Context) (string, error) return "", fmt.Errorf("project name not available") } +// 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 { @@ -120,6 +129,8 @@ func (e DeploymentEndpoint) ExtractList(ol client.ObjectList) ([]Endpoint, error 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{}, fmt.Errorf("invalid endpoint or user") diff --git a/internal/controller/connsecrets-generic/endpoint_federation.go b/internal/controller/connsecrets-generic/endpoint_federation.go index 3f412824a6..7b868b53a9 100644 --- a/internal/controller/connsecrets-generic/endpoint_federation.go +++ b/internal/controller/connsecrets-generic/endpoint_federation.go @@ -36,6 +36,7 @@ type FederationEndpoint struct { r *ConnSecretReconciler } +// GetName resolves the endpoints name from the spec func (e FederationEndpoint) GetName() string { if e.obj == nil { return "" @@ -43,13 +44,17 @@ func (e FederationEndpoint) GetName() string { 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") @@ -66,6 +71,7 @@ func (e FederationEndpoint) GetProjectID(ctx context.Context) (string, error) { return "", fmt.Errorf("project ID not available") } +// 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") @@ -84,16 +90,20 @@ func (e FederationEndpoint) GetProjectName(ctx context.Context) (string, error) return "", fmt.Errorf("project name not available") } +// 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 { @@ -106,6 +116,8 @@ func (e FederationEndpoint) ExtractList(ol client.ObjectList) ([]Endpoint, error 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{}, fmt.Errorf("invalid endpoint or user") @@ -139,7 +151,6 @@ func (e FederationEndpoint) BuildConnData(ctx context.Context, user *akov2.Atlas return ConnSecretData{}, fmt.Errorf("no DF hostnames") } - // mongodb://host1,host2,hoss3/user@password.com hostlist := strings.Join(df.Hostnames, ",") u := &url.URL{ Scheme: "mongodb", diff --git a/internal/controller/connsecrets-generic/endpoint_user.go b/internal/controller/connsecrets-generic/endpoint_user.go index d632ccd268..a6a41f3297 100644 --- a/internal/controller/connsecrets-generic/endpoint_user.go +++ b/internal/controller/connsecrets-generic/endpoint_user.go @@ -22,10 +22,12 @@ import ( "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 deployment") + return "", fmt.Errorf("nil user") } + if user.Spec.ProjectRef != nil && user.Spec.ProjectRef.Name != "" { proj := &akov2.AtlasProject{} key := user.Spec.ProjectRef.GetObject(user.GetNamespace()) diff --git a/internal/controller/connsecrets/connsecret.go b/internal/controller/connsecrets/connsecret.go deleted file mode 100644 index 34f943dbf0..0000000000 --- a/internal/controller/connsecrets/connsecret.go +++ /dev/null @@ -1,296 +0,0 @@ -package connsecrets - -// import ( -// "context" -// "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/types" -// ctrl "sigs.k8s.io/controller-runtime" -// "sigs.k8s.io/controller-runtime/pkg/client" - -// "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/controller/workflow" -// "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" - -// userNameKey string = "username" -// passwordKey string = "password" -// standardKey string = "connectionStringStandard" -// standardKeySrv string = "connectionStringStandardSrv" -// privateKey string = "connectionStringPrivate" -// privateSrvKey string = "connectionStringPrivateSrv" -// privateShardKey string = "connectionStringPrivateShard" -// ) - -// type ConnSecretIdentifiers struct { -// ProjectID string -// ProjectName string -// ClusterName string -// DatabaseUsername 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) -// } - -// func (r *ConnSecretReconciler) LoadIdentifiers(ctx context.Context, req types.NamespacedName) (*ConnSecretIdentifiers, error) { -// // === Internal format: $$ -// if strings.Contains(req.Name, InternalSeparator) { -// parts := strings.Split(req.Name, InternalSeparator) -// if len(parts) != 3 { -// return nil, fmt.Errorf("internal format expected 3 parts separated by %q", InternalSeparator) -// } -// if parts[0] == "" || parts[1] == "" || parts[2] == "" { -// return nil, fmt.Errorf("internal format got empty value in one or more parts") -// } -// return &ConnSecretIdentifiers{ -// ProjectID: parts[0], -// ClusterName: parts[1], -// DatabaseUsername: parts[2], -// }, nil -// } - -// // === 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] -// if !hasProject || !hasCluster { -// return nil, fmt.Errorf("k8s format got a missing required label(s)") -// } -// if projectID == "" || clusterName == "" { -// return nil, fmt.Errorf("k8s format got label present but empty") -// } - -// sep := fmt.Sprintf("-%s-", clusterName) -// parts := strings.SplitN(req.Name, sep, 2) -// if len(parts) != 2 { -// return nil, fmt.Errorf("k8s format expected to separate across --") -// } -// if parts[0] == "" || parts[1] == "" { -// return nil, fmt.Errorf("k8s format got empty value in one or more parts") -// } - -// return &ConnSecretIdentifiers{ -// ProjectID: projectID, -// ProjectName: parts[0], -// ClusterName: clusterName, -// DatabaseUsername: parts[1], -// }, nil -// } - -// // handleDelete manages the case where we will delete the connection secret -// func (r *ConnSecretReconciler) handleDelete( -// ctx context.Context, -// req ctrl.Request, -// ids *ConnSecretIdentifiers, -// pair *ConnSecretPair[any], -// strategy AnyEndpointStrategy, -// ) (ctrl.Result, error) { -// log := r.Log.With("ns", req.Namespace, "name", req.Name) - -// projectName, err := strategy.ResolveProjectName(ctx, 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") - -// name := CreateK8sFormat(projectName, ids.ClusterName, ids.DatabaseUsername) -// secret := &corev1.Secret{ -// ObjectMeta: metav1.ObjectMeta{ -// Name: name, -// Namespace: req.Namespace, -// }, -// } - -// 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.Infow("secret deleted", "reason", workflow.ConnSecretDeleted) -// r.EventRecorder.Event(secret, corev1.EventTypeNormal, "Deleted", "ConnectionSecret deleted") -// return workflow.TerminateSilently(nil).WithoutRetry().ReconcileResult() -// } - -// // handleUpsert manages the case where we will create or update the connection secret -// func (r *ConnSecretReconciler) handleUpsert( -// ctx context.Context, -// req ctrl.Request, -// ids *ConnSecretIdentifiers, -// pair *ConnSecretPair[any], -// strategy AnyEndpointStrategy, -// ) (ctrl.Result, error) { -// log := r.Log.With("ns", req.Namespace, "name", req.Name) - -// projectName, err := strategy.ResolveProjectName(ctx, pair) -// if projectName == "" { -// err = fmt.Errorf("project name is empty") -// } -// if err != nil { -// log.Errorw("failed to resolve project name", "reason", workflow.ConnSecretFailedToResolveProjectName, "error", err) -// return workflow.Terminate(workflow.ConnSecretFailedToResolveProjectName, err).ReconcileResult() -// } -// ids.ProjectName = projectName -// log.Debugw("project name resolved for upsert") - -// data, err := strategy.BuildConnectionData(ctx, r.Client, 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() -// } - -// // ensureSecret creates or updates the Secret for the given identifiers and connection data -// func (r *ConnSecretReconciler) ensureSecret( -// ctx context.Context, -// ids *ConnSecretIdentifiers, -// pair *ConnSecretPair[any], -// 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, -// }, -// } - -// if err := fillConnSecretData(secret, ids, data); err != nil { -// log.Errorw("failed to fill secret data", "reason", workflow.ConnSecretFailedToFillData, "error", err) -// return err -// } - -// // OwnerRef is set elsewhere in your flow via controllerutil.SetControllerReference(pair.User, ...) - -// 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", "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 -// } -// } else { -// log.Errorw("failed to create secret", "reason", workflow.ConnSecretFailedToCreateSecret, "error", err) -// return err -// } -// } -// return nil -// } - -// // fillConnSecretData populates secret labels and data -// 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 secrets urls for the data fields -// func CreateURL(connURL, username, password string) (string, error) { -// if connURL == "" { -// return "", nil -// } -// u, err := url.Parse(connURL) -// if err != nil { -// return "", err -// } -// u.User = url.UserPassword(username, password) -// return u.String(), nil -// } diff --git a/internal/controller/connsecrets/connsecret_controller.go b/internal/controller/connsecrets/connsecret_controller.go deleted file mode 100644 index 7106a6a2c5..0000000000 --- a/internal/controller/connsecrets/connsecret_controller.go +++ /dev/null @@ -1,229 +0,0 @@ -package connsecrets - -// import ( -// "context" -// "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 -// EndpointStrategies []AnyEndpointStrategy -// } - -// func (r *ConnSecretReconciler) 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.LoadIdentifiers(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() -// } - -// var ( -// pair *ConnSecretPair[any] -// strategy AnyEndpointStrategy -// ) - -// // We would need to know if we use a Deployment or Federation as Endpoint -// for _, s := range r.EndpointStrategies { -// p, err := s.LoadPair(ctx, r.Client, ids) -// if err == nil { -// pair, strategy = p, s -// break -// } -// if err == ErrNoEndpointFound || err == ErrNoPairedResourcesFound { -// continue -// } - -// return ctrl.Result{}, err -// } - -// expired, err := timeutil.IsExpired(pair.User.Spec.DeleteAfterDate) -// if err != nil { -// return workflow.Terminate(workflow.ConnSecretCheckExpirationFailed, err).ReconcileResult() -// } -// if expired { -// return r.handleDelete(ctx, req, ids, pair, strategy) -// } - -// if !strategy.ValidScopes(pair) { -// log.Infow("invalid scope; scheduling deletion", "reason", workflow.ConnSecretInvalidScopes) -// return r.handleDelete(ctx, req, ids, pair, strategy) -// } - -// // Checks that AtlasDeployment and AtlasDatabaseUser are ready before proceeding -// if ready := strategy.Ready(pair); !ready { -// return workflow.InProgress(workflow.ConnSecretNotReady, "not ready").ReconcileResult() -// } - -// // Create or update the k8s connection secret -// log.Infow("creating/updating connection secret", "reason", workflow.ConnSecretUpsert) -// return r.handleUpsert(ctx, req, ids, pair, strategy) -// } - -// 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.newDeploymentMapFunc), -// builder.WithPredicates(predicate.Or( -// watch.ReadyTransitionPredicate(func(d *akov2.AtlasDeployment) 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 (r *ConnSecretReconciler) 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 -// } -// name := CreateInternalFormat(projectID, d.GetDeploymentName(), u.Spec.Username) -// requests = append(requests, reconcile.Request{ -// NamespacedName: types.NamespacedName{Namespace: u.Namespace, Name: name}, -// }) -// } -// } -// return requests -// } - -// func (r *ConnSecretReconciler) 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: %w", err) -// } -// return project.ID(), nil -// } -// return "", fmt.Errorf("missing both external and internal project references") -// } - -// func (r *ConnSecretReconciler) newDeploymentMapFunc(ctx context.Context, obj client.Object) []reconcile.Request { -// d, ok := obj.(*akov2.AtlasDeployment) -// if !ok { -// return nil -// } -// projectID, err := r.ResolveProjectId(ctx, d.Spec.ProjectDualReference, d.GetNamespace()) -// 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, []akov2.AtlasDeployment{*d}, users.Items) -// } - -// func (r *ConnSecretReconciler) newDatabaseUserMapFunc(ctx context.Context, obj client.Object) []reconcile.Request { -// u, ok := obj.(*akov2.AtlasDatabaseUser) -// if !ok { -// return nil -// } -// projectID, err := r.ResolveProjectId(ctx, u.Spec.ProjectDualReference, u.GetNamespace()) -// if err != nil || projectID == "" { -// return nil -// } -// deps := &akov2.AtlasDeploymentList{} -// if err := r.Client.List(ctx, deps, &client.ListOptions{ -// FieldSelector: fields.OneTermEqualSelector(indexer.AtlasDeploymentByProject, projectID), -// }); err != nil { -// return nil -// } -// return r.generateConnectionSecretRequests(projectID, deps.Items, []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, -// } - -// r.EndpointStrategies = []AnyEndpointStrategy{ -// NewAnyEndpointStrategy(r.NewDeploymentEndpoint()), -// // NewAnyEndpointStrategy(df), -// } - -// return r -// } diff --git a/internal/controller/connsecrets/endpoints.go b/internal/controller/connsecrets/endpoints.go deleted file mode 100644 index 9e5a957b02..0000000000 --- a/internal/controller/connsecrets/endpoints.go +++ /dev/null @@ -1,98 +0,0 @@ -package connsecrets - -// import ( -// "context" -// "fmt" - -// "k8s.io/apimachinery/pkg/fields" -// "k8s.io/apimachinery/pkg/types" -// "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/indexer" -// ) - -// type EndpointStrategy[T any] struct { -// List client.ObjectList -// Selector func(ids *ConnSecretIdentifiers) fields.Selector -// ExtractList func(client.ObjectList) ([]T, error) - -// GetName func(obj T) string -// IsReady func(obj T) bool -// GetConnStrings func(obj T) *status.ConnectionStrings -// GetProjectID func(ctx context.Context, obj T) string -// GetProjectName func(ctx context.Context, obj T) string -// } - -// // NewDeploymentEndpoint returns the EndpointStrategy for AtlasDeployment. -// func (r *ConnSecretReconciler) NewDeploymentEndpoint() EndpointStrategy[*akov2.AtlasDeployment] { -// return EndpointStrategy[*akov2.AtlasDeployment]{ -// List: &akov2.AtlasDeploymentList{}, -// Selector: func(ids *ConnSecretIdentifiers) fields.Selector { -// return fields.OneTermEqualSelector( -// indexer.AtlasDeploymentBySpecNameAndProjectID, -// ids.ProjectID+"-"+ids.ClusterName, -// ) -// }, -// ExtractList: func(ol client.ObjectList) ([]*akov2.AtlasDeployment, error) { -// l, ok := ol.(*akov2.AtlasDeploymentList) -// if !ok { -// return nil, fmt.Errorf("unexpected list type %T", ol) -// } -// out := make([]*akov2.AtlasDeployment, 0, len(l.Items)) -// for i := range l.Items { -// out = append(out, &l.Items[i]) -// } -// return out, nil -// }, -// GetName: func(dpl *akov2.AtlasDeployment) string { -// return dpl.GetDeploymentName() -// }, -// IsReady: func(dpl *akov2.AtlasDeployment) bool { -// return api.HasReadyCondition(dpl.Status.Conditions) -// }, -// GetConnStrings: func(dpl *akov2.AtlasDeployment) *status.ConnectionStrings { -// return dpl.Status.ConnectionStrings -// }, -// GetProjectID: func(ctx context.Context, dpl *akov2.AtlasDeployment) string { -// if dpl.Spec.ExternalProjectRef != nil && dpl.Spec.ExternalProjectRef.ID != "" { -// return dpl.Spec.ExternalProjectRef.ID -// } -// if dpl.Spec.ProjectRef != nil && dpl.Spec.ProjectRef.Name != "" { -// ns := dpl.Spec.ProjectRef.Namespace -// var proj akov2.AtlasProject -// if err := r.Client.Get(ctx, types.NamespacedName{Namespace: ns, Name: dpl.Spec.ProjectRef.Name}, &proj); err == nil { -// return proj.ID() -// } -// } -// return "" -// }, -// GetProjectName: func(ctx context.Context, dpl *akov2.AtlasDeployment) string { -// // Prefer K8s project name when ProjectRef is present -// if dpl.Spec.ProjectRef != nil && dpl.Spec.ProjectRef.Name != "" { -// ns := dpl.Spec.ProjectRef.Namespace -// var proj akov2.AtlasProject -// if err := r.Client.Get(ctx, types.NamespacedName{Namespace: ns, Name: dpl.Spec.ProjectRef.Name}, &proj); err == nil && proj.Spec.Name != "" { -// return proj.Spec.Name -// } -// } - -// // SDK fallback -// connCfg, err := r.ResolveConnectionConfig(ctx, dpl) -// if err != nil { -// return "" -// } -// sdkClientSet, err := r.AtlasProvider.SdkClientSet(ctx, connCfg.Credentials, r.Log) -// if err != nil { -// return "" -// } -// ap, err := r.ResolveProject(ctx, sdkClientSet.SdkClient20250312002, dpl) -// if err != nil { -// return "" -// } -// return ap.Name -// }, -// } -// } diff --git a/internal/controller/connsecrets/strategy.go b/internal/controller/connsecrets/strategy.go deleted file mode 100644 index 846c83e72e..0000000000 --- a/internal/controller/connsecrets/strategy.go +++ /dev/null @@ -1,171 +0,0 @@ -package connsecrets - -// import ( -// "context" -// "errors" -// "fmt" - -// "k8s.io/apimachinery/pkg/fields" -// "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/stringutil" -// ) - -// const InternalSeparator = "$" - -// var ( -// ErrNoPairedResourcesFound = errors.New("no endpoint and no AtlasDatabaseUser found") -// ErrNoEndpointFound = errors.New("no endpoint found") -// ErrManyEndpoints = errors.New("multiple endpoints found") -// ErrNoUserFound = errors.New("no AtlasDatabaseUser found") -// ErrManyUsers = errors.New("multiple AtlasDatabaseUsers found") -// ) - -// type AnyEndpointStrategy interface { -// LoadPair(ctx context.Context, c client.Client, ids *ConnSecretIdentifiers) (*ConnSecretPair[any], error) -// Ready(p *ConnSecretPair[any]) bool -// ValidScopes(p *ConnSecretPair[any]) bool -// BuildConnectionData(ctx context.Context, c client.Client, p *ConnSecretPair[any]) (ConnSecretData, error) -// ResolveProjectName(ctx context.Context, p *ConnSecretPair[any]) (string, error) -// } - -// type anyEndpointStrategy[T any] struct { -// EndpointStrategy[T] -// } - -// type ConnSecretPair[T any] struct { -// ProjectID string -// User *akov2.AtlasDatabaseUser -// Endpoint T -// } - -// type ConnSecretData struct { -// DBUserName string -// Password string -// ConnURL string -// SrvConnURL string -// PrivateConnURLs []PrivateLinkConnURLs -// } - -// type PrivateLinkConnURLs struct { -// PvtConnURL string -// PvtSrvConnURL string -// PvtShardConnURL string -// } - -// func NewAnyEndpointStrategy[T any](s EndpointStrategy[T]) AnyEndpointStrategy { -// return &anyEndpointStrategy[T]{s} -// } - -// func (w *anyEndpointStrategy[T]) LoadPair(ctx context.Context, c client.Client, ids *ConnSecretIdentifiers) (*ConnSecretPair[any], error) { -// if err := c.List(ctx, w.List, &client.ListOptions{FieldSelector: w.Selector(ids)}); err != nil { -// return nil, err -// } -// eps, err := w.ExtractList(w.List) -// if err != nil { -// return nil, err -// } - -// users := &akov2.AtlasDatabaseUserList{} -// userSel := fields.OneTermEqualSelector(indexer.AtlasDatabaseUserBySpecUsernameAndProjectID, ids.ProjectID+"-"+ids.DatabaseUsername) -// if err := c.List(ctx, users, &client.ListOptions{FieldSelector: userSel}); err != nil { -// return nil, err -// } - -// switch { -// case len(eps) == 0 && len(users.Items) == 0: -// return nil, ErrNoPairedResourcesFound -// case len(eps) == 0: -// return &ConnSecretPair[any]{ProjectID: ids.ProjectID, User: &users.Items[0], Endpoint: nil}, ErrNoEndpointFound -// case len(users.Items) == 0: -// return &ConnSecretPair[any]{ProjectID: ids.ProjectID, User: nil, Endpoint: eps[0]}, ErrNoUserFound -// case len(eps) > 1: -// return nil, ErrManyEndpoints -// case len(users.Items) > 1: -// return nil, ErrManyUsers -// } - -// return &ConnSecretPair[any]{ProjectID: ids.ProjectID, User: &users.Items[0], Endpoint: eps[0]}, nil -// } - -// func (w *anyEndpointStrategy[T]) ValidScopes(p *ConnSecretPair[any]) bool { -// if p == nil || p.User == nil { -// return false -// } -// scopes := p.User.GetScopes(akov2.DeploymentScopeType) -// if len(scopes) == 0 { -// return true -// } -// t, ok := p.Endpoint.(T) -// if !ok || p.Endpoint == nil { -// return false -// } -// name := w.GetName(t) -// if name == "" { -// return false -// } -// return stringutil.Contains(scopes, name) -// } - -// func (w *anyEndpointStrategy[T]) Ready(p *ConnSecretPair[any]) bool { -// if p == nil || p.User == nil || !p.User.IsDatabaseUserReady() { -// return false -// } -// t, ok := p.Endpoint.(T) -// if !ok || p.Endpoint == nil { -// return false -// } -// return w.IsReady(t) -// } - -// func (w *anyEndpointStrategy[T]) BuildConnectionData(ctx context.Context, c client.Client, p *ConnSecretPair[any]) (ConnSecretData, error) { -// if p == nil || p.User == nil || p.Endpoint == nil { -// return ConnSecretData{}, fmt.Errorf("invalid pair: nil user or endpoint") -// } - -// password, err := p.User.ReadPassword(ctx, c) -// if err != nil { -// return ConnSecretData{}, fmt.Errorf("failed to read password for user %q: %w", p.User.Spec.Username, err) -// } - -// t, ok := p.Endpoint.(T) -// if !ok { -// return ConnSecretData{}, fmt.Errorf("unexpected endpoint type") -// } - -// conn := w.GetConnStrings(t) - -// 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 -// } - -// func (a *anyEndpointStrategy[T]) ResolveProjectName(ctx context.Context, p *ConnSecretPair[any]) (string, error) { -// t, ok := p.Endpoint.(T) -// if !ok || p.Endpoint == nil { -// return "", fmt.Errorf("unexpected endpoint type") -// } -// return a.GetProjectName(ctx, t), nil -// } From c27c92c75e46cbbbc36eb013c893f3b6a0b0b816 Mon Sep 17 00:00:00 2001 From: andrpac Date: Fri, 15 Aug 2025 16:09:07 +0100 Subject: [PATCH 11/11] chore: start adding testcases --- .../connsecrets-generic/connectionsecret.go | 24 +- .../connectionsecret_controller.go | 9 +- .../connectionsecret_controller_test.go | 428 +++++++ .../connectionsecret_test.go | 1016 +++++++++++++++++ .../endpoint_deployment.go | 16 +- .../endpoint_deployment_test.go | 357 ++++++ .../endpoint_federation.go | 12 +- .../endpoint_federation_test.go | 319 ++++++ .../connsecrets-generic/endpoint_user.go | 33 +- .../connsecrets-generic/endpoint_user_test.go | 249 ++++ .../atlasdatafederationbyspecname_test.go | 163 +++ 11 files changed, 2577 insertions(+), 49 deletions(-) create mode 100644 internal/controller/connsecrets-generic/connectionsecret_controller_test.go create mode 100644 internal/controller/connsecrets-generic/connectionsecret_test.go create mode 100644 internal/controller/connsecrets-generic/endpoint_deployment_test.go create mode 100644 internal/controller/connsecrets-generic/endpoint_federation_test.go create mode 100644 internal/controller/connsecrets-generic/endpoint_user_test.go create mode 100644 internal/indexer/atlasdatafederationbyspecname_test.go diff --git a/internal/controller/connsecrets-generic/connectionsecret.go b/internal/controller/connsecrets-generic/connectionsecret.go index 624e221ecb..48b46e4db7 100644 --- a/internal/controller/connsecrets-generic/connectionsecret.go +++ b/internal/controller/connsecrets-generic/connectionsecret.go @@ -54,10 +54,12 @@ const ( ) 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") + 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 @@ -239,7 +241,7 @@ func (r *ConnSecretReconciler) resolveProjectName( // project name resolution requires at least on parent to be available if pair == nil { - return "", fmt.Errorf("project name cannot be resolved") + return "", ErrUnresolvedProjectName } var err error @@ -262,7 +264,7 @@ func (r *ConnSecretReconciler) resolveProjectName( } if err == nil { - err = fmt.Errorf("project name cannot be resolved") + err = ErrUnresolvedProjectName } return "", err } @@ -276,10 +278,6 @@ func (r *ConnSecretReconciler) handleDelete( ) (ctrl.Result, error) { log := r.Log.With("ns", req.Namespace, "name", req.Name) - if pair == nil { - return workflow.TerminateSilently(nil).WithoutRetry().ReconcileResult() - } - // project name is required for metadata.name projectName, err := r.resolveProjectName(ctx, ids, pair) if err != nil { @@ -377,16 +375,16 @@ func (r *ConnSecretReconciler) ensureSecret( 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", "reason", workflow.ConnSecretFailedToGetSecret, "error", err) + 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", "reason", workflow.ConnSecretFailedToUpdateSecret, "error", err) + log.Errorw("failed to update secret", "error", err) return err } } else { - log.Errorw("failed to create secret", "reason", workflow.ConnSecretFailedToCreateSecret, "error", err) + log.Errorw("failed to create secret", "error", err) return err } } diff --git a/internal/controller/connsecrets-generic/connectionsecret_controller.go b/internal/controller/connsecrets-generic/connectionsecret_controller.go index 87b0c76dcf..137d4de28d 100644 --- a/internal/controller/connsecrets-generic/connectionsecret_controller.go +++ b/internal/controller/connsecrets-generic/connectionsecret_controller.go @@ -130,7 +130,7 @@ func (r *ConnSecretReconciler) Reconcile(ctx context.Context, req ctrl.Request) // 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, "not ready").ReconcileResult() + return workflow.InProgress(workflow.ConnSecretNotReady, "resources not ready").ReconcileResult() } return r.handleUpsert(ctx, req, ids, pair) @@ -182,11 +182,10 @@ func (r *ConnSecretReconciler) SetupWithManager(mgr ctrl.Manager, skipNameValida Complete(r) } -// TODO: change this function according to Helders feedback func allowsByScopes(u *akov2.AtlasDatabaseUser, epName string, epType akov2.ScopeType) bool { - scopes := u.GetScopes(epType) - total_len := len(u.GetScopes(akov2.DataLakeScopeType)) + len(u.GetScopes(akov2.DeploymentScopeType)) - if total_len == 0 || stringutil.Contains(scopes, epName) { + scopes := u.Spec.Scopes + filtered_scopes := u.GetScopes(epType) + if len(scopes) == 0 || stringutil.Contains(filtered_scopes, epName) { return true } 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 index 48aabb06a6..a86ec34534 100644 --- a/internal/controller/connsecrets-generic/endpoint_deployment.go +++ b/internal/controller/connsecrets-generic/endpoint_deployment.go @@ -60,13 +60,12 @@ func (e DeploymentEndpoint) GetProjectID(ctx context.Context) (string, error) { } if e.obj.Spec.ProjectRef != nil && e.obj.Spec.ProjectRef.Name != "" && e.r != nil && e.r.Client != nil { proj := &akov2.AtlasProject{} - key := e.obj.Spec.ProjectRef.GetObject(e.obj.GetNamespace()) - if err := e.r.Client.Get(ctx, *key, proj); err != nil { + if err := e.r.Client.Get(ctx, e.obj.AtlasProjectObjectKey(), proj); err != nil { return "", err } return proj.ID(), nil } - return "", fmt.Errorf("project ID not available") + return "", ErrUnresolvedProjectID } // GetProjectName returns the parent project's name (either by getting K8s AtlasProject or SDK calls) @@ -76,8 +75,7 @@ func (e DeploymentEndpoint) GetProjectName(ctx context.Context) (string, error) } if e.obj.Spec.ProjectRef != nil && e.obj.Spec.ProjectRef.Name != "" && e.r != nil && e.r.Client != nil { proj := &akov2.AtlasProject{} - key := e.obj.Spec.ProjectRef.GetObject(e.obj.GetNamespace()) - if err := e.r.Client.Get(ctx, *key, proj); err != nil { + if err := e.r.Client.Get(ctx, e.obj.AtlasProjectObjectKey(), proj); err != nil { return "", err } if proj.Spec.Name != "" { @@ -100,7 +98,7 @@ func (e DeploymentEndpoint) GetProjectName(ctx context.Context) (string, error) } return kube.NormalizeIdentifier(ap.Name), nil } - return "", fmt.Errorf("project name not available") + return "", ErrUnresolvedProjectName } // Defines the list type @@ -133,7 +131,7 @@ func (e DeploymentEndpoint) ExtractList(ol client.ObjectList) ([]Endpoint, error // 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{}, fmt.Errorf("invalid endpoint or user") + return ConnSecretData{}, ErrMissingPairing } password, err := user.ReadPassword(ctx, e.r.Client) if err != nil { @@ -144,6 +142,10 @@ func (e DeploymentEndpoint) BuildConnData(ctx context.Context, user *akov2.Atlas Password: password, } + if e.obj.Status.ConnectionStrings == nil { + return data, nil + } + conn := e.obj.Status.ConnectionStrings data.ConnURL = conn.Standard data.SrvConnURL = conn.StandardSrv 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 index 7b868b53a9..3bd40d3e94 100644 --- a/internal/controller/connsecrets-generic/endpoint_federation.go +++ b/internal/controller/connsecrets-generic/endpoint_federation.go @@ -61,14 +61,13 @@ func (e FederationEndpoint) GetProjectID(ctx context.Context) (string, error) { } if e.obj.Spec.Project.Name != "" { proj := &akov2.AtlasProject{} - key := e.obj.Spec.Project.GetObject(e.obj.GetNamespace()) - if err := e.r.Client.Get(ctx, *key, proj); err != nil { + if err := e.r.Client.Get(ctx, e.obj.AtlasProjectObjectKey(), proj); err != nil { return "", err } return proj.ID(), nil } - return "", fmt.Errorf("project ID not available") + return "", ErrUnresolvedProjectID } // GetProjectName returns the parent project's name (only by getting K8s AtlasProject) @@ -78,8 +77,7 @@ func (e FederationEndpoint) GetProjectName(ctx context.Context) (string, error) } if e.obj.Spec.Project.Name != "" { proj := &akov2.AtlasProject{} - key := e.obj.Spec.Project.GetObject(e.obj.GetNamespace()) - if err := e.r.Client.Get(ctx, *key, proj); err != nil { + if err := e.r.Client.Get(ctx, e.obj.AtlasProjectObjectKey(), proj); err != nil { return "", err } if proj.Spec.Name != "" { @@ -87,7 +85,7 @@ func (e FederationEndpoint) GetProjectName(ctx context.Context) (string, error) } } - return "", fmt.Errorf("project name not available") + return "", ErrUnresolvedProjectName } // Defines the list type @@ -120,7 +118,7 @@ func (e FederationEndpoint) ExtractList(ol client.ObjectList) ([]Endpoint, error // 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{}, fmt.Errorf("invalid endpoint or user") + return ConnSecretData{}, ErrMissingPairing } password, err := user.ReadPassword(ctx, e.r.Client) if err != 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 index a6a41f3297..94d06af242 100644 --- a/internal/controller/connsecrets-generic/endpoint_user.go +++ b/internal/controller/connsecrets-generic/endpoint_user.go @@ -30,8 +30,7 @@ func (r *ConnSecretReconciler) GetUserProjectName(ctx context.Context, user *ako if user.Spec.ProjectRef != nil && user.Spec.ProjectRef.Name != "" { proj := &akov2.AtlasProject{} - key := user.Spec.ProjectRef.GetObject(user.GetNamespace()) - if err := r.Client.Get(ctx, *key, proj); err != nil { + if err := r.Client.Get(ctx, user.AtlasProjectObjectKey(), proj); err != nil { return "", err } if proj.Spec.Name != "" { @@ -39,21 +38,21 @@ func (r *ConnSecretReconciler) GetUserProjectName(ctx context.Context, user *ako } } - if r != 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 - } - return kube.NormalizeIdentifier(ap.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 "", 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/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()) + }) + } +}