diff --git a/api/v1alpha1/database_types.go b/api/v1alpha1/database_types.go index 6684b622..35b790de 100644 --- a/api/v1alpha1/database_types.go +++ b/api/v1alpha1/database_types.go @@ -92,9 +92,9 @@ type InstanceHANode struct { // Name of the VM to be created // +kubebuilder:validation:Required VmName string `json:"vmName"` - // Type of this node: "database", "haproxy" (Postgres HA), or "mysqlrouter" (MySQL HA) + // Type of this node: "database", "haproxy" (Postgres HA), "mysqlrouter" (MySQL HA), or "arbiter" (MongoDB HA) // +kubebuilder:validation:Required - // +kubebuilder:validation:Enum=haproxy;database;mysqlrouter + // +kubebuilder:validation:Enum=haproxy;database;mysqlrouter;arbiter NodeType string `json:"nodeType"` // Role of this node (database nodes only): "Primary" or "Secondary" // +optional @@ -134,6 +134,10 @@ type InstanceHAConfig struct { // Required when the database type is "mysql". // +optional MySQL *MySQLHAConfig `json:"mysql,omitempty"` + // MongoDB contains Replica Set settings specific to MongoDB HA. + // Required when the database type is "mongodb". + // +optional + MongoDB *MongoHAConfig `json:"mongodb,omitempty"` } // MySQLHAConfig holds InnoDB Cluster and MySQL Router settings specific to a MySQL HA instance. @@ -184,6 +188,30 @@ type PostgresHAConfig struct { ProvisionVirtualIP bool `json:"provisionVirtualIP,omitempty"` } +// MongoHAConfig holds Replica Set settings specific to a MongoDB HA instance. +type MongoHAConfig struct { + // ReplicaSetName is the MongoDB Replica Set name, mapped to the "cluster_name" NDB action argument. + // +kubebuilder:validation:Required + ReplicaSetName string `json:"replicaSetName"` + // ReplicaSetDescription is an optional human-readable description shown in the NDB UI. + // +optional + ReplicaSetDescription string `json:"replicaSetDescription,omitempty"` + // DeployArbiter controls whether an Arbiter VM is provisioned. + // When true, exactly one node with nodeType "arbiter" must be present in nodes[]. + // When false (default), no arbiter nodes may be present — all nodes are data-bearing. + // +optional + DeployArbiter bool `json:"deployArbiter,omitempty"` + // ArbiterComputeProfileId optionally assigns a smaller compute profile to the Arbiter VM. + // Falls back to the instance-level compute profile if unset. + // Only meaningful when DeployArbiter is true. + // +optional + ArbiterComputeProfileId string `json:"arbiterComputeProfileId,omitempty"` + // ListenerPort is the MongoDB listener port. Defaults to 27017. + // +optional + // +kubebuilder:default=27017 + ListenerPort int32 `json:"listenerPort,omitempty"` +} + // Database instance specific details type Instance struct { // Name of the database instance diff --git a/api/v1alpha1/ha_validators.go b/api/v1alpha1/ha_validators.go index 36c68650..0871ba9d 100644 --- a/api/v1alpha1/ha_validators.go +++ b/api/v1alpha1/ha_validators.go @@ -35,6 +35,7 @@ type HAParamsValidator interface { var haValidators = map[string]HAParamsValidator{ common.DATABASE_TYPE_POSTGRES: &PostgresHAParamsValidator{}, common.DATABASE_TYPE_MYSQL: &MysqlHAParamsValidator{}, + common.DATABASE_TYPE_MONGODB: &MongoHAParamsValidator{}, } // getHAValidator returns the registered HAParamsValidator for the given database type, @@ -109,6 +110,69 @@ func (v *MysqlHAParamsValidator) Validate(haConfig *InstanceHAConfig, haPath *fi } } +// MongoHAParamsValidator validates Replica Set specific fields for MongoDB HA. +// +kubebuilder:object:generate=false +type MongoHAParamsValidator struct{} + +// Validate checks MongoDB-specific HA constraints. Fields validated: +// - haConfig.mongodb — must be present (required) +// - haConfig.mongodb.replicaSetName — must be non-empty +// - haConfig.nodes[*].nodeType — must be "database" or "arbiter" +// - haConfig.nodes — arbiter nodes must not have a role set +// - haConfig.nodes — exactly one database node must have role "primary" +// - haConfig.mongodb.deployArbiter — if true, exactly one "arbiter" node must be present; +// if false, no "arbiter" nodes may be present +func (v *MongoHAParamsValidator) Validate(haConfig *InstanceHAConfig, haPath *field.Path, errors *field.ErrorList) { + mgPath := haPath.Child("mongodb") + + if haConfig.MongoDB == nil { + *errors = append(*errors, field.Required(mgPath, + "mongodb config must be specified in haConfig when database type is mongodb")) + return + } + + mg := haConfig.MongoDB + if mg.ReplicaSetName == "" { + *errors = append(*errors, field.Invalid(mgPath.Child("replicaSetName"), + mg.ReplicaSetName, "replicaSetName must be specified")) + } + + primaryCount := 0 + arbiterCount := 0 + for i, node := range haConfig.Nodes { + nodePath := haPath.Child("nodes").Index(i) + if node.NodeType != common.HA_NODE_TYPE_DATABASE && node.NodeType != common.HA_NODE_TYPE_ARBITER { + *errors = append(*errors, field.Invalid(nodePath.Child("nodeType"), node.NodeType, + "nodeType must be either '"+common.HA_NODE_TYPE_DATABASE+"' or '"+common.HA_NODE_TYPE_ARBITER+"' for mongodb HA")) + } + if node.NodeType == common.HA_NODE_TYPE_ARBITER && node.Role != "" { + *errors = append(*errors, field.Invalid(nodePath.Child("role"), node.Role, + "role must not be set for arbiter nodes; role is implied by nodeType")) + } + if node.NodeType == common.HA_NODE_TYPE_DATABASE && node.Role == common.HA_NODE_ROLE_MONGO_PRIMARY { + primaryCount++ + } + if node.NodeType == common.HA_NODE_TYPE_ARBITER { + arbiterCount++ + } + } + + if len(haConfig.Nodes) > 0 && primaryCount != 1 { + *errors = append(*errors, field.Invalid(haPath.Child("nodes"), haConfig.Nodes, + "exactly one database node must have role '"+common.HA_NODE_ROLE_MONGO_PRIMARY+"'")) + } + + // Enforce consistency between deployArbiter and the nodes list. + if mg.DeployArbiter && arbiterCount != 1 { + *errors = append(*errors, field.Invalid(mgPath.Child("deployArbiter"), mg.DeployArbiter, + "deployArbiter is true but exactly one arbiter node must be present in haConfig.nodes")) + } + if !mg.DeployArbiter && arbiterCount > 0 { + *errors = append(*errors, field.Invalid(haPath.Child("nodes"), haConfig.Nodes, + "arbiter nodes are present but deployArbiter is false")) + } +} + // PostgresHAParamsValidator validates Patroni and HAProxy specific fields for Postgres HA. // +kubebuilder:object:generate=false type PostgresHAParamsValidator struct{} diff --git a/api/v1alpha1/ha_validators_test.go b/api/v1alpha1/ha_validators_test.go index e1d6d5d9..81d9fb48 100644 --- a/api/v1alpha1/ha_validators_test.go +++ b/api/v1alpha1/ha_validators_test.go @@ -64,6 +64,26 @@ func validMySQLConfig() *MySQLHAConfig { return &MySQLHAConfig{InnoDBClusterName: "innodb-cluster"} } +// mongoPrimaryNode returns a minimal valid MongoDB primary database node. +func mongoPrimaryNode(name string) InstanceHANode { + return InstanceHANode{VmName: name, NodeType: common.HA_NODE_TYPE_DATABASE, Role: common.HA_NODE_ROLE_MONGO_PRIMARY, ClusterName: "cluster-a"} +} + +// mongoSecondaryNode returns a minimal valid MongoDB secondary database node. +func mongoSecondaryNode(name string) InstanceHANode { + return InstanceHANode{VmName: name, NodeType: common.HA_NODE_TYPE_DATABASE, Role: common.HA_NODE_ROLE_MONGO_SECONDARY, ClusterName: "cluster-b"} +} + +// mongoArbiterNode returns a minimal valid MongoDB arbiter node (no role). +func mongoArbiterNode(name string) InstanceHANode { + return InstanceHANode{VmName: name, NodeType: common.HA_NODE_TYPE_ARBITER, ClusterName: "cluster-a"} +} + +// validMongoConfig returns a minimal valid MongoHAConfig for use in test fixtures. +func validMongoConfig() *MongoHAConfig { + return &MongoHAConfig{ReplicaSetName: "mongo-rs"} +} + func TestGetHAValidator(t *testing.T) { t.Run("returns validator for postgres", func(t *testing.T) { v, ok := getHAValidator(common.DATABASE_TYPE_POSTGRES) @@ -79,8 +99,15 @@ func TestGetHAValidator(t *testing.T) { assert.IsType(t, &MysqlHAParamsValidator{}, v) }) + t.Run("returns validator for mongodb", func(t *testing.T) { + v, ok := getHAValidator(common.DATABASE_TYPE_MONGODB) + assert.True(t, ok) + assert.NotNil(t, v) + assert.IsType(t, &MongoHAParamsValidator{}, v) + }) + t.Run("returns false for unsupported engine types", func(t *testing.T) { - for _, unsupported := range []string{"mongodb", "mssql", "oracle", ""} { + for _, unsupported := range []string{"mssql", "oracle", ""} { v, ok := getHAValidator(unsupported) assert.False(t, ok, "expected no validator for type %q", unsupported) assert.Nil(t, v) @@ -367,3 +394,145 @@ func TestPostgresHAParamsValidator_Validate(t *testing.T) { assert.Equal(t, "haConfig.nodes", (*errors)[0].Field) }) } + +// --------------------------------------------------------------------------- +// MongoHAParamsValidator +// --------------------------------------------------------------------------- + +func TestMongoHAParamsValidator_Validate(t *testing.T) { + validator := &MongoHAParamsValidator{} + haPath := field.NewPath("haConfig") + + t.Run("valid 3-data-node config (no arbiter) produces no errors", func(t *testing.T) { + haConfig := &InstanceHAConfig{ + MongoDB: validMongoConfig(), + Nodes: []InstanceHANode{mongoPrimaryNode("db1"), mongoSecondaryNode("db2"), mongoSecondaryNode("db3")}, + } + errors := &field.ErrorList{} + validator.Validate(haConfig, haPath, errors) + assert.Empty(t, *errors) + }) + + t.Run("valid config with arbiter (deployArbiter=true + one arbiter node) produces no errors", func(t *testing.T) { + haConfig := &InstanceHAConfig{ + MongoDB: &MongoHAConfig{ReplicaSetName: "rs0", DeployArbiter: true}, + Nodes: []InstanceHANode{mongoPrimaryNode("db1"), mongoSecondaryNode("db2"), mongoArbiterNode("arb1")}, + } + errors := &field.ErrorList{} + validator.Validate(haConfig, haPath, errors) + assert.Empty(t, *errors) + }) + + t.Run("missing mongodb config block returns required error", func(t *testing.T) { + haConfig := &InstanceHAConfig{ + MongoDB: nil, + Nodes: []InstanceHANode{mongoPrimaryNode("db1"), mongoSecondaryNode("db2")}, + } + errors := &field.ErrorList{} + validator.Validate(haConfig, haPath, errors) + assert.Len(t, *errors, 1) + assert.Equal(t, field.ErrorTypeRequired, (*errors)[0].Type) + assert.Equal(t, "haConfig.mongodb", (*errors)[0].Field) + }) + + t.Run("empty replicaSetName returns invalid error", func(t *testing.T) { + haConfig := &InstanceHAConfig{ + MongoDB: &MongoHAConfig{ReplicaSetName: ""}, + Nodes: []InstanceHANode{mongoPrimaryNode("db1"), mongoSecondaryNode("db2"), mongoSecondaryNode("db3")}, + } + errors := &field.ErrorList{} + validator.Validate(haConfig, haPath, errors) + assert.Len(t, *errors, 1) + assert.Equal(t, field.ErrorTypeInvalid, (*errors)[0].Type) + assert.Equal(t, "haConfig.mongodb.replicaSetName", (*errors)[0].Field) + }) + + t.Run("invalid nodeType returns invalid error", func(t *testing.T) { + haConfig := &InstanceHAConfig{ + MongoDB: validMongoConfig(), + Nodes: []InstanceHANode{ + mongoPrimaryNode("db1"), + {VmName: "db2", NodeType: common.HA_NODE_TYPE_HAPROXY, Role: common.HA_NODE_ROLE_MONGO_SECONDARY, ClusterName: "cluster-a"}, + mongoSecondaryNode("db3"), + }, + } + errors := &field.ErrorList{} + validator.Validate(haConfig, haPath, errors) + assert.GreaterOrEqual(t, len(*errors), 1) + assert.Equal(t, "haConfig.nodes[1].nodeType", (*errors)[0].Field) + }) + + t.Run("no primary node returns invalid error", func(t *testing.T) { + haConfig := &InstanceHAConfig{ + MongoDB: validMongoConfig(), + Nodes: []InstanceHANode{mongoSecondaryNode("db1"), mongoSecondaryNode("db2"), mongoSecondaryNode("db3")}, + } + errors := &field.ErrorList{} + validator.Validate(haConfig, haPath, errors) + assert.Len(t, *errors, 1) + assert.Equal(t, "haConfig.nodes", (*errors)[0].Field) + assert.Contains(t, (*errors)[0].Detail, "exactly one") + }) + + t.Run("multiple primary nodes returns invalid error", func(t *testing.T) { + haConfig := &InstanceHAConfig{ + MongoDB: validMongoConfig(), + Nodes: []InstanceHANode{mongoPrimaryNode("db1"), mongoPrimaryNode("db2"), mongoSecondaryNode("db3")}, + } + errors := &field.ErrorList{} + validator.Validate(haConfig, haPath, errors) + assert.Len(t, *errors, 1) + assert.Equal(t, "haConfig.nodes", (*errors)[0].Field) + assert.Contains(t, (*errors)[0].Detail, "exactly one") + }) + + t.Run("arbiter node with role set returns invalid error", func(t *testing.T) { + haConfig := &InstanceHAConfig{ + MongoDB: &MongoHAConfig{ReplicaSetName: "rs0", DeployArbiter: true}, + Nodes: []InstanceHANode{ + mongoPrimaryNode("db1"), + mongoSecondaryNode("db2"), + {VmName: "arb1", NodeType: common.HA_NODE_TYPE_ARBITER, Role: "primary", ClusterName: "cluster-a"}, + }, + } + errors := &field.ErrorList{} + validator.Validate(haConfig, haPath, errors) + assert.Len(t, *errors, 1) + assert.Equal(t, "haConfig.nodes[2].role", (*errors)[0].Field) + assert.Contains(t, (*errors)[0].Detail, "must not be set for arbiter nodes") + }) + + t.Run("deployArbiter=true but no arbiter node returns invalid error", func(t *testing.T) { + haConfig := &InstanceHAConfig{ + MongoDB: &MongoHAConfig{ReplicaSetName: "rs0", DeployArbiter: true}, + Nodes: []InstanceHANode{mongoPrimaryNode("db1"), mongoSecondaryNode("db2"), mongoSecondaryNode("db3")}, + } + errors := &field.ErrorList{} + validator.Validate(haConfig, haPath, errors) + assert.Len(t, *errors, 1) + assert.Equal(t, "haConfig.mongodb.deployArbiter", (*errors)[0].Field) + assert.Contains(t, (*errors)[0].Detail, "exactly one arbiter node") + }) + + t.Run("deployArbiter=false but arbiter node present returns invalid error", func(t *testing.T) { + haConfig := &InstanceHAConfig{ + MongoDB: validMongoConfig(), // DeployArbiter defaults to false + Nodes: []InstanceHANode{mongoPrimaryNode("db1"), mongoSecondaryNode("db2"), mongoArbiterNode("arb1")}, + } + errors := &field.ErrorList{} + validator.Validate(haConfig, haPath, errors) + assert.Len(t, *errors, 1) + assert.Equal(t, "haConfig.nodes", (*errors)[0].Field) + assert.Contains(t, (*errors)[0].Detail, "deployArbiter is false") + }) + + t.Run("empty nodes list skips primary and arbiter count checks", func(t *testing.T) { + haConfig := &InstanceHAConfig{ + MongoDB: validMongoConfig(), + Nodes: []InstanceHANode{}, + } + errors := &field.ErrorList{} + validator.Validate(haConfig, haPath, errors) + assert.Empty(t, *errors) + }) +} diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 12b44416..9815cfa1 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -230,6 +230,11 @@ func (in *InstanceHAConfig) DeepCopyInto(out *InstanceHAConfig) { *out = new(MySQLHAConfig) **out = **in } + if in.MongoDB != nil { + in, out := &in.MongoDB, &out.MongoDB + *out = new(MongoHAConfig) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new InstanceHAConfig. @@ -257,6 +262,21 @@ func (in *InstanceHANode) DeepCopy() *InstanceHANode { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MongoHAConfig) DeepCopyInto(out *MongoHAConfig) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MongoHAConfig. +func (in *MongoHAConfig) DeepCopy() *MongoHAConfig { + if in == nil { + return nil + } + out := new(MongoHAConfig) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *MySQLHAConfig) DeepCopyInto(out *MySQLHAConfig) { *out = *in diff --git a/common/constants.go b/common/constants.go index b79b4da4..b5f67460 100644 --- a/common/constants.go +++ b/common/constants.go @@ -70,6 +70,14 @@ const ( HA_MYSQL_DEFAULT_RO_PORT = int32(6447) HA_MYSQL_DEFAULT_LISTENER_PORT = int32(3306) + HA_MONGO_DEFAULT_LISTENER_PORT = int32(27017) + + HA_NODE_TYPE_ARBITER = "arbiter" + + HA_NODE_ROLE_MONGO_PRIMARY = "primary" + HA_NODE_ROLE_MONGO_SECONDARY = "secondary" + HA_NODE_ROLE_MONGO_ARBITER = "arbiter" + NDB_CR_STATUS_AUTHENTICATION_ERROR = "Authentication Error" NDB_CR_STATUS_CREDENTIAL_ERROR = "Credential Error" NDB_CR_STATUS_ERROR = "Error" diff --git a/common/util/additionalArguments.go b/common/util/additionalArguments.go index d8008682..35ed968c 100644 --- a/common/util/additionalArguments.go +++ b/common/util/additionalArguments.go @@ -127,10 +127,13 @@ func GetAllowedAdditionalArgumentsForDatabase(dbType string) (map[string]bool, e }, nil case common.DATABASE_TYPE_MONGODB: return map[string]bool{ - /* Has a default */ + /* SI and HA */ "listener_port": true, "log_size": true, "journal_size": true, + /* HA only — users can override defaults injected by the appender */ + "cluster_name": true, + "cluster_description": true, }, nil case common.DATABASE_TYPE_POSTGRES: return map[string]bool{ diff --git a/config/crd/bases/ndb.nutanix.com_databases.yaml b/config/crd/bases/ndb.nutanix.com_databases.yaml index 9baba458..e3a714f1 100644 --- a/config/crd/bases/ndb.nutanix.com_databases.yaml +++ b/config/crd/bases/ndb.nutanix.com_databases.yaml @@ -191,6 +191,40 @@ spec: enableSynchronousMode: description: Enable synchronous replication mode type: boolean + mongodb: + description: |- + MongoDB contains Replica Set settings specific to MongoDB HA. + Required when the database type is "mongodb". + properties: + arbiterComputeProfileId: + description: |- + ArbiterComputeProfileId optionally assigns a smaller compute profile to the Arbiter VM. + Falls back to the instance-level compute profile if unset. + Only meaningful when DeployArbiter is true. + type: string + deployArbiter: + description: |- + DeployArbiter controls whether an Arbiter VM is provisioned. + When true, exactly one node with nodeType "arbiter" must be present in nodes[]. + When false (default), no arbiter nodes may be present — all nodes are data-bearing. + type: boolean + listenerPort: + default: 27017 + description: ListenerPort is the MongoDB listener port. + Defaults to 27017. + format: int32 + type: integer + replicaSetDescription: + description: ReplicaSetDescription is an optional human-readable + description shown in the NDB UI. + type: string + replicaSetName: + description: ReplicaSetName is the MongoDB Replica Set + name, mapped to the "cluster_name" NDB action argument. + type: string + required: + - replicaSetName + type: object mysql: description: |- MySQL contains InnoDB Cluster and MySQL Router settings specific to MySQL HA. @@ -254,11 +288,13 @@ spec: type: string nodeType: description: 'Type of this node: "database", "haproxy" - (Postgres HA), or "mysqlrouter" (MySQL HA)' + (Postgres HA), "mysqlrouter" (MySQL HA), or "arbiter" + (MongoDB HA)' enum: - haproxy - database - mysqlrouter + - arbiter type: string role: description: 'Role of this node (database nodes only): diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index e8787388..8211f188 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -36,8 +36,11 @@ rules: resources: - secrets verbs: + - create - get - list + - patch + - update - watch - apiGroups: - ndb.nutanix.com diff --git a/controller_adapters/database.go b/controller_adapters/database.go index 4911a054..282126a8 100644 --- a/controller_adapters/database.go +++ b/controller_adapters/database.go @@ -247,11 +247,18 @@ func (d *Database) IsMysqlHA() bool { d.Spec.Instance.HAConfig != nil } +// IsMongoHA returns true when this is a non-clone MongoDB instance with haConfig specified. +func (d *Database) IsMongoHA() bool { + return !d.IsClone() && + d.Spec.Instance.Type == common.DATABASE_TYPE_MONGODB && + d.Spec.Instance.HAConfig != nil +} + // GetInstanceHAConfig converts the v1alpha1.InstanceHAConfig into the ndb_api.HAConfig // representation used within the ndb_api package. // Returns nil for clones and non-HA instances. func (d *Database) GetInstanceHAConfig() *ndb_api.HAConfig { - if !d.IsPostgresHA() && !d.IsMysqlHA() { + if !d.IsPostgresHA() && !d.IsMysqlHA() && !d.IsMongoHA() { return nil } src := d.Spec.Instance.HAConfig @@ -291,5 +298,13 @@ func (d *Database) GetInstanceHAConfig() *ndb_api.HAConfig { cfg.ReplicationUser = my.ReplicationUser } + if mg := src.MongoDB; mg != nil { + cfg.ReplicaSetName = mg.ReplicaSetName + cfg.ReplicaSetDescription = mg.ReplicaSetDescription + cfg.DeployArbiter = mg.DeployArbiter + cfg.ArbiterComputeProfileId = mg.ArbiterComputeProfileId + cfg.MongoListenerPort = mg.ListenerPort + } + return cfg } diff --git a/controllers/database_controller.go b/controllers/database_controller.go index 280e2271..9a54c756 100644 --- a/controllers/database_controller.go +++ b/controllers/database_controller.go @@ -44,7 +44,7 @@ import ( // +kubebuilder:rbac:groups="core",resources=services,verbs=get;list;watch;create;update;patch;delete // +kubebuilder:rbac:groups="core",resources=endpoints,verbs=get;list;watch;create;update;patch;delete // +kubebuilder:rbac:groups="core",resources=configmaps,verbs=get;list -// +kubebuilder:rbac:groups="core",resources=secrets,verbs=get;list;watch +// +kubebuilder:rbac:groups="core",resources=secrets,verbs=get;list;watch;create;update;patch // +kubebuilder:rbac:groups=ndb.nutanix.com,resources=databases,verbs=get;list;watch;create;update;patch;delete // +kubebuilder:rbac:groups=ndb.nutanix.com,resources=databases/status,verbs=get;update;patch // +kubebuilder:rbac:groups=ndb.nutanix.com,resources=databases/finalizers,verbs=update diff --git a/controllers/database_reconciler_helpers.go b/controllers/database_reconciler_helpers.go index acdd4d62..87602db6 100644 --- a/controllers/database_reconciler_helpers.go +++ b/controllers/database_reconciler_helpers.go @@ -331,6 +331,14 @@ func (r *DatabaseReconciler) setupConnectivity(ctx context.Context, database *nd ns := database.Namespace + // MongoDB HA uses a K8s Secret (not Services) for connectivity — smart MongoDB drivers + // need direct IP access to every node; a K8s Service with NAT breaks SDAM topology detection. + if database.Spec.Instance != nil && + database.Spec.Instance.HAConfig != nil && + database.Spec.Instance.HAConfig.MongoDB != nil { + return r.setupMongoConnSecret(ctx, database) + } + primaryIPs := strings.Split(database.Status.IPAddress, ",") // Determine the port for the primary -svc and whether an HA manager is needed. @@ -538,6 +546,86 @@ func (r *DatabaseReconciler) setupEndpoints(ctx context.Context, database *ndbv1 return } +// setupMongoConnSecret creates or updates the -db-uri Secret that holds +// the MongoDB connection URI for a Replica Set HA instance. +// The URI is built from all data-node IPs (stored in database.Status.IPAddress by MongoHAIPResolver), +// the credentials from the user's pre-existing credentialSecret, and the replica set name from haConfig. +// The Secret is owned by the Database CR so it is garbage-collected on CR deletion. +func (r *DatabaseReconciler) setupMongoConnSecret(ctx context.Context, database *ndbv1alpha1.Database) error { + log := ctrllog.FromContext(ctx) + log.Info("Entered database_reconciler_helpers.setupMongoConnSecret") + + haConfig := database.Spec.Instance.HAConfig + port := haConnectivityManagers[common.DATABASE_TYPE_MONGODB].PrimaryPort(haConfig) + rsName := haConfig.MongoDB.ReplicaSetName + dbName := strings.Join(database.Spec.Instance.DatabaseNames, ",") + + // Read username + password from the user-provided credential Secret (Secret 1). + // This is the same Secret used for provisioning — we never modify it. + secretData, err := util.GetAllDataFromSecret(ctx, r.Client, + database.Spec.Instance.CredentialSecret, database.Namespace) + if err != nil { + log.Error(err, "Failed to read credential secret for MongoDB URI construction", + "credentialSecret", database.Spec.Instance.CredentialSecret) + return err + } + dbUser := string(secretData[common.SECRET_DATA_KEY_USERNAME]) + dbPass := string(secretData[common.SECRET_DATA_KEY_PASSWORD]) + + // database.Status.IPAddress contains all data node IPs (comma-separated), + // set by MongoHAIPResolver in the NDBServer controller flow. + var hostParts []string + for _, ip := range strings.Split(database.Status.IPAddress, ",") { + if ip = strings.TrimSpace(ip); ip != "" { + hostParts = append(hostParts, fmt.Sprintf("%s:%d", ip, port)) + } + } + hosts := strings.Join(hostParts, ",") + uri := fmt.Sprintf("mongodb://%s:%s@%s/%s?replicaSet=%s", dbUser, dbPass, hosts, dbName, rsName) + + secretName := database.Name + "-db-uri" + nn := types.NamespacedName{Name: secretName, Namespace: database.Namespace} + + existing := &corev1.Secret{} + getErr := r.Get(ctx, nn, existing) + if getErr != nil && errors.IsNotFound(getErr) { + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: secretName, + Namespace: database.Namespace, + }, + StringData: map[string]string{ + "uri": uri, + }, + } + if ownerErr := ctrl.SetControllerReference(database, secret, r.Scheme); ownerErr != nil { + log.Error(ownerErr, "Failed to set controller reference on MongoDB URI secret") + return ownerErr + } + if createErr := r.Create(ctx, secret); createErr != nil { + log.Error(createErr, "Failed to create MongoDB URI secret", "secretName", secretName) + r.recorder.Eventf(database, "Warning", EVENT_SERVICE_SETUP_FAILED, + "Failed to create MongoDB URI secret %s: %s", secretName, createErr.Error()) + return createErr + } + log.Info("Created MongoDB URI secret", "secretName", secretName) + } else if getErr == nil { + // Secret exists — update the URI in case IPs have changed + existing.StringData = map[string]string{"uri": uri} + if updateErr := r.Update(ctx, existing); updateErr != nil { + log.Error(updateErr, "Failed to update MongoDB URI secret", "secretName", secretName) + return updateErr + } + log.Info("Updated MongoDB URI secret", "secretName", secretName) + } else { + log.Error(getErr, "Failed to get MongoDB URI secret", "secretName", secretName) + return getErr + } + + log.Info("Returning from database_reconciler_helpers.setupMongoConnSecret") + return nil +} + // Returns the credentials(password and ssh public key) for NDB // Returns an error if reading the secret containing credentials fails func (r *DatabaseReconciler) getDatabaseCredentials(ctx context.Context, name, namespace string) (password, sshPublicKey string, err error) { diff --git a/controllers/ha_connectivity.go b/controllers/ha_connectivity.go index 93c33758..af797b2e 100644 --- a/controllers/ha_connectivity.go +++ b/controllers/ha_connectivity.go @@ -51,6 +51,7 @@ type HAConnectivityManager interface { var haConnectivityManagers = map[string]HAConnectivityManager{ common.DATABASE_TYPE_POSTGRES: &PostgresHAConnectivityManager{}, common.DATABASE_TYPE_MYSQL: &MySQLHAConnectivityManager{}, + common.DATABASE_TYPE_MONGODB: &MongoHAConnectivityManager{}, } // HAIPResolver resolves the connection IP(s) for an HA database as reported by the NDB API. @@ -69,6 +70,7 @@ type HAIPResolver interface { var haIPResolvers = map[string]HAIPResolver{ common.DATABASE_TYPE_POSTGRES: &PostgresHAIPResolver{}, common.DATABASE_TYPE_MYSQL: &MySQLHAIPResolver{}, + common.DATABASE_TYPE_MONGODB: &MongoHAIPResolver{}, } // PostgresHAIPResolver resolves HAProxy IPs for a Postgres HA database. @@ -200,3 +202,45 @@ func (r *MySQLHAIPResolver) collectMasterIP(nodes []ndb_api.DatabaseNode) []stri } return nil } + +// MongoHAIPResolver resolves connection IPs for a MongoDB HA (Replica Set) database. +// It returns the IPs of all data-bearing nodes (Primary + Secondaries), excluding Arbiters. +// These IPs are joined and stored in database.Status.IPAddress, then used by +// setupMongoConnSecret to build the full MongoDB connection URI. +type MongoHAIPResolver struct{} + +func (r *MongoHAIPResolver) ResolveIPs(_ context.Context, _ ndb_client.NDBClientHTTPInterface, db ndb_api.DatabaseResponse) ([]string, error) { + var ips []string + for _, node := range db.DatabaseNodes { + isArbiter := false + for _, prop := range node.Properties { + if prop.Name == "role" && prop.Value == common.HA_NODE_ROLE_MONGO_ARBITER { + isArbiter = true + break + } + } + if !isArbiter && len(node.DbServer.IPAddresses) > 0 { + ips = append(ips, node.DbServer.IPAddresses[0]) + } + } + return ips, nil +} + +// MongoHAConnectivityManager manages connectivity for MongoDB HA (Replica Set) databases. +// Unlike Postgres/MySQL, MongoDB HA does NOT use K8s Services — the operator instead creates +// a -db-uri Secret with the full MongoDB connection URI so that smart MongoDB +// drivers can connect directly to each node without K8s Service NAT interfering with SDAM. +type MongoHAConnectivityManager struct{} + +func (m *MongoHAConnectivityManager) PrimaryPort(haConfig *ndbv1alpha1.InstanceHAConfig) int32 { + if mg := haConfig.MongoDB; mg != nil && mg.ListenerPort != 0 { + return mg.ListenerPort + } + return common.HA_MONGO_DEFAULT_LISTENER_PORT +} + +// AdditionalServices returns nil — MongoDB HA creates no K8s Services. +// Connectivity is handled via the -db-uri Secret instead. +func (m *MongoHAConnectivityManager) AdditionalServices(_ *ndbv1alpha1.InstanceHAConfig) []HAServiceSpec { + return nil +} diff --git a/controllers/ha_connectivity_test.go b/controllers/ha_connectivity_test.go index 237c7164..59929f80 100644 --- a/controllers/ha_connectivity_test.go +++ b/controllers/ha_connectivity_test.go @@ -604,3 +604,132 @@ func TestMySQLHAIPResolver_ResolveIPs(t *testing.T) { ndbClient.AssertExpectations(t) }) } + +// --------------------------------------------------------------------------- +// Helpers for MongoDB tests +// --------------------------------------------------------------------------- + +func mongoPrimaryDBNode(ip string) ndb_api.DatabaseNode { + return ndb_api.DatabaseNode{ + Properties: []ndb_api.Property{{Name: "role", Value: common.HA_NODE_ROLE_MONGO_PRIMARY}}, + DbServer: ndb_api.DatabaseServer{IPAddresses: []string{ip}}, + } +} + +func mongoSecondaryDBNode(ip string) ndb_api.DatabaseNode { + return ndb_api.DatabaseNode{ + Properties: []ndb_api.Property{{Name: "role", Value: common.HA_NODE_ROLE_MONGO_SECONDARY}}, + DbServer: ndb_api.DatabaseServer{IPAddresses: []string{ip}}, + } +} + +func mongoArbiterDBNode(ip string) ndb_api.DatabaseNode { + return ndb_api.DatabaseNode{ + Properties: []ndb_api.Property{{Name: "role", Value: common.HA_NODE_ROLE_MONGO_ARBITER}}, + DbServer: ndb_api.DatabaseServer{IPAddresses: []string{ip}}, + } +} + +// --------------------------------------------------------------------------- +// MongoHAConnectivityManager — registry & PrimaryPort +// --------------------------------------------------------------------------- + +func TestMongoHAConnectivityManager_Registry(t *testing.T) { + mgr, ok := haConnectivityManagers[common.DATABASE_TYPE_MONGODB] + assert.True(t, ok) + assert.IsType(t, &MongoHAConnectivityManager{}, mgr) +} + +func TestMongoHAConnectivityManager_PrimaryPort(t *testing.T) { + mgr := &MongoHAConnectivityManager{} + + t.Run("returns default 27017 when MongoDB config is nil", func(t *testing.T) { + haConfig := &ndbv1alpha1.InstanceHAConfig{MongoDB: nil} + assert.Equal(t, common.HA_MONGO_DEFAULT_LISTENER_PORT, mgr.PrimaryPort(haConfig)) + }) + + t.Run("returns default 27017 when ListenerPort is zero", func(t *testing.T) { + haConfig := &ndbv1alpha1.InstanceHAConfig{ + MongoDB: &ndbv1alpha1.MongoHAConfig{ReplicaSetName: "rs0", ListenerPort: 0}, + } + assert.Equal(t, common.HA_MONGO_DEFAULT_LISTENER_PORT, mgr.PrimaryPort(haConfig)) + }) + + t.Run("returns configured port when ListenerPort is set", func(t *testing.T) { + haConfig := &ndbv1alpha1.InstanceHAConfig{ + MongoDB: &ndbv1alpha1.MongoHAConfig{ReplicaSetName: "rs0", ListenerPort: 27018}, + } + assert.Equal(t, int32(27018), mgr.PrimaryPort(haConfig)) + }) +} + +func TestMongoHAConnectivityManager_AdditionalServices(t *testing.T) { + mgr := &MongoHAConnectivityManager{} + haConfig := &ndbv1alpha1.InstanceHAConfig{ + MongoDB: &ndbv1alpha1.MongoHAConfig{ReplicaSetName: "rs0"}, + } + t.Run("returns nil (no additional services for MongoDB HA)", func(t *testing.T) { + svcs := mgr.AdditionalServices(haConfig) + assert.Nil(t, svcs) + }) +} + +// --------------------------------------------------------------------------- +// MongoHAIPResolver — ResolveIPs +// --------------------------------------------------------------------------- + +func TestMongoHAIPResolver_Registry(t *testing.T) { + res, ok := haIPResolvers[common.DATABASE_TYPE_MONGODB] + assert.True(t, ok) + assert.IsType(t, &MongoHAIPResolver{}, res) +} + +func TestMongoHAIPResolver_ResolveIPs(t *testing.T) { + r := &MongoHAIPResolver{} + ctx := context.Background() + + t.Run("returns all data node IPs (primary + secondary), excludes arbiter", func(t *testing.T) { + db := ndb_api.DatabaseResponse{ + DatabaseNodes: []ndb_api.DatabaseNode{ + mongoPrimaryDBNode("10.0.0.1"), + mongoSecondaryDBNode("10.0.0.2"), + mongoArbiterDBNode("10.0.0.3"), + }, + } + ips, err := r.ResolveIPs(ctx, nil, db) + assert.NoError(t, err) + assert.ElementsMatch(t, []string{"10.0.0.1", "10.0.0.2"}, ips) + }) + + t.Run("returns all data node IPs when no arbiter is present", func(t *testing.T) { + db := ndb_api.DatabaseResponse{ + DatabaseNodes: []ndb_api.DatabaseNode{ + mongoPrimaryDBNode("10.0.0.1"), + mongoSecondaryDBNode("10.0.0.2"), + mongoSecondaryDBNode("10.0.0.3"), + }, + } + ips, err := r.ResolveIPs(ctx, nil, db) + assert.NoError(t, err) + assert.ElementsMatch(t, []string{"10.0.0.1", "10.0.0.2", "10.0.0.3"}, ips) + }) + + t.Run("returns empty slice when all nodes are arbiters", func(t *testing.T) { + db := ndb_api.DatabaseResponse{ + DatabaseNodes: []ndb_api.DatabaseNode{ + mongoArbiterDBNode("10.0.0.1"), + mongoArbiterDBNode("10.0.0.2"), + }, + } + ips, err := r.ResolveIPs(ctx, nil, db) + assert.NoError(t, err) + assert.Empty(t, ips) + }) + + t.Run("returns empty slice when DatabaseNodes is empty", func(t *testing.T) { + db := ndb_api.DatabaseResponse{DatabaseNodes: []ndb_api.DatabaseNode{}} + ips, err := r.ResolveIPs(ctx, nil, db) + assert.NoError(t, err) + assert.Empty(t, ips) + }) +} diff --git a/deploy/helm/templates/clusterrole-ndb-operator-manager-role.yaml b/deploy/helm/templates/clusterrole-ndb-operator-manager-role.yaml index 8a0fd295..40965b47 100644 --- a/deploy/helm/templates/clusterrole-ndb-operator-manager-role.yaml +++ b/deploy/helm/templates/clusterrole-ndb-operator-manager-role.yaml @@ -36,8 +36,11 @@ rules: resources: - secrets verbs: + - create - get - list + - patch + - update - watch - apiGroups: - ndb.nutanix.com diff --git a/ndb_api/db_helpers.go b/ndb_api/db_helpers.go index 91c782d7..7ad8eb12 100644 --- a/ndb_api/db_helpers.go +++ b/ndb_api/db_helpers.go @@ -165,6 +165,67 @@ func GenerateProvisioningRequest(ctx context.Context, ndb_client *ndb_client.NDB } } + // Override request fields for HA MongoDB (Replica Set) instances + if database.IsMongoHA() { + haConfig := database.GetInstanceHAConfig() + requestBody.Clustered = true + requestBody.NodeCount = len(haConfig.Nodes) + + profilesMap := reqData[common.PROFILE_MAP_PARAM].(map[string]ProfileResponse) + haNodes := make([]Node, 0, len(haConfig.Nodes)) + for _, n := range haConfig.Nodes { + node := Node{ + VmName: n.VmName, + NxClusterId: n.ClusterId, + NetworkProfileId: profilesMap[common.PROFILE_TYPE_NETWORK].Id, + } + if n.NodeType == common.HA_NODE_TYPE_ARBITER { + // Arbiter: use arbiter-specific compute profile if provided, else fall back to default. + // priority must be 0 so the arbiter never attempts to become Primary. + if haConfig.ArbiterComputeProfileId != "" { + node.ComputeProfileId = haConfig.ArbiterComputeProfileId + } else { + node.ComputeProfileId = profilesMap[common.PROFILE_TYPE_COMPUTE].Id + } + node.Properties = []NodeProperty{ + {Name: "role", Value: common.HA_NODE_ROLE_MONGO_ARBITER}, + {Name: "votes", Value: "1"}, + {Name: "priority", Value: "0"}, + } + } else { + // Database node — role ("primary"/"secondary") comes from the user's YAML verbatim. + // No positional assumption is made; NDB manages actual election after provisioning. + node.ComputeProfileId = profilesMap[common.PROFILE_TYPE_COMPUTE].Id + node.Properties = []NodeProperty{ + {Name: "role", Value: n.Role}, + {Name: "votes", Value: "1"}, + {Name: "priority", Value: "1"}, + } + } + haNodes = append(haNodes, node) + } + requestBody.Nodes = haNodes + + // Collect unique cluster IDs across all HA nodes for the TM SLA details + clusterIdSet := make(map[string]struct{}) + for _, n := range haConfig.Nodes { + if n.ClusterId != "" { + clusterIdSet[n.ClusterId] = struct{}{} + } + } + clusterIds := make([]string, 0, len(clusterIdSet)) + for id := range clusterIdSet { + clusterIds = append(clusterIds, id) + } + requestBody.TimeMachineInfo.SlaId = "" + requestBody.TimeMachineInfo.SlaDetails = &SlaDetails{ + PrimarySla: PrimarySlaDetails{ + SlaId: sla.Id, + NxClusterIds: clusterIds, + }, + } + } + // Appending request body based on database type appender, err := GetRequestAppender(database.GetInstanceType()) if err != nil { @@ -308,7 +369,7 @@ func (a *MongoDbRequestAppender) appendProvisioningRequest(req *DatabaseProvisio SSHPublicKey := reqData[common.NDB_PARAM_SSH_PUBLIC_KEY].(string) req.SSHPublicKey = SSHPublicKey - // Default action arguments + // Default action arguments (shared by SI and HA) actionArguments := map[string]string{ "listener_port": "27017", "log_size": "100", @@ -321,7 +382,26 @@ func (a *MongoDbRequestAppender) appendProvisioningRequest(req *DatabaseProvisio "database_names": databaseNames, } - // Appending/overwriting database actionArguments to actionArguments + // HA-specific action arguments — merged before setConfiguredActionArguments so that + // user-provided additionalArguments take final precedence over the HA defaults below. + if database.IsMongoHA() { + haConfig := database.GetInstanceHAConfig() + haActionArguments := map[string]string{ + "cluster_name": haConfig.ReplicaSetName, + "cluster_description": haConfig.ReplicaSetDescription, + // listener_port: use the configured port (may differ from 27017 if overridden in CRD) + "listener_port": strconv.Itoa(int(haConfig.MongoListenerPort)), + "backup_policy": "primary_only", + "restart_mongod": "true", + "working_dir": "/tmp", + } + for k, v := range haActionArguments { + actionArguments[k] = v + } + } + + // Apply user-provided additionalArguments last so they override both the base + // defaults and the HA defaults merged above. if err := setConfiguredActionArguments(database, actionArguments); err != nil { return nil, err } diff --git a/ndb_api/db_helpers_test.go b/ndb_api/db_helpers_test.go index c1d53605..865ea49a 100644 --- a/ndb_api/db_helpers_test.go +++ b/ndb_api/db_helpers_test.go @@ -745,6 +745,7 @@ func TestMongoDbProvisionRequestAppender_withoutAdditionalArguments_positiveWork mockDatabase.On("GetInstanceType").Return(common.DATABASE_TYPE_MONGODB) mockDatabase.On("GetAdditionalArguments").Return(map[string]string{}) mockDatabase.On("IsClone").Return(false) + mockDatabase.On("IsMongoHA").Return(false) expectedActionArgs := []ActionArgument{ { Name: "listener_port", @@ -832,6 +833,7 @@ func TestMongoDbProvisionRequestAppender_withAdditionalArguments_positiveWorkflo "journal_size": "1", }) mockDatabase.On("IsClone").Return(false) + mockDatabase.On("IsMongoHA").Return(false) expectedActionArgs := []ActionArgument{ { Name: "listener_port", @@ -917,6 +919,7 @@ func TestMongoDbProvisionRequestAppender_withAdditionalArguments_negativeWorkflo "invalid-key": "invalid-value", }) mockDatabase.On("IsClone").Return(false) + mockDatabase.On("IsMongoHA").Return(false) // Get specific implementation of RequestAppender requestAppender, _ := GetRequestAppender(common.DATABASE_TYPE_MONGODB) @@ -1412,6 +1415,7 @@ func TestGenerateProvisioningRequest_AgainstDifferentReqData(t *testing.T) { mockDatabase.On("IsClone").Return(false) mockDatabase.On("IsPostgresHA").Return(false) mockDatabase.On("IsMysqlHA").Return(false) + mockDatabase.On("IsMongoHA").Return(false) mockDatabase.On("GetInstanceHAConfig").Return((*HAConfig)(nil)) // Test diff --git a/ndb_api/interface_mock_test.go b/ndb_api/interface_mock_test.go index 801c4b79..1da2e39e 100644 --- a/ndb_api/interface_mock_test.go +++ b/ndb_api/interface_mock_test.go @@ -142,6 +142,12 @@ func (m *MockDatabaseInterface) IsMysqlHA() bool { return args.Bool(0) } +// IsMongoHA is a mock implementation of the IsMongoHA method in the Database interface +func (m *MockDatabaseInterface) IsMongoHA() bool { + args := m.Called() + return args.Bool(0) +} + // GetInstanceHAConfig is a mock implementation of the GetInstanceHAConfig method in the Database interface func (m *MockDatabaseInterface) GetInstanceHAConfig() *HAConfig { args := m.Called() diff --git a/ndb_api/interfaces.go b/ndb_api/interfaces.go index a16feaaa..0ed9dd48 100644 --- a/ndb_api/interfaces.go +++ b/ndb_api/interfaces.go @@ -65,6 +65,13 @@ type HAConfig struct { RouterROPort int32 MySQLClusterUsername string ReplicationUser string + + // MongoDB-specific fields (Replica Set) + ReplicaSetName string + ReplicaSetDescription string + DeployArbiter bool + ArbiterComputeProfileId string + MongoListenerPort int32 } type DatabaseInterface interface { @@ -87,6 +94,8 @@ type DatabaseInterface interface { IsPostgresHA() bool // IsMysqlHA returns true when this is a non-clone MySQL instance with haConfig set. IsMysqlHA() bool + // IsMongoHA returns true when this is a non-clone MongoDB instance with haConfig set. + IsMongoHA() bool // GetInstanceHAConfig returns the HA configuration for HA instances. // Returns nil for clones and non-HA instances. GetInstanceHAConfig() *HAConfig