diff --git a/.evergreen-tasks.yml b/.evergreen-tasks.yml index 1d289fa3f..cb502c0cd 100644 --- a/.evergreen-tasks.yml +++ b/.evergreen-tasks.yml @@ -1290,3 +1290,8 @@ tasks: tags: ["patch-run"] commands: - func: "e2e_test" + + - name: e2e_search_enterprise_basic + tags: ["patch-run"] + commands: + - func: "e2e_test" diff --git a/.evergreen.yml b/.evergreen.yml index 94bbd5a09..8937f8def 100644 --- a/.evergreen.yml +++ b/.evergreen.yml @@ -811,6 +811,8 @@ task_groups: - e2e_replica_set_oidc_workforce - e2e_sharded_cluster_oidc_m2m_group - e2e_sharded_cluster_oidc_m2m_user + # MongoDBSearch test group + - e2e_search_enterprise_basic <<: *teardown_group # this task group contains just a one task, which is smoke testing whether the operator diff --git a/controllers/operator/mongodbreplicaset_controller.go b/controllers/operator/mongodbreplicaset_controller.go index c16ed89c6..186dcbc62 100644 --- a/controllers/operator/mongodbreplicaset_controller.go +++ b/controllers/operator/mongodbreplicaset_controller.go @@ -4,12 +4,16 @@ import ( "context" "fmt" + "github.com/blang/semver" "go.uber.org/zap" "golang.org/x/xerrors" "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/fields" "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/controller" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" "sigs.k8s.io/controller-runtime/pkg/event" "sigs.k8s.io/controller-runtime/pkg/handler" "sigs.k8s.io/controller-runtime/pkg/manager" @@ -22,6 +26,7 @@ import ( mdbv1 "github.com/mongodb/mongodb-kubernetes/api/v1/mdb" rolev1 "github.com/mongodb/mongodb-kubernetes/api/v1/role" + searchv1 "github.com/mongodb/mongodb-kubernetes/api/v1/search" mdbstatus "github.com/mongodb/mongodb-kubernetes/api/v1/status" "github.com/mongodb/mongodb-kubernetes/controllers/om" "github.com/mongodb/mongodb-kubernetes/controllers/om/backup" @@ -39,6 +44,7 @@ import ( "github.com/mongodb/mongodb-kubernetes/controllers/operator/recovery" "github.com/mongodb/mongodb-kubernetes/controllers/operator/watch" "github.com/mongodb/mongodb-kubernetes/controllers/operator/workflow" + "github.com/mongodb/mongodb-kubernetes/controllers/search_controller" mcoConstruct "github.com/mongodb/mongodb-kubernetes/mongodb-community-operator/controllers/construct" "github.com/mongodb/mongodb-kubernetes/mongodb-community-operator/pkg/kube/annotations" "github.com/mongodb/mongodb-kubernetes/mongodb-community-operator/pkg/kube/configmap" @@ -52,6 +58,7 @@ import ( "github.com/mongodb/mongodb-kubernetes/pkg/util/architectures" "github.com/mongodb/mongodb-kubernetes/pkg/util/env" util_int "github.com/mongodb/mongodb-kubernetes/pkg/util/int" + "github.com/mongodb/mongodb-kubernetes/pkg/util/maputil" "github.com/mongodb/mongodb-kubernetes/pkg/vault" "github.com/mongodb/mongodb-kubernetes/pkg/vault/vaultwatcher" ) @@ -219,6 +226,8 @@ func (r *ReconcileMongoDbReplicaSet) Reconcile(ctx context.Context, request reco return r.updateStatus(ctx, rs, workflow.Failed(xerrors.Errorf("Failed to reconcileHostnameOverrideConfigMap: %w", err)), log) } + shouldMirrorKeyfile := r.applySearchOverrides(ctx, rs, log) + sts := construct.DatabaseStatefulSet(*rs, rsConfig, log) if status := r.ensureRoles(ctx, rs.Spec.DbCommonSpec, r.enableClusterMongoDBRoles, conn, kube.ObjectKeyFromApiObject(rs), log); !status.IsOK() { return r.updateStatus(ctx, rs, status, log) @@ -238,7 +247,7 @@ func (r *ReconcileMongoDbReplicaSet) Reconcile(ctx context.Context, request reco // See CLOUDP-189433 and CLOUDP-229222 for more details. if recovery.ShouldTriggerRecovery(rs.Status.Phase != mdbstatus.PhaseRunning, rs.Status.LastTransition) { log.Warnf("Triggering Automatic Recovery. The MongoDB resource %s/%s is in %s state since %s", rs.Namespace, rs.Name, rs.Status.Phase, rs.Status.LastTransition) - automationConfigStatus := r.updateOmDeploymentRs(ctx, conn, rs.Status.Members, rs, sts, log, caFilePath, agentCertSecretName, prometheusCertHash, true).OnErrorPrepend("Failed to create/update (Ops Manager reconciliation phase):") + automationConfigStatus := r.updateOmDeploymentRs(ctx, conn, rs.Status.Members, rs, sts, log, caFilePath, agentCertSecretName, prometheusCertHash, true, shouldMirrorKeyfile).OnErrorPrepend("Failed to create/update (Ops Manager reconciliation phase):") deploymentError := create.DatabaseInKubernetes(ctx, r.client, *rs, sts, rsConfig, log) if deploymentError != nil { log.Errorf("Recovery failed because of deployment errors, %w", deploymentError) @@ -254,7 +263,7 @@ func (r *ReconcileMongoDbReplicaSet) Reconcile(ctx context.Context, request reco } status = workflow.RunInGivenOrder(publishAutomationConfigFirst(ctx, r.client, *rs, lastSpec, rsConfig, log), func() workflow.Status { - return r.updateOmDeploymentRs(ctx, conn, rs.Status.Members, rs, sts, log, caFilePath, agentCertSecretName, prometheusCertHash, false).OnErrorPrepend("Failed to create/update (Ops Manager reconciliation phase):") + return r.updateOmDeploymentRs(ctx, conn, rs.Status.Members, rs, sts, log, caFilePath, agentCertSecretName, prometheusCertHash, false, shouldMirrorKeyfile).OnErrorPrepend("Failed to create/update (Ops Manager reconciliation phase):") }, func() workflow.Status { workflowStatus := create.HandlePVCResize(ctx, r.client, &sts, log) @@ -408,6 +417,16 @@ func AddReplicaSetController(ctx context.Context, mgr manager.Manager, imageUrls zap.S().Errorf("Failed to watch for vault secret changes: %w", err) } } + + err = c.Watch(source.Kind(mgr.GetCache(), &searchv1.MongoDBSearch{}, + handler.TypedEnqueueRequestsFromMapFunc(func(ctx context.Context, search *searchv1.MongoDBSearch) []reconcile.Request { + source := search.GetMongoDBResourceRef() + return []reconcile.Request{{NamespacedName: types.NamespacedName{Namespace: source.Namespace, Name: source.Name}}} + }))) + if err != nil { + return err + } + zap.S().Infof("Registered controller %s", util.MongoDbReplicaSetController) return nil @@ -415,7 +434,7 @@ func AddReplicaSetController(ctx context.Context, mgr manager.Manager, imageUrls // updateOmDeploymentRs performs OM registration operation for the replicaset. So the changes will be finally propagated // to automation agents in containers -func (r *ReconcileMongoDbReplicaSet) updateOmDeploymentRs(ctx context.Context, conn om.Connection, membersNumberBefore int, rs *mdbv1.MongoDB, set appsv1.StatefulSet, log *zap.SugaredLogger, caFilePath string, agentCertSecretName string, prometheusCertHash string, isRecovering bool) workflow.Status { +func (r *ReconcileMongoDbReplicaSet) updateOmDeploymentRs(ctx context.Context, conn om.Connection, membersNumberBefore int, rs *mdbv1.MongoDB, set appsv1.StatefulSet, log *zap.SugaredLogger, caFilePath string, agentCertSecretName string, prometheusCertHash string, isRecovering bool, shouldMirrorKeyfileForMongot bool) workflow.Status { log.Debug("Entering UpdateOMDeployments") // Only "concrete" RS members should be observed // - if scaling down, let's observe only members that will remain after scale-down operation @@ -469,6 +488,11 @@ func (r *ReconcileMongoDbReplicaSet) updateOmDeploymentRs(ctx context.Context, c err = conn.ReadUpdateDeployment( func(d om.Deployment) error { + if shouldMirrorKeyfileForMongot { + if err := r.mirrorKeyfileIntoSecretForMongot(ctx, d, rs, log); err != nil { + return err + } + } return ReconcileReplicaSetAC(ctx, d, rs.Spec.DbCommonSpec, lastRsConfig.ToMap(), rs.Name, replicaSet, caFilePath, internalClusterPath, &p, log) }, log, @@ -609,3 +633,70 @@ func getAllHostsRs(set appsv1.StatefulSet, clusterName string, membersCount int, hostnames, _ := dns.GetDnsForStatefulSetReplicasSpecified(set, clusterName, membersCount, externalDomain) return hostnames } + +func (r *ReconcileMongoDbReplicaSet) applySearchOverrides(ctx context.Context, rs *mdbv1.MongoDB, log *zap.SugaredLogger) bool { + search := r.lookupCorrespondingSearchResource(ctx, rs, log) + if search == nil { + log.Debugf("No MongoDBSearch resource found, skipping search overrides") + return false + } + + log.Infof("Applying search overrides from MongoDBSearch %s", search.NamespacedName()) + + if rs.Spec.AdditionalMongodConfig == nil { + rs.Spec.AdditionalMongodConfig = mdbv1.NewEmptyAdditionalMongodConfig() + } + searchMongodConfig := search_controller.GetMongodConfigParameters(search) + rs.Spec.AdditionalMongodConfig.AddOption("setParameter", searchMongodConfig["setParameter"]) + + mdbVersion, err := semver.ParseTolerant(rs.Spec.Version) + if err != nil { + log.Warnf("Failed to parse MongoDB version %q: %w. Proceeding without the automatic creation of the searchCoordinator role that's necessary for MongoDB <8.2", rs.Spec.Version, err) + } else if semver.MustParse("8.2.0").GT(mdbVersion) { + log.Infof("Polyfilling the searchCoordinator role for MongoDB %s", rs.Spec.Version) + + if rs.Spec.Security == nil { + rs.Spec.Security = &mdbv1.Security{} + } + rs.Spec.Security.Roles = append(rs.Spec.Security.Roles, search_controller.SearchCoordinatorRole()) + } + + return true +} + +func (r *ReconcileMongoDbReplicaSet) mirrorKeyfileIntoSecretForMongot(ctx context.Context, d om.Deployment, rs *mdbv1.MongoDB, log *zap.SugaredLogger) error { + keyfileContents := maputil.ReadMapValueAsString(d, "auth", "key") + keyfileSecret := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: fmt.Sprintf("%s-keyfile", rs.Name), Namespace: rs.Namespace}} + + log.Infof("Mirroring the replicaset %s's keyfile into the secret %s", rs.ObjectKey(), kube.ObjectKeyFromApiObject(keyfileSecret)) + + _, err := controllerutil.CreateOrUpdate(ctx, r.client, keyfileSecret, func() error { + keyfileSecret.StringData = map[string]string{"keyfile": keyfileContents} + return controllerutil.SetOwnerReference(rs, keyfileSecret, r.client.Scheme()) + }) + if err != nil { + return xerrors.Errorf("Failed to mirror the replicaset's keyfile into a secret: %w", err) + } else { + return nil + } +} + +func (r *ReconcileMongoDbReplicaSet) lookupCorrespondingSearchResource(ctx context.Context, rs *mdbv1.MongoDB, log *zap.SugaredLogger) *searchv1.MongoDBSearch { + var search *searchv1.MongoDBSearch + searchList := &searchv1.MongoDBSearchList{} + if err := r.client.List(ctx, searchList, &client.ListOptions{ + FieldSelector: fields.OneTermEqualSelector(search_controller.MongoDBSearchIndexFieldName, rs.GetNamespace()+"/"+rs.GetName()), + }); err != nil { + log.Debugf("Failed to list MongoDBSearch resources: %v", err) + } + // this validates that there is exactly one MongoDBSearch pointing to this resource, + // and that this resource passes search validations. If either fails, proceed without a search target + // for the mongod automation config. + if len(searchList.Items) == 1 { + searchSource := search_controller.NewEnterpriseResourceSearchSource(rs) + if searchSource.Validate() == nil { + search = &searchList.Items[0] + } + } + return search +} diff --git a/controllers/operator/mongodbsearch_controller.go b/controllers/operator/mongodbsearch_controller.go index bf8a5f022..324886906 100644 --- a/controllers/operator/mongodbsearch_controller.go +++ b/controllers/operator/mongodbsearch_controller.go @@ -14,13 +14,16 @@ import ( appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" ctrl "sigs.k8s.io/controller-runtime" + mdbv1 "github.com/mongodb/mongodb-kubernetes/api/v1/mdb" searchv1 "github.com/mongodb/mongodb-kubernetes/api/v1/search" "github.com/mongodb/mongodb-kubernetes/controllers/search_controller" mdbcv1 "github.com/mongodb/mongodb-kubernetes/mongodb-community-operator/api/v1" "github.com/mongodb/mongodb-kubernetes/mongodb-community-operator/controllers/watch" kubernetesClient "github.com/mongodb/mongodb-kubernetes/mongodb-community-operator/pkg/kube/client" + "github.com/mongodb/mongodb-kubernetes/pkg/kube" "github.com/mongodb/mongodb-kubernetes/pkg/kube/commoncontroller" "github.com/mongodb/mongodb-kubernetes/pkg/util" "github.com/mongodb/mongodb-kubernetes/pkg/util/env" @@ -28,15 +31,18 @@ import ( type MongoDBSearchReconciler struct { kubeClient kubernetesClient.Client - mdbcWatcher *watch.ResourceWatcher + mdbcWatcher watch.ResourceWatcher + mdbWatcher watch.ResourceWatcher + secretWatcher watch.ResourceWatcher operatorSearchConfig search_controller.OperatorSearchConfig } func newMongoDBSearchReconciler(client client.Client, operatorSearchConfig search_controller.OperatorSearchConfig) *MongoDBSearchReconciler { - mdbcWatcher := watch.New() return &MongoDBSearchReconciler{ kubeClient: kubernetesClient.NewClient(client), - mdbcWatcher: &mdbcWatcher, + mdbcWatcher: watch.New(), + mdbWatcher: watch.New(), + secretWatcher: watch.New(), operatorSearchConfig: operatorSearchConfig, } } @@ -51,26 +57,43 @@ func (r *MongoDBSearchReconciler) Reconcile(ctx context.Context, request reconci return result, err } - sourceResource, err := getSourceMongoDBForSearch(ctx, r.kubeClient, mdbSearch) + sourceResource, err := r.getSourceMongoDBForSearch(ctx, r.kubeClient, mdbSearch, log) if err != nil { return reconcile.Result{RequeueAfter: time.Second * util.RetryTimeSec}, err } - - r.mdbcWatcher.Watch(ctx, sourceResource.NamespacedName(), request.NamespacedName) + r.secretWatcher.Watch(ctx, kube.ObjectKey(sourceResource.GetNamespace(), sourceResource.KeyfileSecretName()), mdbSearch.NamespacedName()) reconcileHelper := search_controller.NewMongoDBSearchReconcileHelper(kubernetesClient.NewClient(r.kubeClient), mdbSearch, sourceResource, r.operatorSearchConfig) return reconcileHelper.Reconcile(ctx, log).ReconcileResult() } -func getSourceMongoDBForSearch(ctx context.Context, kubeClient client.Client, search *searchv1.MongoDBSearch) (search_controller.SearchSourceDBResource, error) { +func (r *MongoDBSearchReconciler) getSourceMongoDBForSearch(ctx context.Context, kubeClient client.Client, search *searchv1.MongoDBSearch, log *zap.SugaredLogger) (search_controller.SearchSourceDBResource, error) { sourceMongoDBResourceRef := search.GetMongoDBResourceRef() - mdbcName := types.NamespacedName{Namespace: search.GetNamespace(), Name: sourceMongoDBResourceRef.Name} + sourceName := types.NamespacedName{Namespace: search.GetNamespace(), Name: sourceMongoDBResourceRef.Name} + log.Infof("Looking up Search source %s", sourceName) + + mdb := &mdbv1.MongoDB{} + if err := kubeClient.Get(ctx, sourceName, mdb); err != nil { + if !apierrors.IsNotFound(err) { + return nil, xerrors.Errorf("error getting MongoDB %s: %w", sourceName, err) + } + } else { + r.mdbWatcher.Watch(ctx, sourceName, search.NamespacedName()) + return search_controller.NewEnterpriseResourceSearchSource(mdb), nil + } + mdbc := &mdbcv1.MongoDBCommunity{} - if err := kubeClient.Get(ctx, mdbcName, mdbc); err != nil { - return nil, xerrors.Errorf("error getting MongoDBCommunity %s: %w", mdbcName, err) + if err := kubeClient.Get(ctx, sourceName, mdbc); err != nil { + if !apierrors.IsNotFound(err) { + return nil, xerrors.Errorf("error getting MongoDBCommunity %s: %w", sourceName, err) + } + } else { + r.mdbcWatcher.Watch(ctx, sourceName, search.NamespacedName()) + return search_controller.NewCommunityResourceSearchSource(mdbc), nil } - return search_controller.NewSearchSourceDBResourceFromMongoDBCommunity(mdbc), nil + + return nil, xerrors.Errorf("No database resource named %s found", sourceName) } func mdbcSearchIndexBuilder(rawObj client.Object) []string { @@ -88,7 +111,9 @@ func AddMongoDBSearchController(ctx context.Context, mgr manager.Manager, operat return ctrl.NewControllerManagedBy(mgr). WithOptions(controller.Options{MaxConcurrentReconciles: env.ReadIntOrDefault(util.MaxConcurrentReconcilesEnv, 1)}). // nolint:forbidigo For(&searchv1.MongoDBSearch{}). + Watches(&mdbv1.MongoDB{}, r.mdbWatcher). Watches(&mdbcv1.MongoDBCommunity{}, r.mdbcWatcher). + Watches(&corev1.Secret{}, r.secretWatcher). Owns(&appsv1.StatefulSet{}). Owns(&corev1.Secret{}). Complete(r) diff --git a/controllers/operator/mongodbsearch_controller_test.go b/controllers/operator/mongodbsearch_controller_test.go index ba3885042..a977d2ebf 100644 --- a/controllers/operator/mongodbsearch_controller_test.go +++ b/controllers/operator/mongodbsearch_controller_test.go @@ -29,6 +29,7 @@ import ( mdbcv1 "github.com/mongodb/mongodb-kubernetes/mongodb-community-operator/api/v1" "github.com/mongodb/mongodb-kubernetes/mongodb-community-operator/api/v1/common" "github.com/mongodb/mongodb-kubernetes/mongodb-community-operator/pkg/mongot" + "github.com/mongodb/mongodb-kubernetes/mongodb-community-operator/pkg/util/constants" ) func newMongoDBCommunity(name, namespace string) *mdbcv1.MongoDBCommunity { @@ -62,7 +63,16 @@ func newSearchReconcilerWithOperatorConfig( builder.WithIndex(&searchv1.MongoDBSearch{}, search_controller.MongoDBSearchIndexFieldName, mdbcSearchIndexBuilder) if mdbc != nil { - builder.WithObjects(mdbc) + keyfileSecret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: mdbc.GetAgentKeyfileSecretNamespacedName().Name, + Namespace: mdbc.Namespace, + }, + StringData: map[string]string{ + constants.AgentKeyfileKey: "keyfile", + }, + } + builder.WithObjects(mdbc, keyfileSecret) } for _, search := range searches { diff --git a/controllers/search_controller/community_search_source.go b/controllers/search_controller/community_search_source.go new file mode 100644 index 000000000..098932550 --- /dev/null +++ b/controllers/search_controller/community_search_source.go @@ -0,0 +1,80 @@ +package search_controller + +import ( + "strings" + + "github.com/blang/semver" + "golang.org/x/xerrors" + "k8s.io/apimachinery/pkg/types" + + mdbcv1 "github.com/mongodb/mongodb-kubernetes/mongodb-community-operator/api/v1" + "github.com/mongodb/mongodb-kubernetes/pkg/util" +) + +func NewCommunityResourceSearchSource(mdbc *mdbcv1.MongoDBCommunity) SearchSourceDBResource { + return &CommunitySearchSource{MongoDBCommunity: mdbc} +} + +type CommunitySearchSource struct { + *mdbcv1.MongoDBCommunity +} + +func (r *CommunitySearchSource) Members() int { + return r.Spec.Members +} + +func (r *CommunitySearchSource) GetName() string { + return r.Name +} + +func (r *CommunitySearchSource) NamespacedName() types.NamespacedName { + return r.MongoDBCommunity.NamespacedName() +} + +func (r *CommunitySearchSource) KeyfileSecretName() string { + return r.MongoDBCommunity.GetAgentKeyfileSecretNamespacedName().Name +} + +func (r *CommunitySearchSource) GetNamespace() string { + return r.Namespace +} + +func (r *CommunitySearchSource) DatabaseServiceName() string { + return r.ServiceName() +} + +func (r *CommunitySearchSource) IsSecurityTLSConfigEnabled() bool { + return r.Spec.Security.TLS.Enabled +} + +func (r *CommunitySearchSource) DatabasePort() int { + return r.MongoDBCommunity.GetMongodConfiguration().GetDBPort() +} + +func (r *CommunitySearchSource) TLSOperatorCASecretNamespacedName() types.NamespacedName { + return r.MongoDBCommunity.TLSOperatorCASecretNamespacedName() +} + +func (r *CommunitySearchSource) Validate() error { + version, err := semver.ParseTolerant(r.GetMongoDBVersion()) + if err != nil { + return xerrors.Errorf("error parsing MongoDB version '%s': %w", r.Spec.Version, err) + } else if version.LT(semver.MustParse("8.0.10")) { + return xerrors.New("MongoDB version must be 8.0.10 or higher") + } + + foundScram := false + for _, authMode := range r.Spec.Security.Authentication.Modes { + // Check for SCRAM, SCRAM-SHA-1, or SCRAM-SHA-256 + if strings.HasPrefix(strings.ToUpper(string(authMode)), util.SCRAM) { + foundScram = true + break + } + } + + if !foundScram && len(r.Spec.Security.Authentication.Modes) > 0 { + return xerrors.New("MongoDBSearch requires SCRAM authentication to be enabled") + } + + return nil +} diff --git a/controllers/search_controller/community_search_source_test.go b/controllers/search_controller/community_search_source_test.go new file mode 100644 index 000000000..90bf9967e --- /dev/null +++ b/controllers/search_controller/community_search_source_test.go @@ -0,0 +1,208 @@ +package search_controller + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + mdbcv1 "github.com/mongodb/mongodb-kubernetes/mongodb-community-operator/api/v1" +) + +func newCommunitySearchSource(version string, authModes []mdbcv1.AuthMode) *CommunitySearchSource { + return &CommunitySearchSource{ + MongoDBCommunity: &mdbcv1.MongoDBCommunity{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-mongodb", + Namespace: "test-namespace", + }, + Spec: mdbcv1.MongoDBCommunitySpec{ + Version: version, + Security: mdbcv1.Security{ + Authentication: mdbcv1.Authentication{ + Modes: authModes, + }, + }, + }, + }, + } +} + +func TestCommunitySearchSource_Validate(t *testing.T) { + cases := []struct { + name string + version string + authModes []mdbcv1.AuthMode + expectError bool + expectedErrMsg string + }{ + // Version validation tests + { + name: "Invalid version", + version: "invalid.version", + authModes: []mdbcv1.AuthMode{"SCRAM-SHA-256"}, + expectError: true, + expectedErrMsg: "error parsing MongoDB version", + }, + { + name: "Version too old", + version: "7.0.0", + authModes: []mdbcv1.AuthMode{"SCRAM-SHA-256"}, + expectError: true, + expectedErrMsg: "MongoDB version must be 8.0.10 or higher", + }, + { + name: "Version just below minimum", + version: "8.0.9", + authModes: []mdbcv1.AuthMode{"SCRAM-SHA-256"}, + expectError: true, + expectedErrMsg: "MongoDB version must be 8.0.10 or higher", + }, + { + name: "Valid minimum version", + version: "8.0.10", + authModes: []mdbcv1.AuthMode{"SCRAM-SHA-256"}, + expectError: false, + }, + { + name: "Version above minimum", + version: "8.1.0", + authModes: []mdbcv1.AuthMode{"SCRAM-SHA-256"}, + expectError: false, + }, + { + name: "Version with build number", + version: "8.1.0-rc1", + authModes: []mdbcv1.AuthMode{"SCRAM-SHA-256"}, + expectError: false, + }, + // Authentication mode tests - empty/nil cases + { + name: "Empty auth modes", + version: "8.0.10", + authModes: []mdbcv1.AuthMode{}, + expectError: false, + }, + { + name: "Nil auth modes", + version: "8.0.10", + authModes: nil, + expectError: false, + }, + { + name: "X509 mode only", + version: "8.0.10", + authModes: []mdbcv1.AuthMode{"X509"}, + expectError: true, + expectedErrMsg: "MongoDBSearch requires SCRAM authentication to be enabled", + }, + { + name: "X509 and SCRAM", + version: "8.0.10", + authModes: []mdbcv1.AuthMode{"X509", "SCRAM-SHA-256"}, + expectError: false, + }, + { + name: "Multiple auth modes with SCRAM first", + version: "8.0.10", + authModes: []mdbcv1.AuthMode{"SCRAM-SHA-1", "X509"}, + expectError: false, + }, + { + name: "Multiple auth modes with SCRAM last", + version: "8.0.10", + authModes: []mdbcv1.AuthMode{"PLAIN", "X509", "SCRAM-SHA-256"}, + expectError: false, + }, + { + name: "Multiple non-SCRAM modes", + version: "8.0.10", + authModes: []mdbcv1.AuthMode{"PLAIN", "X509"}, + expectError: true, + expectedErrMsg: "MongoDBSearch requires SCRAM authentication to be enabled", + }, + // SCRAM variant tests + { + name: "SCRAM only", + version: "8.0.10", + authModes: []mdbcv1.AuthMode{"SCRAM"}, + expectError: false, + }, + { + name: "SCRAM-SHA-1 only", + version: "8.0.10", + authModes: []mdbcv1.AuthMode{"SCRAM-SHA-1"}, + expectError: false, + }, + { + name: "SCRAM-SHA-256 only", + version: "8.0.10", + authModes: []mdbcv1.AuthMode{"SCRAM-SHA-256"}, + expectError: false, + }, + { + name: "All SCRAM variants", + version: "8.0.10", + authModes: []mdbcv1.AuthMode{"SCRAM", "SCRAM-SHA-1", "SCRAM-SHA-256"}, + expectError: false, + }, + // Case-insensitive tests (now supported with ToUpper) + { + name: "Lowercase SCRAM", + version: "8.0.10", + authModes: []mdbcv1.AuthMode{"scram-sha-256"}, + expectError: false, + }, + { + name: "Mixed case SCRAM", + version: "8.0.10", + authModes: []mdbcv1.AuthMode{"Scram-Sha-256"}, + expectError: false, + }, + // Edge case tests + { + name: "PLAIN only", + version: "8.0.10", + authModes: []mdbcv1.AuthMode{"PLAIN"}, + expectError: true, + expectedErrMsg: "MongoDBSearch requires SCRAM authentication to be enabled", + }, + // Combined validation tests + { + name: "Invalid version with valid auth", + version: "7.0.0", + authModes: []mdbcv1.AuthMode{"SCRAM-SHA-256"}, + expectError: true, + expectedErrMsg: "MongoDB version must be 8.0.10 or higher", + }, + { + name: "Valid version with invalid auth", + version: "8.0.10", + authModes: []mdbcv1.AuthMode{"X509"}, + expectError: true, + expectedErrMsg: "MongoDBSearch requires SCRAM authentication to be enabled", + }, + { + name: "Invalid version with invalid auth", + version: "7.0.0", + authModes: []mdbcv1.AuthMode{"X509"}, + expectError: true, + expectedErrMsg: "MongoDB version must be 8.0.10 or higher", // Should fail on version first + }, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + src := newCommunitySearchSource(c.version, c.authModes) + err := src.Validate() + + if c.expectError { + assert.Error(t, err) + assert.Contains(t, err.Error(), c.expectedErrMsg) + } else { + assert.NoError(t, err) + } + }) + } +} diff --git a/controllers/search_controller/enterprise_search_source.go b/controllers/search_controller/enterprise_search_source.go new file mode 100644 index 000000000..3b837cace --- /dev/null +++ b/controllers/search_controller/enterprise_search_source.go @@ -0,0 +1,93 @@ +package search_controller + +import ( + "fmt" + "strings" + + "github.com/blang/semver" + "golang.org/x/xerrors" + "k8s.io/apimachinery/pkg/types" + + mdbv1 "github.com/mongodb/mongodb-kubernetes/api/v1/mdb" + "github.com/mongodb/mongodb-kubernetes/pkg/util" +) + +type EnterpriseResourceSearchSource struct { + *mdbv1.MongoDB +} + +func NewEnterpriseResourceSearchSource(mdb *mdbv1.MongoDB) SearchSourceDBResource { + return EnterpriseResourceSearchSource{mdb} +} + +func (r EnterpriseResourceSearchSource) NamespacedName() types.NamespacedName { + return types.NamespacedName{ + Name: r.Name, + Namespace: r.Namespace, + } +} + +func (r EnterpriseResourceSearchSource) Members() int { + return r.Spec.Replicas() +} + +func (r EnterpriseResourceSearchSource) GetMongoDBVersion() string { + return r.Spec.GetMongoDBVersion() +} + +func (r EnterpriseResourceSearchSource) DatabasePort() int { + return int(r.MongoDB.Spec.GetAdditionalMongodConfig().GetPortOrDefault()) +} + +func (r EnterpriseResourceSearchSource) DatabaseServiceName() string { + return r.ServiceName() +} + +func (r EnterpriseResourceSearchSource) KeyfileSecretName() string { + return fmt.Sprintf("%s-keyfile", r.Name) +} + +func (r EnterpriseResourceSearchSource) IsSecurityTLSConfigEnabled() bool { + return r.Spec.Security.IsTLSEnabled() +} + +func (r EnterpriseResourceSearchSource) TLSOperatorCASecretNamespacedName() types.NamespacedName { + return types.NamespacedName{} +} + +func (r EnterpriseResourceSearchSource) Validate() error { + version, err := semver.ParseTolerant(r.Spec.GetMongoDBVersion()) + if err != nil { + return xerrors.Errorf("error parsing MongoDB version '%s': %w", r.Spec.GetMongoDBVersion(), err) + } else if version.LT(semver.MustParse("8.0.10")) { + return xerrors.New("MongoDB version must be 8.0.10 or higher") + } + + if r.Spec.GetTopology() != mdbv1.ClusterTopologySingleCluster { + return xerrors.Errorf("MongoDBSearch is only supported for %s topology", mdbv1.ClusterTopologySingleCluster) + } + + if r.GetResourceType() != mdbv1.ReplicaSet { + return xerrors.Errorf("MongoDBSearch is only supported for %s resources", mdbv1.ReplicaSet) + } + + authModes := r.Spec.GetSecurityAuthenticationModes() + foundScram := false + for _, authMode := range authModes { + // Check for SCRAM, SCRAM-SHA-1, or SCRAM-SHA-256 + if strings.HasPrefix(strings.ToUpper(authMode), util.SCRAM) { + foundScram = true + break + } + } + + if !foundScram && len(authModes) > 0 { + return xerrors.New("MongoDBSearch requires SCRAM authentication to be enabled") + } + + if r.Spec.Security.GetInternalClusterAuthenticationMode() == util.X509 { + return xerrors.New("MongoDBSearch does not support X.509 internal cluster authentication") + } + + return nil +} diff --git a/controllers/search_controller/enterprise_search_source_test.go b/controllers/search_controller/enterprise_search_source_test.go new file mode 100644 index 000000000..1362ab605 --- /dev/null +++ b/controllers/search_controller/enterprise_search_source_test.go @@ -0,0 +1,290 @@ +package search_controller + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + mdbv1 "github.com/mongodb/mongodb-kubernetes/api/v1/mdb" +) + +func newEnterpriseSearchSource(version string, topology string, resourceType mdbv1.ResourceType, authModes []string, internalClusterAuth string) EnterpriseResourceSearchSource { + authModesList := make([]mdbv1.AuthMode, len(authModes)) + for i, mode := range authModes { + authModesList[i] = mdbv1.AuthMode(mode) + } + + // Create security with authentication if needed + var security *mdbv1.Security + if len(authModes) > 0 || internalClusterAuth != "" { + security = &mdbv1.Security{ + Authentication: &mdbv1.Authentication{ + Enabled: len(authModes) > 0, + Modes: authModesList, + InternalCluster: internalClusterAuth, + }, + } + } + + src := EnterpriseResourceSearchSource{ + MongoDB: &mdbv1.MongoDB{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-mongodb", + Namespace: "test-namespace", + }, + Spec: mdbv1.MongoDbSpec{ + DbCommonSpec: mdbv1.DbCommonSpec{ + Version: version, + ResourceType: resourceType, + Security: security, + }, + }, + }, + } + + // Set topology directly since it's inlined from DbCommonSpec + src.Spec.Topology = topology + return src +} + +func TestEnterpriseResourceSearchSource_Validate(t *testing.T) { + cases := []struct { + name string + version string + topology string + resourceType mdbv1.ResourceType + authModes []string + internalClusterAuth string + expectError bool + expectedErrMsg string + }{ + // Version validation tests + { + name: "Invalid version", + version: "invalid.version", + topology: mdbv1.ClusterTopologySingleCluster, + resourceType: mdbv1.ReplicaSet, + authModes: []string{}, + expectError: true, + expectedErrMsg: "error parsing MongoDB version", + }, + { + name: "Version too old", + version: "7.0.0", + topology: mdbv1.ClusterTopologySingleCluster, + resourceType: mdbv1.ReplicaSet, + authModes: []string{}, + expectError: true, + expectedErrMsg: "MongoDB version must be 8.0.10 or higher", + }, + { + name: "Version just below minimum", + version: "8.0.9", + topology: mdbv1.ClusterTopologySingleCluster, + resourceType: mdbv1.ReplicaSet, + authModes: []string{}, + expectError: true, + expectedErrMsg: "MongoDB version must be 8.0.10 or higher", + }, + { + name: "Valid minimum version", + version: "8.0.10", + topology: mdbv1.ClusterTopologySingleCluster, + resourceType: mdbv1.ReplicaSet, + authModes: []string{}, + expectError: false, + }, + { + name: "Version above minimum", + version: "8.1.0", + topology: mdbv1.ClusterTopologySingleCluster, + resourceType: mdbv1.ReplicaSet, + authModes: []string{}, + expectError: false, + }, + // Topology validation tests + { + name: "Invalid topology - MultiCluster", + version: "8.0.10", + topology: mdbv1.ClusterTopologyMultiCluster, + resourceType: mdbv1.ReplicaSet, + authModes: []string{}, + expectError: true, + expectedErrMsg: "MongoDBSearch is only supported for SingleCluster topology", + }, + { + name: "Valid topology - SingleCluster", + version: "8.0.10", + topology: mdbv1.ClusterTopologySingleCluster, + resourceType: mdbv1.ReplicaSet, + authModes: []string{}, + expectError: false, + }, + { + name: "Empty topology defaults to SingleCluster", + version: "8.0.10", + topology: "", + resourceType: mdbv1.ReplicaSet, + authModes: []string{}, + expectError: false, + }, + // Resource type validation tests + { + name: "Invalid resource type - Standalone", + version: "8.0.10", + topology: mdbv1.ClusterTopologySingleCluster, + resourceType: mdbv1.Standalone, + authModes: []string{}, + expectError: true, + expectedErrMsg: "MongoDBSearch is only supported for ReplicaSet resources", + }, + { + name: "Invalid resource type - ShardedCluster", + version: "8.0.10", + topology: mdbv1.ClusterTopologySingleCluster, + resourceType: mdbv1.ShardedCluster, + authModes: []string{}, + expectError: true, + expectedErrMsg: "MongoDBSearch is only supported for ReplicaSet resources", + }, + { + name: "Valid resource type - ReplicaSet", + version: "8.0.10", + topology: mdbv1.ClusterTopologySingleCluster, + resourceType: mdbv1.ReplicaSet, + authModes: []string{}, + expectError: false, + }, + // Authentication mode tests + { + name: "No SCRAM authentication", + version: "8.0.10", + topology: mdbv1.ClusterTopologySingleCluster, + resourceType: mdbv1.ReplicaSet, + authModes: []string{"X509"}, + expectError: true, + expectedErrMsg: "MongoDBSearch requires SCRAM authentication to be enabled", + }, + { + name: "Empty authentication modes", + version: "8.0.10", + topology: mdbv1.ClusterTopologySingleCluster, + resourceType: mdbv1.ReplicaSet, + authModes: []string{}, + expectError: false, + }, + { + name: "Nil authentication modes", + version: "8.0.10", + topology: mdbv1.ClusterTopologySingleCluster, + resourceType: mdbv1.ReplicaSet, + authModes: nil, + expectError: false, + }, + { + name: "Valid SCRAM authentication", + version: "8.0.10", + topology: mdbv1.ClusterTopologySingleCluster, + resourceType: mdbv1.ReplicaSet, + authModes: []string{"SCRAM-SHA-256"}, + expectError: false, + }, + { + name: "Mixed auth modes with SCRAM", + version: "8.0.10", + topology: mdbv1.ClusterTopologySingleCluster, + resourceType: mdbv1.ReplicaSet, + authModes: []string{"X509", "SCRAM-SHA-256"}, + expectError: false, + }, + { + name: "Case insensitive SCRAM", + version: "8.0.10", + topology: mdbv1.ClusterTopologySingleCluster, + resourceType: mdbv1.ReplicaSet, + authModes: []string{"scram-sha-256"}, + expectError: false, + }, + { + name: "SCRAM variants", + version: "8.0.10", + topology: mdbv1.ClusterTopologySingleCluster, + resourceType: mdbv1.ReplicaSet, + authModes: []string{"SCRAM", "SCRAM-SHA-1", "SCRAM-SHA-256"}, + expectError: false, + }, + // Internal cluster authentication tests + { + name: "X509 internal cluster auth not supported", + version: "8.0.10", + topology: mdbv1.ClusterTopologySingleCluster, + resourceType: mdbv1.ReplicaSet, + authModes: []string{"SCRAM-SHA-256"}, + internalClusterAuth: "X509", + expectError: true, + expectedErrMsg: "MongoDBSearch does not support X.509 internal cluster authentication", + }, + { + name: "Valid internal cluster auth - empty", + version: "8.0.10", + topology: mdbv1.ClusterTopologySingleCluster, + resourceType: mdbv1.ReplicaSet, + authModes: []string{"SCRAM-SHA-256"}, + internalClusterAuth: "", + expectError: false, + }, + { + name: "Valid internal cluster auth - SCRAM", + version: "8.0.10", + topology: mdbv1.ClusterTopologySingleCluster, + resourceType: mdbv1.ReplicaSet, + authModes: []string{"SCRAM-SHA-256"}, + internalClusterAuth: "SCRAM", + expectError: false, + }, + // Combined validation tests + { + name: "Multiple validation failures - version takes precedence", + version: "7.0.0", + topology: mdbv1.ClusterTopologyMultiCluster, + resourceType: mdbv1.Standalone, + authModes: []string{"X509"}, + expectError: true, + expectedErrMsg: "MongoDB version must be 8.0.10 or higher", + }, + { + name: "Valid version, invalid topology", + version: "8.0.10", + topology: mdbv1.ClusterTopologyMultiCluster, + resourceType: mdbv1.ReplicaSet, + authModes: []string{}, + expectError: true, + expectedErrMsg: "MongoDBSearch is only supported for SingleCluster topology", + }, + { + name: "Valid version and topology, invalid resource type", + version: "8.0.10", + topology: mdbv1.ClusterTopologySingleCluster, + resourceType: mdbv1.Standalone, + authModes: []string{}, + expectError: true, + expectedErrMsg: "MongoDBSearch is only supported for ReplicaSet resources", + }, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + src := newEnterpriseSearchSource(c.version, c.topology, c.resourceType, c.authModes, c.internalClusterAuth) + err := src.Validate() + + if c.expectError { + assert.Error(t, err) + assert.Contains(t, err.Error(), c.expectedErrMsg) + } else { + assert.NoError(t, err) + } + }) + } +} diff --git a/controllers/search_controller/mongodbsearch_reconcile_helper.go b/controllers/search_controller/mongodbsearch_reconcile_helper.go index 08a264853..143311a8c 100644 --- a/controllers/search_controller/mongodbsearch_reconcile_helper.go +++ b/controllers/search_controller/mongodbsearch_reconcile_helper.go @@ -7,7 +7,6 @@ import ( "fmt" "strings" - "github.com/blang/semver" "github.com/ghodss/yaml" "go.uber.org/zap" "golang.org/x/xerrors" @@ -19,8 +18,10 @@ import ( appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + mdbv1 "github.com/mongodb/mongodb-kubernetes/api/v1/mdb" searchv1 "github.com/mongodb/mongodb-kubernetes/api/v1/search" "github.com/mongodb/mongodb-kubernetes/controllers/operator/workflow" "github.com/mongodb/mongodb-kubernetes/mongodb-community-operator/pkg/automationconfig" @@ -30,6 +31,7 @@ import ( "github.com/mongodb/mongodb-kubernetes/mongodb-community-operator/pkg/kube/service" "github.com/mongodb/mongodb-kubernetes/mongodb-community-operator/pkg/mongot" "github.com/mongodb/mongodb-kubernetes/mongodb-community-operator/pkg/tls" + "github.com/mongodb/mongodb-kubernetes/pkg/kube" "github.com/mongodb/mongodb-kubernetes/pkg/kube/commoncontroller" "github.com/mongodb/mongodb-kubernetes/pkg/statefulset" ) @@ -82,7 +84,7 @@ func (r *MongoDBSearchReconcileHelper) reconcile(ctx context.Context, log *zap.S log = log.With("MongoDBSearch", r.mdbSearch.NamespacedName()) log.Infof("Reconciling MongoDBSearch") - if err := ValidateSearchSource(r.db); err != nil { + if err := r.db.Validate(); err != nil { return workflow.Failed(err) } @@ -94,6 +96,13 @@ func (r *MongoDBSearchReconcileHelper) reconcile(ctx context.Context, log *zap.S return workflow.Failed(err) } + keyfileStsModification, err := r.ensureSourceKeyfile(ctx, log) + if apierrors.IsNotFound(err) { + return workflow.Pending("Waiting for keyfile secret to be created") + } else if err != nil { + return workflow.Failed(err) + } + if err := r.ensureSearchService(ctx, r.mdbSearch); err != nil { return workflow.Failed(err) } @@ -119,7 +128,7 @@ func (r *MongoDBSearchReconcileHelper) reconcile(ctx context.Context, log *zap.S }, )) - if err := r.createOrUpdateStatefulSet(ctx, log, CreateSearchStatefulSetFunc(r.mdbSearch, r.db, r.buildImageString()), configHashModification, ingressTlsStsModification, egressTlsStsModification); err != nil { + if err := r.createOrUpdateStatefulSet(ctx, log, CreateSearchStatefulSetFunc(r.mdbSearch, r.db, r.buildImageString()), configHashModification, keyfileStsModification, ingressTlsStsModification, egressTlsStsModification); err != nil { return workflow.Failed(err) } @@ -130,6 +139,23 @@ func (r *MongoDBSearchReconcileHelper) reconcile(ctx context.Context, log *zap.S return workflow.OK() } +func (r *MongoDBSearchReconcileHelper) ensureSourceKeyfile(ctx context.Context, log *zap.SugaredLogger) (statefulset.Modification, error) { + keyfileSecretName := kube.ObjectKey(r.db.GetNamespace(), r.db.KeyfileSecretName()) + keyfileSecret := &corev1.Secret{} + if err := r.client.Get(ctx, keyfileSecretName, keyfileSecret); err != nil { + return nil, err + } + + return statefulset.Apply( + // make sure mongot pods get restarted if the keyfile changes + statefulset.WithPodSpecTemplate(podtemplatespec.WithAnnotations( + map[string]string{ + "keyfileHash": hashBytes(keyfileSecret.Data["keyfile"]), + }, + )), + ), nil +} + func (r *MongoDBSearchReconcileHelper) buildImageString() string { imageVersion := r.mdbSearch.Spec.Version if imageVersion == "" { @@ -197,7 +223,7 @@ func (r *MongoDBSearchReconcileHelper) ensureMongotConfig(ctx context.Context, l log.Debugf("Updated mongot config yaml config map: %v (%s) with the following configuration: %s", cmName, op, string(configData)) - return hashMongotConfig(configData), nil + return hashBytes(configData), nil } func (r *MongoDBSearchReconcileHelper) ensureIngressTlsConfig(ctx context.Context) (mongot.Modification, statefulset.Modification, error) { @@ -287,8 +313,8 @@ cp /mongot-community/bin/jdk/lib/security/cacerts /java/trust-store/cacerts return mongotModification, statefulsetModification, nil } -func hashMongotConfig(mongotConfigYaml []byte) string { - hashBytes := sha256.Sum256(mongotConfigYaml) +func hashBytes(bytes []byte) string { + hashBytes := sha256.Sum256(bytes) return base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(hashBytes[:]) } @@ -336,7 +362,7 @@ func createMongotConfig(search *searchv1.MongoDBSearch, db SearchSourceDBResourc return func(config *mongot.Config) { var hostAndPorts []string for i := range db.Members() { - hostAndPorts = append(hostAndPorts, fmt.Sprintf("%s-%d.%s.%s.svc.cluster.local:%d", db.Name(), i, db.DatabaseServiceName(), db.GetNamespace(), db.DatabasePort())) + hostAndPorts = append(hostAndPorts, fmt.Sprintf("%s-%d.%s.%s.svc.cluster.local:%d", db.GetName(), i, db.DatabaseServiceName(), db.GetNamespace(), db.DatabasePort())) } config.SyncSource = mongot.ConfigSyncSource{ @@ -385,7 +411,7 @@ func GetMongodConfigParameters(search *searchv1.MongoDBSearch) map[string]any { "mongotHost": mongotHostAndPort(search), "searchIndexManagementHostAndPort": mongotHostAndPort(search), "skipAuthenticationToSearchIndexManagementServer": false, - "searchTLSMode": searchTLSMode, + "searchTLSMode": string(searchTLSMode), }, } } @@ -395,23 +421,12 @@ func mongotHostAndPort(search *searchv1.MongoDBSearch) string { return fmt.Sprintf("%s.%s.svc.cluster.local:%d", svcName.Name, svcName.Namespace, search.GetMongotPort()) } -func ValidateSearchSource(db SearchSourceDBResource) error { - version, err := semver.ParseTolerant(db.GetMongoDBVersion()) - if err != nil { - return xerrors.Errorf("error parsing MongoDB version '%s': %w", db.GetMongoDBVersion(), err) - } else if version.LT(semver.MustParse("8.0.10")) { - return xerrors.New("MongoDB version must be 8.0.10 or higher") - } - - return nil -} - func (r *MongoDBSearchReconcileHelper) ValidateSingleMongoDBSearchForSearchSource(ctx context.Context) error { searchList := &searchv1.MongoDBSearchList{} if err := r.client.List(ctx, searchList, &client.ListOptions{ - FieldSelector: fields.OneTermEqualSelector(MongoDBSearchIndexFieldName, r.db.GetNamespace()+"/"+r.db.Name()), + FieldSelector: fields.OneTermEqualSelector(MongoDBSearchIndexFieldName, r.db.GetNamespace()+"/"+r.db.GetName()), }); err != nil { - return xerrors.Errorf("Error listing MongoDBSearch resources for search source '%s': %w", r.db.Name(), err) + return xerrors.Errorf("Error listing MongoDBSearch resources for search source '%s': %w", r.db.GetName(), err) } if len(searchList.Items) > 1 { @@ -420,7 +435,7 @@ func (r *MongoDBSearchReconcileHelper) ValidateSingleMongoDBSearchForSearchSourc resourceNames[i] = search.Name } return xerrors.Errorf( - "Found multiple MongoDBSearch resources for search source '%s': %s", r.db.Name(), + "Found multiple MongoDBSearch resources for search source '%s': %s", r.db.GetName(), strings.Join(resourceNames, ", "), ) } @@ -460,3 +475,51 @@ func (r *MongoDBSearchReconcileHelper) getMongotImage() string { return "" } + +func SearchCoordinatorRole() mdbv1.MongoDBRole { + // direct translation of https://github.com/10gen/mongo/blob/6f8d95a513eea8f91ea9f5d895dd8a288dfcf725/src/mongo/db/auth/builtin_roles.yml#L652 + return mdbv1.MongoDBRole{ + Role: "searchCoordinator", + Db: "admin", + Roles: []mdbv1.InheritedRole{ + { + Role: "clusterMonitor", + Db: "admin", + }, + { + Role: "directShardOperations", + Db: "admin", + }, + { + Role: "readAnyDatabase", + Db: "admin", + }, + }, + Privileges: []mdbv1.Privilege{ + { + Resource: mdbv1.Resource{ + Db: "__mdb_internal_search", + }, + Actions: []string{ + "changeStream", "collStats", "dbHash", "dbStats", "find", + "killCursors", "listCollections", "listIndexes", "listSearchIndexes", + // performRawDataOperations is available only on mongod master + // "performRawDataOperations", + "planCacheRead", "cleanupStructuredEncryptionData", + "compactStructuredEncryptionData", "convertToCapped", "createCollection", + "createIndex", "createSearchIndexes", "dropCollection", "dropIndex", + "dropSearchIndex", "insert", "remove", "renameCollectionSameDB", + "update", "updateSearchIndex", + }, + }, + // TODO: this causes the error "(BadValue) resource: {cluster: true} conflicts with resource type 'db'" + // { + // Resource: mdbv1.Resource{ + // Cluster: ptr.To(true), + // }, + // Actions: []string{"bypassDefaultMaxTimeMS"}, + // }, + }, + AuthenticationRestrictions: nil, + } +} diff --git a/controllers/search_controller/mongodbsearch_reconcile_helper_test.go b/controllers/search_controller/mongodbsearch_reconcile_helper_test.go index f70ec9a03..5a2a757ce 100644 --- a/controllers/search_controller/mongodbsearch_reconcile_helper_test.go +++ b/controllers/search_controller/mongodbsearch_reconcile_helper_test.go @@ -15,65 +15,6 @@ import ( kubernetesClient "github.com/mongodb/mongodb-kubernetes/mongodb-community-operator/pkg/kube/client" ) -func TestMongoDBSearchReconcileHelper_ValidateSearchSource(t *testing.T) { - mdbcMeta := metav1.ObjectMeta{ - Name: "test-mongodb", - Namespace: "test", - } - - cases := []struct { - name string - mdbc mdbcv1.MongoDBCommunity - expectedError string - }{ - { - name: "Invalid version", - mdbc: mdbcv1.MongoDBCommunity{ - ObjectMeta: mdbcMeta, - Spec: mdbcv1.MongoDBCommunitySpec{ - Version: "4.4.0", - }, - }, - expectedError: "MongoDB version must be 8.0.10 or higher", - }, - { - name: "Valid version", - mdbc: mdbcv1.MongoDBCommunity{ - ObjectMeta: mdbcMeta, - Spec: mdbcv1.MongoDBCommunitySpec{ - Version: "8.0.10", - }, - }, - }, - { - name: "TLS enabled", - mdbc: mdbcv1.MongoDBCommunity{ - ObjectMeta: mdbcMeta, - Spec: mdbcv1.MongoDBCommunitySpec{ - Version: "8.0.10", - Security: mdbcv1.Security{ - TLS: mdbcv1.TLS{ - Enabled: true, - }, - }, - }, - }, - }, - } - - for _, c := range cases { - t.Run(c.name, func(t *testing.T) { - db := NewSearchSourceDBResourceFromMongoDBCommunity(&c.mdbc) - err := ValidateSearchSource(db) - if c.expectedError == "" { - assert.NoError(t, err) - } else { - assert.EqualError(t, err, c.expectedError) - } - }) - } -} - func TestMongoDBSearchReconcileHelper_ValidateSingleMongoDBSearchForSearchSource(t *testing.T) { mdbSearchSpec := searchv1.MongoDBSearchSpec{ Source: &searchv1.MongoDBSource{ @@ -144,7 +85,7 @@ func TestMongoDBSearchReconcileHelper_ValidateSingleMongoDBSearchForSearchSource clientBuilder.WithObjects(v) } - helper := NewMongoDBSearchReconcileHelper(kubernetesClient.NewClient(clientBuilder.Build()), mdbSearch, NewSearchSourceDBResourceFromMongoDBCommunity(mdbc), OperatorSearchConfig{}) + helper := NewMongoDBSearchReconcileHelper(kubernetesClient.NewClient(clientBuilder.Build()), mdbSearch, NewCommunityResourceSearchSource(mdbc), OperatorSearchConfig{}) err := helper.ValidateSingleMongoDBSearchForSearchSource(t.Context()) if c.expectedError == "" { assert.NoError(t, err) diff --git a/controllers/search_controller/search_construction.go b/controllers/search_controller/search_construction.go index 6aae8e982..7ad062001 100644 --- a/controllers/search_controller/search_construction.go +++ b/controllers/search_controller/search_construction.go @@ -9,7 +9,6 @@ import ( searchv1 "github.com/mongodb/mongodb-kubernetes/api/v1/search" "github.com/mongodb/mongodb-kubernetes/controllers/operator/construct" - mdbcv1 "github.com/mongodb/mongodb-kubernetes/mongodb-community-operator/api/v1" "github.com/mongodb/mongodb-kubernetes/mongodb-community-operator/api/v1/common" "github.com/mongodb/mongodb-kubernetes/mongodb-community-operator/pkg/kube/container" "github.com/mongodb/mongodb-kubernetes/mongodb-community-operator/pkg/kube/podtemplatespec" @@ -31,69 +30,16 @@ const ( // // TODO check if we could use already existing interface (DbCommon, MongoDBStatefulSetOwner, etc.) type SearchSourceDBResource interface { - Name() string + GetName() string NamespacedName() types.NamespacedName KeyfileSecretName() string GetNamespace() string - HasSeparateDataAndLogsVolumes() bool DatabaseServiceName() string DatabasePort() int - GetMongoDBVersion() string IsSecurityTLSConfigEnabled() bool TLSOperatorCASecretNamespacedName() types.NamespacedName Members() int -} - -func NewSearchSourceDBResourceFromMongoDBCommunity(mdbc *mdbcv1.MongoDBCommunity) SearchSourceDBResource { - return &mdbcSearchResource{db: mdbc} -} - -type mdbcSearchResource struct { - db *mdbcv1.MongoDBCommunity -} - -func (r *mdbcSearchResource) Members() int { - return r.db.Spec.Members -} - -func (r *mdbcSearchResource) Name() string { - return r.db.Name -} - -func (r *mdbcSearchResource) NamespacedName() types.NamespacedName { - return r.db.NamespacedName() -} - -func (r *mdbcSearchResource) KeyfileSecretName() string { - return r.db.GetAgentKeyfileSecretNamespacedName().Name -} - -func (r *mdbcSearchResource) GetNamespace() string { - return r.db.Namespace -} - -func (r *mdbcSearchResource) HasSeparateDataAndLogsVolumes() bool { - return r.db.HasSeparateDataAndLogsVolumes() -} - -func (r *mdbcSearchResource) DatabaseServiceName() string { - return r.db.ServiceName() -} - -func (r *mdbcSearchResource) GetMongoDBVersion() string { - return r.db.Spec.Version -} - -func (r *mdbcSearchResource) IsSecurityTLSConfigEnabled() bool { - return r.db.Spec.Security.TLS.Enabled -} - -func (r *mdbcSearchResource) DatabasePort() int { - return r.db.GetMongodConfiguration().GetDBPort() -} - -func (r *mdbcSearchResource) TLSOperatorCASecretNamespacedName() types.NamespacedName { - return r.db.TLSOperatorCASecretNamespacedName() + Validate() error } // ReplicaSetOptions returns a set of options which will configure a ReplicaSet StatefulSet diff --git a/docker/mongodb-kubernetes-tests/tests/common/search/search_tester.py b/docker/mongodb-kubernetes-tests/tests/common/search/search_tester.py index 8d8334ee9..ac8c6654d 100644 --- a/docker/mongodb-kubernetes-tests/tests/common/search/search_tester.py +++ b/docker/mongodb-kubernetes-tests/tests/common/search/search_tester.py @@ -32,7 +32,18 @@ def mongorestore_from_url(self, archive_url: str, ns_include: str, mongodb_tools mongorestore_cmd += " --ssl" if ca_path := self.default_opts.get("tlsCAFile"): mongorestore_cmd += " --sslCAFile=" + ca_path - process_run_and_check(mongorestore_cmd.split()) + process_run_and_check(mongorestore_cmd.split(), capture_output=True) + + def assert_search_enabled(self): + try: + result = self.client.admin.command("getCmdLineOpts") + setParameter = result.get("parsed", {}).get("setParameter", {}) + assert ( + "mongotHost" in setParameter and "searchIndexManagementHostAndPort" in setParameter + ), "mongot parameters not found in mongod config" + except Exception as e: + logger.error(f"Error checking if search is enabled: {e}") + raise def create_search_index(self, database_name: str, collection_name: str): database = self.client[database_name] diff --git a/docker/mongodb-kubernetes-tests/tests/search/fixtures/enterprise-replicaset-sample-mflix.yaml b/docker/mongodb-kubernetes-tests/tests/search/fixtures/enterprise-replicaset-sample-mflix.yaml new file mode 100644 index 000000000..29d455b51 --- /dev/null +++ b/docker/mongodb-kubernetes-tests/tests/search/fixtures/enterprise-replicaset-sample-mflix.yaml @@ -0,0 +1,34 @@ +--- +apiVersion: mongodb.com/v1 +kind: MongoDB +metadata: + name: mdb-rs +spec: + members: 3 + version: 8.0.10 + type: ReplicaSet + opsManager: + configMapRef: + name: my-project + credentials: my-credentials + security: + authentication: + enabled: true + ignoreUnknownUsers: true + modes: + - SCRAM + agent: + logLevel: DEBUG + statefulSet: + spec: + template: + spec: + containers: + - name: mongodb-enterprise-database + resources: + limits: + cpu: "2" + memory: 2Gi + requests: + cpu: "1" + memory: 1Gi diff --git a/docker/mongodb-kubernetes-tests/tests/search/fixtures/mongodbuser-mdb-admin.yaml b/docker/mongodb-kubernetes-tests/tests/search/fixtures/mongodbuser-mdb-admin.yaml new file mode 100644 index 000000000..0b3fe4c77 --- /dev/null +++ b/docker/mongodb-kubernetes-tests/tests/search/fixtures/mongodbuser-mdb-admin.yaml @@ -0,0 +1,16 @@ +# admin user with root role +apiVersion: mongodb.com/v1 +kind: MongoDBUser +metadata: + name: mdb-admin +spec: + username: mdb-admin + db: admin + mongodbResourceRef: + name: mdb-rs + passwordSecretKeyRef: + name: mdb-admin-user-password + key: password + roles: + - name: root + db: admin \ No newline at end of file diff --git a/docker/mongodb-kubernetes-tests/tests/search/fixtures/mongodbuser-mdb-user.yaml b/docker/mongodb-kubernetes-tests/tests/search/fixtures/mongodbuser-mdb-user.yaml new file mode 100644 index 000000000..579cc58d0 --- /dev/null +++ b/docker/mongodb-kubernetes-tests/tests/search/fixtures/mongodbuser-mdb-user.yaml @@ -0,0 +1,16 @@ +# user performing search queries +apiVersion: mongodb.com/v1 +kind: MongoDBUser +metadata: + name: mdb-user +spec: + username: mdb-user + db: admin + mongodbResourceRef: + name: mdb-rs + passwordSecretKeyRef: + name: mdb-user-password + key: password + roles: + - name: readWrite + db: sample_mflix diff --git a/docker/mongodb-kubernetes-tests/tests/search/fixtures/mongodbuser-search-sync-source-user.yaml b/docker/mongodb-kubernetes-tests/tests/search/fixtures/mongodbuser-search-sync-source-user.yaml new file mode 100644 index 000000000..cd1eab1a5 --- /dev/null +++ b/docker/mongodb-kubernetes-tests/tests/search/fixtures/mongodbuser-search-sync-source-user.yaml @@ -0,0 +1,18 @@ +# user used by MongoDB Search to connect to MongoDB database to synchronize data from +# For MongoDB <8.2, the operator will be creating the searchCoordinator custom role automatically +# From MongoDB 8.2, searchCoordinator role will be a built-in role. +apiVersion: mongodb.com/v1 +kind: MongoDBUser +metadata: + name: search-sync-source-user +spec: + username: search-sync-source + db: admin + mongodbResourceRef: + name: mdb-rs + passwordSecretKeyRef: + name: mdb-rs-search-sync-source-password + key: password + roles: + - name: searchCoordinator + db: admin diff --git a/docker/mongodb-kubernetes-tests/tests/search/search_enterprise_basic.py b/docker/mongodb-kubernetes-tests/tests/search/search_enterprise_basic.py new file mode 100644 index 000000000..8fb7dec8b --- /dev/null +++ b/docker/mongodb-kubernetes-tests/tests/search/search_enterprise_basic.py @@ -0,0 +1,171 @@ +from kubetester import create_or_update_secret, try_load +from kubetester.kubetester import fixture as yaml_fixture +from kubetester.mongodb import MongoDB +from kubetester.mongodb_search import MongoDBSearch +from kubetester.mongodb_user import MongoDBUser +from kubetester.phase import Phase +from pytest import fixture, mark +from tests import test_logger +from tests.common.search import movies_search_helper +from tests.common.search.movies_search_helper import SampleMoviesSearchHelper +from tests.common.search.search_tester import SearchTester +from tests.conftest import get_default_operator + +logger = test_logger.get_test_logger(__name__) + +ADMIN_USER_NAME = "mdb-admin-user" +ADMIN_USER_PASSWORD = f"{ADMIN_USER_NAME}-password" + +MONGOT_USER_NAME = "search-sync-source" +MONGOT_USER_PASSWORD = f"{MONGOT_USER_NAME}-password" + +USER_NAME = "mdb-user" +USER_PASSWORD = f"{USER_NAME}-password" + +MDB_RESOURCE_NAME = "mdb-rs" + + +@fixture(scope="function") +def mdb(namespace: str) -> MongoDB: + resource = MongoDB.from_yaml( + yaml_fixture("enterprise-replicaset-sample-mflix.yaml"), + name=MDB_RESOURCE_NAME, + namespace=namespace, + ) + + if try_load(resource): + return resource + + return resource + + +@fixture(scope="function") +def mdbs(namespace: str) -> MongoDBSearch: + resource = MongoDBSearch.from_yaml(yaml_fixture("search-minimal.yaml"), namespace=namespace, name=MDB_RESOURCE_NAME) + + if try_load(resource): + return resource + + return resource + + +@fixture(scope="function") +def admin_user(namespace: str) -> MongoDBUser: + resource = MongoDBUser.from_yaml( + yaml_fixture("mongodbuser-mdb-admin.yaml"), namespace=namespace, name=ADMIN_USER_NAME + ) + + if try_load(resource): + return resource + + resource["spec"]["username"] = resource.name + resource["spec"]["passwordSecretKeyRef"]["name"] = f"{resource.name}-password" + + return resource + + +@fixture(scope="function") +def user(namespace: str) -> MongoDBUser: + resource = MongoDBUser.from_yaml(yaml_fixture("mongodbuser-mdb-user.yaml"), namespace=namespace, name=USER_NAME) + + if try_load(resource): + return resource + + resource["spec"]["username"] = resource.name + resource["spec"]["passwordSecretKeyRef"]["name"] = f"{resource.name}-password" + + return resource + + +@fixture(scope="function") +def mongot_user(namespace: str, mdbs: MongoDBSearch) -> MongoDBUser: + resource = MongoDBUser.from_yaml( + yaml_fixture("mongodbuser-search-sync-source-user.yaml"), + namespace=namespace, + name=f"{mdbs.name}-{MONGOT_USER_NAME}", + ) + + if try_load(resource): + return resource + + resource["spec"]["username"] = MONGOT_USER_NAME + resource["spec"]["passwordSecretKeyRef"]["name"] = f"{resource.name}-password" + + return resource + + +@mark.e2e_search_enterprise_basic +def test_install_operator(namespace: str, operator_installation_config: dict[str, str]): + operator = get_default_operator(namespace, operator_installation_config=operator_installation_config) + operator.assert_is_running() + + +@mark.e2e_search_enterprise_basic +def test_create_database_resource(mdb: MongoDB): + mdb.update() + mdb.assert_reaches_phase(Phase.Running, timeout=1000) + + +@mark.e2e_search_enterprise_basic +def test_create_users( + namespace: str, admin_user: MongoDBUser, user: MongoDBUser, mongot_user: MongoDBUser, mdb: MongoDB +): + create_or_update_secret( + namespace, name=admin_user["spec"]["passwordSecretKeyRef"]["name"], data={"password": ADMIN_USER_PASSWORD} + ) + admin_user.create() + admin_user.assert_reaches_phase(Phase.Updated, timeout=300) + + create_or_update_secret( + namespace, name=user["spec"]["passwordSecretKeyRef"]["name"], data={"password": USER_PASSWORD} + ) + user.create() + user.assert_reaches_phase(Phase.Updated, timeout=300) + + create_or_update_secret( + namespace, name=mongot_user["spec"]["passwordSecretKeyRef"]["name"], data={"password": MONGOT_USER_PASSWORD} + ) + mongot_user.create() + # we deliberately don't wait for this user to be ready, because to be reconciled successfully it needs the searchCoordinator role + # which the ReplicaSet reconciler will only define in the automation config after the MongoDBSearch resource is created. + + +@mark.e2e_search_enterprise_basic +def test_create_search_resource(mdbs: MongoDBSearch): + mdbs.update() + mdbs.assert_reaches_phase(Phase.Running, timeout=300) + + +@mark.e2e_search_enterprise_basic +def test_wait_for_database_resource_ready(mdb: MongoDB): + mdb.assert_abandons_phase(Phase.Running, timeout=1800) + mdb.assert_reaches_phase(Phase.Running, timeout=1800) + SearchTester(get_connection_string(mdb, ADMIN_USER_NAME, ADMIN_USER_PASSWORD)).assert_search_enabled() + + +@fixture(scope="function") +def sample_movies_helper(request, mdb: MongoDB) -> SampleMoviesSearchHelper: + credentials = (USER_NAME, USER_PASSWORD) + if request.node.get_closest_marker("use_admin_user"): + credentials = (ADMIN_USER_NAME, ADMIN_USER_PASSWORD) + return movies_search_helper.SampleMoviesSearchHelper(SearchTester(get_connection_string(mdb, *credentials))) + + +@mark.e2e_search_enterprise_basic +@mark.use_admin_user +def test_search_restore_sample_database(sample_movies_helper: SampleMoviesSearchHelper): + sample_movies_helper.restore_sample_database() + + +@mark.e2e_search_enterprise_basic +def test_search_create_search_index(sample_movies_helper: SampleMoviesSearchHelper): + sample_movies_helper.create_search_index() + + +@mark.e2e_search_enterprise_basic +def test_search_assert_search_query(sample_movies_helper: SampleMoviesSearchHelper): + sample_movies_helper.assert_search_query(retry_timeout=60) + + +def get_connection_string(mdb: MongoDB, user_name: str, user_password: str) -> str: + return f"mongodb://{user_name}:{user_password}@{mdb.name}-0.{mdb.name}-svc.{mdb.namespace}.svc.cluster.local:27017/?replicaSet={mdb.name}" diff --git a/mongodb-community-operator/controllers/replica_set_controller.go b/mongodb-community-operator/controllers/replica_set_controller.go index 644719294..f96878d16 100644 --- a/mongodb-community-operator/controllers/replica_set_controller.go +++ b/mongodb-community-operator/controllers/replica_set_controller.go @@ -712,8 +712,8 @@ func (r ReplicaSetReconciler) buildAutomationConfig(ctx context.Context, mdb mdb // and that this resource passes search validations. If either fails, proceed without a search target // for the mongod automation config. if len(searchList.Items) == 1 { - searchSource := search_controller.NewSearchSourceDBResourceFromMongoDBCommunity(&mdb) - if search_controller.ValidateSearchSource(searchSource) == nil { + searchSource := search_controller.NewCommunityResourceSearchSource(&mdb) + if searchSource.Validate() == nil { search = &searchList.Items[0] } } diff --git a/scripts/dev/contexts/e2e_mdb_kind_ubi_cloudqa b/scripts/dev/contexts/e2e_mdb_kind_ubi_cloudqa index 03384c26c..f4e53d7ce 100644 --- a/scripts/dev/contexts/e2e_mdb_kind_ubi_cloudqa +++ b/scripts/dev/contexts/e2e_mdb_kind_ubi_cloudqa @@ -16,3 +16,7 @@ export CUSTOM_OM_VERSION export CUSTOM_MDB_VERSION=6.0.5 export CUSTOM_MDB_PREV_VERSION=5.0.7 + +export MDB_SEARCH_COMMUNITY_VERSION="fbd60fb055dd500058edcb45677ea85d19421f47" # Nolan's fixes +export MDB_SEARCH_COMMUNITY_NAME="mongot/community" +export MDB_SEARCH_COMMUNITY_REPO_URL="268558157000.dkr.ecr.eu-west-1.amazonaws.com" diff --git a/scripts/dev/contexts/e2e_static_mdb_kind_ubi_cloudqa b/scripts/dev/contexts/e2e_static_mdb_kind_ubi_cloudqa index 869396d1e..adadc3185 100644 --- a/scripts/dev/contexts/e2e_static_mdb_kind_ubi_cloudqa +++ b/scripts/dev/contexts/e2e_static_mdb_kind_ubi_cloudqa @@ -17,3 +17,7 @@ export CUSTOM_OM_VERSION export CUSTOM_MDB_PREV_VERSION=6.0.16 export CUSTOM_MDB_VERSION=7.0.5 + +export MDB_SEARCH_COMMUNITY_VERSION="fbd60fb055dd500058edcb45677ea85d19421f47" # Nolan's fixes +export MDB_SEARCH_COMMUNITY_NAME="mongot/community" +export MDB_SEARCH_COMMUNITY_REPO_URL="268558157000.dkr.ecr.eu-west-1.amazonaws.com"