Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 30 additions & 2 deletions api/v1alpha1/database_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
64 changes: 64 additions & 0 deletions api/v1alpha1/ha_validators.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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{}
Expand Down
171 changes: 170 additions & 1 deletion api/v1alpha1/ha_validators_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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)
})
}
20 changes: 20 additions & 0 deletions api/v1alpha1/zz_generated.deepcopy.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 8 additions & 0 deletions common/constants.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
5 changes: 4 additions & 1 deletion common/util/additionalArguments.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down
Loading
Loading