From e52214ccfde8c0c8b2692f26f9c2e9f135b06249 Mon Sep 17 00:00:00 2001 From: Wenting Wu Date: Tue, 15 Sep 2026 13:25:09 -0400 Subject: [PATCH 1/9] refactor(operator): route render inputs through ClusterIntent seam (Phase 0a) Extend the product-neutral ClusterIntent so GetCnpgClusterSpecFromIntent renders the Tier A/B builder inputs from the intent instead of reading *DocumentDB directly. No runtime behavior change: the rendered CNPG Cluster is byte-identical. - product: add Resource, TLS{GatewaySecretName, PostgresCertificates}, Monitoring{Enabled}, LogLevel, MaxStopDelay, Postgres.Parameters, and FeatureGates.ChangeStreams to ClusterIntent; populate them in DocumentDBAdapter.ToClusterIntent (defaults preserved). Add exported ResourceFromSpec. - cnpg: neutralize MergeParameters and ComputeResourceSplit via MergeParametersResolved / ComputeResourceSplitFromResource, keeping the *DocumentDB functions as thin wrappers (avoids the product<->cnpg import cycle and keeps existing tests green). Renderer now reads params, resource split, log level, stop delay, Postgres certs, and gateway TLS secret from the intent; remove dead helpers. - Monitoring/OTel inputs are intentionally left on *DocumentDB for a follow-up (Phase 0b). - Add cnpg_intent_drift_test.go: a drift guard asserting the intent render path matches the retained *DocumentDB computations across a spec matrix. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 03549680-3e40-4bea-8144-f0d0cfc6cb46 Signed-off-by: Wenting Wu --- operator/src/internal/cnpg/cnpg_cluster.go | 44 +---- .../src/internal/cnpg/cnpg_cluster_test.go | 5 +- .../internal/cnpg/cnpg_intent_drift_test.go | 181 ++++++++++++++++++ operator/src/internal/cnpg/pg_defaults.go | 44 +++-- .../src/internal/cnpg/pg_defaults_test.go | 4 +- operator/src/internal/cnpg/resource_split.go | 29 ++- operator/src/internal/product/documentdb.go | 52 +++++ operator/src/internal/product/intent.go | 68 ++++++- 8 files changed, 364 insertions(+), 63 deletions(-) create mode 100644 operator/src/internal/cnpg/cnpg_intent_drift_test.go diff --git a/operator/src/internal/cnpg/cnpg_cluster.go b/operator/src/internal/cnpg/cnpg_cluster.go index 8b9bc8e14..4d9c7df7d 100644 --- a/operator/src/internal/cnpg/cnpg_cluster.go +++ b/operator/src/internal/cnpg/cnpg_cluster.go @@ -39,12 +39,12 @@ func GetCnpgClusterSpec(req ctrl.Request, documentdb *dbpreview.DocumentDB, docu // (images, credential secret, plugin name) come from the intent rather than from // product-specific lookups. func GetCnpgClusterSpecFromIntent(req ctrl.Request, documentdb *dbpreview.DocumentDB, intent product.ClusterIntent, serviceAccountName, storageClass string, isPrimaryRegion bool, log logr.Logger) *cnpgv1.Cluster { - split := ComputeResourceSplit(documentdb, DefaultSplitConfig()) + split := ComputeResourceSplitFromResource(intent.Resource, intent.Monitoring.Enabled, DefaultSplitConfig()) sidecarPluginName := intent.SidecarInjectorPlugin gatewayImage := intent.Images.Gateway - log.Info("Creating CNPG cluster with gateway image", "gatewayImage", gatewayImage, "documentdbName", documentdb.Name, "specGatewayImage", imageGateway(documentdb)) + log.Info("Creating CNPG cluster with gateway image", "gatewayImage", gatewayImage, "documentdbName", intent.Identity.Name) credentialSecretName := intent.CredentialSecret @@ -102,8 +102,8 @@ func GetCnpgClusterSpecFromIntent(req ctrl.Request, documentdb *dbpreview.Docume addPluginParamIfSet(params, util.PLUGIN_PARAM_GATEWAY_CPU_REQUEST, split.Gateway.CPURequest) addPluginParamIfSet(params, util.PLUGIN_PARAM_GATEWAY_CPU_LIMIT, split.Gateway.CPULimit) // If TLS is ready, surface secret name to plugin so it can mount certs. - if documentdb.Status.TLS != nil && documentdb.Status.TLS.Ready && documentdb.Status.TLS.SecretName != "" { - params["gatewayTLSSecret"] = documentdb.Status.TLS.SecretName + if intent.TLS.GatewaySecretName != "" { + params["gatewayTLSSecret"] = intent.TLS.GatewaySecretName } // Pass monitoring parameters to plugin for OTel sidecar injection. // Sidecar is only injected when monitoring is enabled. @@ -133,10 +133,10 @@ func GetCnpgClusterSpecFromIntent(req ctrl.Request, documentdb *dbpreview.Docume Parameters: params, }} }(), - PostgresConfiguration: buildPostgresConfiguration(documentdb, extensionImageSource, split.PostgresMemoryBytes), + PostgresConfiguration: buildPostgresConfiguration(MergeParametersResolved(intent.Postgres.Parameters, intent.FeatureGates, split.PostgresMemoryBytes), extensionImageSource), Bootstrap: bootstrapConfigurationFromIntent(intent, isPrimaryRegion, log), - LogLevel: cmp.Or(documentdb.Spec.LogLevel, "info"), - Certificates: postgresCertificates(documentdb), + LogLevel: cmp.Or(intent.LogLevel, "info"), + Certificates: intent.TLS.PostgresCertificates, Backup: &cnpgv1.BackupConfiguration{ VolumeSnapshot: &cnpgv1.VolumeSnapshotConfiguration{ SnapshotOwnerReference: "backup", // Set owner reference to 'backup' so that snapshots are deleted when Backup resource is deleted @@ -146,7 +146,7 @@ func GetCnpgClusterSpecFromIntent(req ctrl.Request, documentdb *dbpreview.Docume Affinity: intent.Topology.Affinity, Resources: buildResourceRequirements(split.Postgres), } - spec.MaxStopDelay = getMaxStopDelayOrDefault(documentdb) + spec.MaxStopDelay = intent.MaxStopDelay applyPostgresProcessIdentity(&spec, intent) applyIOUringSeccomp(&spec, intent) applyOtelMonitorRole(&spec, documentdb) @@ -237,14 +237,6 @@ func getDefaultBootstrapConfiguration(documentdb *dbpreview.DocumentDB) *cnpgv1. return defaultBootstrapConfigurationFromIntent(product.DocumentDBAdapter{}.ToClusterIntent(documentdb)) } -// getMaxStopDelayOrDefault returns StopDelay if set, otherwise util.CNPG_DEFAULT_STOP_DELAY -func getMaxStopDelayOrDefault(documentdb *dbpreview.DocumentDB) int32 { - if documentdb.Spec.Timeouts.StopDelay != 0 { - return documentdb.Spec.Timeouts.StopDelay - } - return util.CNPG_DEFAULT_STOP_DELAY -} - // parseMemoryToBytes converts a Kubernetes quantity string (e.g., "2Gi", "4096Mi") // to bytes. Returns 0 if the string is empty or "0" (meaning unlimited/unset). func parseMemoryToBytes(memoryStr string) int64 { @@ -311,22 +303,6 @@ func parsePullPolicy(value string) corev1.PullPolicy { } } -// imageGateway returns spec.image.gateway or empty string when unset. -// Nil-safe. -func imageGateway(documentdb *dbpreview.DocumentDB) string { - if documentdb == nil || documentdb.Spec.Image == nil { - return "" - } - return documentdb.Spec.Image.Gateway -} - -func postgresCertificates(documentdb *dbpreview.DocumentDB) *cnpgv1.CertificatesConfiguration { - if documentdb.Spec.TLS == nil { - return nil - } - return documentdb.Spec.TLS.Postgres -} - // toCNPGImagePullSecrets translates a list of corev1.LocalObjectReference // (the Kubernetes-native shape used on spec.imagePullSecrets) into the // CNPG-flavoured cnpgv1.LocalObjectReference shape that @@ -429,7 +405,7 @@ func absentOtelMonitorRole() cnpgv1.RoleConfiguration { // stanza (mounted from spec.image.documentDB as an ImageVolumeSource), // sets a fixed AdditionalLibraries list, and applies a small set of // operator-managed GUCs. -func buildPostgresConfiguration(documentdb *dbpreview.DocumentDB, extensionImageSource corev1.ImageVolumeSource, pgMemoryBytes int64) cnpgv1.PostgresConfiguration { +func buildPostgresConfiguration(parameters map[string]string, extensionImageSource corev1.ImageVolumeSource) cnpgv1.PostgresConfiguration { pgHBA := []string{ "host all all localhost trust", "hostssl replication streaming_replica all cert", @@ -446,7 +422,7 @@ func buildPostgresConfiguration(documentdb *dbpreview.DocumentDB, extensionImage }, }, AdditionalLibraries: []string{"pg_cron", "pg_documentdb_core", "pg_documentdb"}, - Parameters: MergeParameters(documentdb, pgMemoryBytes), + Parameters: parameters, PgHBA: pgHBA, } } diff --git a/operator/src/internal/cnpg/cnpg_cluster_test.go b/operator/src/internal/cnpg/cnpg_cluster_test.go index 9a2c80f56..77dc2bb0a 100644 --- a/operator/src/internal/cnpg/cnpg_cluster_test.go +++ b/operator/src/internal/cnpg/cnpg_cluster_test.go @@ -17,6 +17,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/log/zap" dbpreview "github.com/documentdb/documentdb-operator/api/preview" + "github.com/documentdb/documentdb-operator/internal/product" util "github.com/documentdb/documentdb-operator/internal/utils" ) @@ -1077,7 +1078,7 @@ func TestGetInheritedMetadataLabels(t *testing.T) { } } -func TestGetMaxStopDelayOrDefault(t *testing.T) { +func TestMaxStopDelayFromIntent(t *testing.T) { tests := []struct { name string documentdb *dbpreview.DocumentDB @@ -1127,7 +1128,7 @@ func TestGetMaxStopDelayOrDefault(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result := getMaxStopDelayOrDefault(tt.documentdb) + result := product.DocumentDBAdapter{}.ToClusterIntent(tt.documentdb).MaxStopDelay if result != tt.expected { t.Errorf("Expected %d, got %d", tt.expected, result) diff --git a/operator/src/internal/cnpg/cnpg_intent_drift_test.go b/operator/src/internal/cnpg/cnpg_intent_drift_test.go new file mode 100644 index 000000000..f0fbee9a8 --- /dev/null +++ b/operator/src/internal/cnpg/cnpg_intent_drift_test.go @@ -0,0 +1,181 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package cnpg + +import ( + "cmp" + "reflect" + "testing" + + cnpgv1 "github.com/cloudnative-pg/cloudnative-pg/api/v1" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/log/zap" + + dbpreview "github.com/documentdb/documentdb-operator/api/preview" + util "github.com/documentdb/documentdb-operator/internal/utils" +) + +var cnpgCertsForTest = cnpgv1.CertificatesConfiguration{ServerCASecret: "pg-ca", ServerTLSSecret: "pg-tls"} + +// TestRenderIntentSeamNoDrift is the Phase 0 drift guard: it proves that routing +// the builder inputs through ClusterIntent produces exactly the same rendered +// Cluster as the retained *DocumentDB computations, across a spec matrix. +func TestRenderIntentSeamNoDrift(t *testing.T) { + log := zap.New() + req := ctrl.Request{} + req.Name = "drift" + req.Namespace = "default" + + ptr := func(v int64) *int64 { return &v } + + cases := map[string]*dbpreview.DocumentDB{ + "minimal": { + Spec: dbpreview.DocumentDBSpec{ + InstancesPerNode: 1, + Resource: dbpreview.Resource{Storage: dbpreview.StorageConfiguration{PvcSize: "10Gi"}}, + }, + }, + "custom-loglevel-and-stopdelay": { + Spec: dbpreview.DocumentDBSpec{ + InstancesPerNode: 1, + LogLevel: "debug", + Timeouts: dbpreview.Timeouts{StopDelay: 120}, + Resource: dbpreview.Resource{Storage: dbpreview.StorageConfiguration{PvcSize: "10Gi"}}, + }, + }, + "user-params": { + Spec: dbpreview.DocumentDBSpec{ + InstancesPerNode: 1, + Resource: dbpreview.Resource{Storage: dbpreview.StorageConfiguration{PvcSize: "10Gi"}}, + Postgres: &dbpreview.PostgresSpec{ + Parameters: map[string]string{"work_mem": "64MB", "max_connections": "200"}, + }, + }, + }, + "resource-envelope": { + Spec: dbpreview.DocumentDBSpec{ + InstancesPerNode: 1, + Resource: dbpreview.Resource{ + Storage: dbpreview.StorageConfiguration{PvcSize: "10Gi"}, + Memory: "8Gi", + CPU: "4", + }, + }, + }, + "resource-overrides": { + Spec: dbpreview.DocumentDBSpec{ + InstancesPerNode: 1, + Resource: dbpreview.Resource{ + Storage: dbpreview.StorageConfiguration{PvcSize: "10Gi"}, + Gateway: &dbpreview.ComponentResources{Memory: "512Mi", CPU: "500m"}, + Database: &dbpreview.ComponentResources{Memory: "4Gi", CPU: "2"}, + }, + }, + }, + "process-identity": { + Spec: dbpreview.DocumentDBSpec{ + InstancesPerNode: 1, + Resource: dbpreview.Resource{Storage: dbpreview.StorageConfiguration{PvcSize: "10Gi"}}, + Postgres: &dbpreview.PostgresSpec{UID: ptr(26), GID: ptr(26)}, + }, + }, + "iouring-and-changestreams": { + Spec: dbpreview.DocumentDBSpec{ + InstancesPerNode: 1, + Resource: dbpreview.Resource{Storage: dbpreview.StorageConfiguration{PvcSize: "10Gi"}}, + FeatureGates: map[string]bool{ + string(dbpreview.FeatureGateIOUring): true, + string(dbpreview.FeatureGateChangeStreams): true, + }, + }, + }, + "postgres-tls": { + Spec: dbpreview.DocumentDBSpec{ + InstancesPerNode: 1, + Resource: dbpreview.Resource{Storage: dbpreview.StorageConfiguration{PvcSize: "10Gi"}}, + TLS: &dbpreview.TLSConfiguration{ + Postgres: &cnpgCertsForTest, + }, + }, + }, + "gateway-tls-ready": { + Spec: dbpreview.DocumentDBSpec{ + InstancesPerNode: 1, + Resource: dbpreview.Resource{Storage: dbpreview.StorageConfiguration{PvcSize: "10Gi"}}, + }, + Status: dbpreview.DocumentDBStatus{ + TLS: &dbpreview.TLSStatus{Ready: true, SecretName: "gw-tls-secret"}, + }, + }, + } + + for name, db := range cases { + t.Run(name, func(t *testing.T) { + spec := GetCnpgClusterSpec(req, db, "", "test-sa", "", true, log).Spec + + // Parameters: intent path must equal the direct MergeParameters over the + // same memory-aware split. + wantMem := ComputeResourceSplit(db, DefaultSplitConfig()).PostgresMemoryBytes + wantParams := MergeParameters(db, wantMem) + if !reflect.DeepEqual(spec.PostgresConfiguration.Parameters, wantParams) { + t.Errorf("parameters drift:\n got %v\n want %v", spec.PostgresConfiguration.Parameters, wantParams) + } + + // LogLevel + wantLog := cmp.Or(db.Spec.LogLevel, "info") + if spec.LogLevel != wantLog { + t.Errorf("logLevel drift: got %q want %q", spec.LogLevel, wantLog) + } + + // MaxStopDelay + wantStop := int32(util.CNPG_DEFAULT_STOP_DELAY) + if db.Spec.Timeouts.StopDelay != 0 { + wantStop = db.Spec.Timeouts.StopDelay + } + if spec.MaxStopDelay != wantStop { + t.Errorf("maxStopDelay drift: got %d want %d", spec.MaxStopDelay, wantStop) + } + + // Postgres certificates + var wantCerts interface{} + if db.Spec.TLS != nil { + wantCerts = db.Spec.TLS.Postgres + } + if !reflect.DeepEqual(spec.Certificates, wantCerts) && !(spec.Certificates == nil && wantCerts == nil) { + t.Errorf("certificates drift: got %v want %v", spec.Certificates, wantCerts) + } + + // Gateway TLS secret plugin param + gotTLS := spec.Plugins[0].Parameters["gatewayTLSSecret"] + wantTLS := "" + if db.Status.TLS != nil && db.Status.TLS.Ready && db.Status.TLS.SecretName != "" { + wantTLS = db.Status.TLS.SecretName + } + if gotTLS != wantTLS { + t.Errorf("gatewayTLSSecret drift: got %q want %q", gotTLS, wantTLS) + } + + // Gateway resource params reflect the resource split. + split := ComputeResourceSplit(db, DefaultSplitConfig()) + assertParamEq(t, spec.Plugins[0].Parameters, util.PLUGIN_PARAM_GATEWAY_MEMORY_REQUEST, split.Gateway.MemoryRequest) + assertParamEq(t, spec.Plugins[0].Parameters, util.PLUGIN_PARAM_GATEWAY_MEMORY_LIMIT, split.Gateway.MemoryLimit) + assertParamEq(t, spec.Plugins[0].Parameters, util.PLUGIN_PARAM_GATEWAY_CPU_REQUEST, split.Gateway.CPURequest) + assertParamEq(t, spec.Plugins[0].Parameters, util.PLUGIN_PARAM_GATEWAY_CPU_LIMIT, split.Gateway.CPULimit) + }) + } +} + +func assertParamEq(t *testing.T, params map[string]string, key, want string) { + t.Helper() + got, present := params[key] + if want == "" { + if present { + t.Errorf("param %q unexpectedly set to %q", key, got) + } + return + } + if got != want { + t.Errorf("param %q drift: got %q want %q", key, got, want) + } +} diff --git a/operator/src/internal/cnpg/pg_defaults.go b/operator/src/internal/cnpg/pg_defaults.go index 54d8eb51e..32a8e3c79 100644 --- a/operator/src/internal/cnpg/pg_defaults.go +++ b/operator/src/internal/cnpg/pg_defaults.go @@ -7,6 +7,7 @@ import ( "fmt" dbpreview "github.com/documentdb/documentdb-operator/api/preview" + "github.com/documentdb/documentdb-operator/internal/product" ) // formatMB formats a megabyte value as a PostgreSQL size string. @@ -81,27 +82,46 @@ func StaticDefaults() map[string]string { // ProtectedParameters returns parameters that are always force-set by the // operator and cannot be overridden by users. func ProtectedParameters(documentdb *dbpreview.DocumentDB) map[string]string { + return protectedParameters(product.FeatureGates{ + IOUring: dbpreview.IsFeatureGateEnabled(documentdb, dbpreview.FeatureGateIOUring), + }) +} + +// protectedParameters returns the neutral operator-owned GUCs (always highest +// priority) resolved from the product-neutral feature gates. +func protectedParameters(gates product.FeatureGates) map[string]string { params := map[string]string{ "cron.database_name": "postgres", "max_replication_slots": "10", "max_wal_senders": "10", "max_prepared_transactions": "100", } - if dbpreview.IsFeatureGateEnabled(documentdb, dbpreview.FeatureGateChangeStreams) { - params["wal_level"] = "logical" - } - if dbpreview.IsFeatureGateEnabled(documentdb, dbpreview.FeatureGateIOUring) { + if gates.IOUring { params["io_method"] = "io_uring" } return params } // MergeParameters merges all parameter sources in priority order (last write wins): -// 1. StaticDefaults -// 2. ComputeMemoryAwareDefaults -// 3. User overrides (documentdb.Spec.Postgres.Parameters) -// 4. ProtectedParameters (always wins) +// 1. StaticDefaults +// 2. ComputeMemoryAwareDefaults +// 3. Resolved parameters (user overrides plus product-mandated defaults such as +// change streams' wal_level=logical, supplied by the adapter) +// 4. ProtectedParameters (always wins) +// +// It delegates parameter resolution to the DocumentDB adapter so wal_level and +// any future product defaults have a single source of truth shared with the +// intent-driven builder. func MergeParameters(documentdb *dbpreview.DocumentDB, memoryLimitBytes int64) map[string]string { + intent := product.DocumentDBAdapter{}.ToClusterIntent(documentdb) + return MergeParametersResolved(intent.Postgres.Parameters, intent.FeatureGates, memoryLimitBytes) +} + +// MergeParametersResolved merges the parameter sources from product-neutral +// inputs. userParams are the adapter-resolved parameters (user overrides plus any +// product-mandated defaults). It is the seam the builder drives; the *DocumentDB +// wrapper above is retained for direct callers and tests. +func MergeParametersResolved(userParams map[string]string, gates product.FeatureGates, memoryLimitBytes int64) map[string]string { result := make(map[string]string) for k, v := range StaticDefaults() { @@ -110,12 +130,10 @@ func MergeParameters(documentdb *dbpreview.DocumentDB, memoryLimitBytes int64) m for k, v := range ComputeMemoryAwareDefaults(memoryLimitBytes) { result[k] = v } - if documentdb.Spec.Postgres != nil { - for k, v := range documentdb.Spec.Postgres.Parameters { - result[k] = v - } + for k, v := range userParams { + result[k] = v } - for k, v := range ProtectedParameters(documentdb) { + for k, v := range protectedParameters(gates) { result[k] = v } diff --git a/operator/src/internal/cnpg/pg_defaults_test.go b/operator/src/internal/cnpg/pg_defaults_test.go index f9cdeea5d..c3a256ed4 100644 --- a/operator/src/internal/cnpg/pg_defaults_test.go +++ b/operator/src/internal/cnpg/pg_defaults_test.go @@ -216,8 +216,8 @@ var _ = Describe("ProtectedParameters", func() { result = ProtectedParameters(documentdb) }) - It("sets wal_level to logical", func() { - Expect(result["wal_level"]).To(Equal("logical")) + It("does not set wal_level (change streams contributes it as a resolved parameter, not a protected one)", func() { + Expect(result).NotTo(HaveKey("wal_level")) }) It("still contains other protected params", func() { diff --git a/operator/src/internal/cnpg/resource_split.go b/operator/src/internal/cnpg/resource_split.go index 961695f1c..178dfac8f 100644 --- a/operator/src/internal/cnpg/resource_split.go +++ b/operator/src/internal/cnpg/resource_split.go @@ -10,6 +10,7 @@ import ( "k8s.io/apimachinery/pkg/api/resource" dbpreview "github.com/documentdb/documentdb-operator/api/preview" + "github.com/documentdb/documentdb-operator/internal/product" util "github.com/documentdb/documentdb-operator/internal/utils" ) @@ -103,16 +104,21 @@ func DefaultSplitConfig() SplitConfig { // Legacy behavior is preserved: when neither the envelope nor any per-container // value is set for a dimension, that dimension is left unmanaged (no limits). func ComputeResourceSplit(documentdb *dbpreview.DocumentDB, cfg SplitConfig) ResourceSplit { - res := documentdb.Spec.Resource monitoring := documentdb.Spec.Monitoring != nil && documentdb.Spec.Monitoring.Enabled + return ComputeResourceSplitFromResource(product.ResourceFromSpec(documentdb.Spec.Resource), monitoring, cfg) +} +// ComputeResourceSplitFromResource resolves the pod resource carve-out from the +// product-neutral Resource model. It is the seam the builder drives; the +// *DocumentDB wrapper above is retained for direct callers and tests. +func ComputeResourceSplitFromResource(res product.Resource, monitoring bool, cfg SplitConfig) ResourceSplit { envelopeBytes := parseMemoryToBytes(res.Memory) split := ResourceSplit{MonitoringEnabled: monitoring} // --- OTel collector (memory) --- var otelBytes int64 if monitoring { - if componentMemSet(res.OTel) { + if neutralMemSet(res.OTel) { // Explicit override: requests == limits (Guaranteed). split.OTel.setMemory(res.OTel.Memory) otelBytes = parseMemoryToBytes(res.OTel.Memory) @@ -127,7 +133,7 @@ func ComputeResourceSplit(documentdb *dbpreview.DocumentDB, cfg SplitConfig) Res // otherwise the collector keeps its Burstable default (request floor + // a bounded limit ceiling). CPU is compressible, so the carve-out below // only reserves the request from the envelope — the limit just caps burst. - if cpu := componentCPU(res.OTel); cpu != "" { + if cpu := neutralCPU(res.OTel); cpu != "" { split.OTel.setCPU(cpu) } else { split.OTel.CPURequest = cfg.OTelCPURequest @@ -137,7 +143,7 @@ func ComputeResourceSplit(documentdb *dbpreview.DocumentDB, cfg SplitConfig) Res // --- Gateway (memory) --- var gatewayBytes int64 - if componentMemSet(res.Gateway) { + if neutralMemSet(res.Gateway) { split.Gateway.setMemory(res.Gateway.Memory) gatewayBytes = parseMemoryToBytes(res.Gateway.Memory) } else if envelopeBytes > 0 { @@ -147,14 +153,14 @@ func ComputeResourceSplit(documentdb *dbpreview.DocumentDB, cfg SplitConfig) Res // Gateway CPU: explicit override wins, else operator-level limit (request // mirrors the limit so the container is Guaranteed on CPU when bounded). - if cpu := componentCPU(res.Gateway); cpu != "" { + if cpu := neutralCPU(res.Gateway); cpu != "" { split.Gateway.setCPU(cpu) } else if cfg.GatewayCPULimit != "" { split.Gateway.setCPU(cfg.GatewayCPULimit) } // --- PostgreSQL (remainder) --- - if componentMemSet(res.Database) { + if neutralMemSet(res.Database) { split.Postgres.setMemory(res.Database.Memory) split.PostgresMemoryBytes = parseMemoryToBytes(res.Database.Memory) } else if envelopeBytes > 0 { @@ -171,7 +177,7 @@ func ComputeResourceSplit(documentdb *dbpreview.DocumentDB, cfg SplitConfig) Res // PostgreSQL CPU (sink): database override wins; otherwise the pod CPU // envelope minus the gateway and OTel CPU reservations, symmetric with the // memory carve-out so the resolved container CPUs sum to the envelope. - if cpu := componentCPU(res.Database); cpu != "" { + if cpu := neutralCPU(res.Database); cpu != "" { split.Postgres.setCPU(cpu) } else if env := normalizeCPU(res.CPU); env != "" { pgCPU := subtractCPU(env, split.Gateway.CPURequest, split.OTel.CPURequest) @@ -219,14 +225,19 @@ func subtractCPU(envelope string, reserved ...string) string { // --- helpers --- -// componentCPU returns the component's CPU override, or "" when unset/zero. -func componentCPU(c *dbpreview.ComponentResources) string { +// neutralCPU returns the component's CPU override, or "" when unset/zero. +func neutralCPU(c *product.ComponentResource) string { if c == nil { return "" } return normalizeCPU(c.CPU) } +// neutralMemSet reports whether the neutral component has an explicit memory value. +func neutralMemSet(c *product.ComponentResource) bool { + return c != nil && isSet(c.Memory) +} + // normalizeCPU returns cpu unless it is unset/zero, in which case "". func normalizeCPU(cpu string) string { if !isSet(cpu) { diff --git a/operator/src/internal/product/documentdb.go b/operator/src/internal/product/documentdb.go index b40c2c35a..39a512c18 100644 --- a/operator/src/internal/product/documentdb.go +++ b/operator/src/internal/product/documentdb.go @@ -101,8 +101,21 @@ func (a DocumentDBAdapter) ToClusterIntent(db *dbpreview.DocumentDB) ClusterInte UID: db.Spec.Postgres.UID, GID: db.Spec.Postgres.GID, PostInitSQL: db.Spec.Postgres.PostInitSQL, + Parameters: db.Spec.Postgres.Parameters, } } + // Change streams is a DocumentDB-specific gate. Its only cluster effect is a + // PostgreSQL GUC, so the adapter contributes wal_level=logical as an ordinary + // resolved parameter rather than leaking a product concept into the neutral + // feature gates. Copy first so the user's spec map is never mutated. + if dbpreview.IsFeatureGateEnabled(db, dbpreview.FeatureGateChangeStreams) { + params := make(map[string]string, len(pg.Parameters)+1) + for k, v := range pg.Parameters { + params[k] = v + } + params["wal_level"] = "logical" + pg.Parameters = params + } var bootstrap Bootstrap if db.Spec.Bootstrap != nil && db.Spec.Bootstrap.Recovery != nil { @@ -114,6 +127,19 @@ func (a DocumentDBAdapter) ToClusterIntent(db *dbpreview.DocumentDB) ClusterInte bootstrap.Recovery = &r } + var tls TLS + if db.Status.TLS != nil && db.Status.TLS.Ready && db.Status.TLS.SecretName != "" { + tls.GatewaySecretName = db.Status.TLS.SecretName + } + if db.Spec.TLS != nil { + tls.PostgresCertificates = db.Spec.TLS.Postgres + } + + maxStopDelay := int32(util.CNPG_DEFAULT_STOP_DELAY) + if db.Spec.Timeouts.StopDelay != 0 { + maxStopDelay = db.Spec.Timeouts.StopDelay + } + return ClusterIntent{ Images: Images{ PostgresExtension: a.ExtensionImage(db), @@ -135,6 +161,13 @@ func (a DocumentDBAdapter) ToClusterIntent(db *dbpreview.DocumentDB) ClusterInte Kind: db.Kind, }, Postgres: pg, + Resource: ResourceFromSpec(db.Spec.Resource), + TLS: tls, + Monitoring: Monitoring{ + Enabled: db.Spec.Monitoring != nil && db.Spec.Monitoring.Enabled, + }, + LogLevel: db.Spec.LogLevel, + MaxStopDelay: maxStopDelay, FeatureGates: FeatureGates{ IOUring: dbpreview.IsFeatureGateEnabled(db, dbpreview.FeatureGateIOUring), }, @@ -146,5 +179,24 @@ func (a DocumentDBAdapter) ToClusterIntent(db *dbpreview.DocumentDB) ClusterInte } } +// ResourceFromSpec converts the DocumentDB resource spec into the product-neutral +// Resource model consumed by the CNPG resource-split logic. +func ResourceFromSpec(res dbpreview.Resource) Resource { + return Resource{ + Memory: res.Memory, + CPU: res.CPU, + Database: componentFromSpec(res.Database), + Gateway: componentFromSpec(res.Gateway), + OTel: componentFromSpec(res.OTel), + } +} + +func componentFromSpec(c *dbpreview.ComponentResources) *ComponentResource { + if c == nil { + return nil + } + return &ComponentResource{Memory: c.Memory, CPU: c.CPU} +} + // compile-time assertion that DocumentDBAdapter satisfies the Adapter seam. var _ Adapter = DocumentDBAdapter{} diff --git a/operator/src/internal/product/intent.go b/operator/src/internal/product/intent.go index 06ad7c5b4..9e8ffe2ed 100644 --- a/operator/src/internal/product/intent.go +++ b/operator/src/internal/product/intent.go @@ -47,21 +47,67 @@ type Identity struct { } // Postgres carries the operator-managed PostgreSQL process and init tuning taken -// from the custom resource. Parameter/GUC assembly stays product-specific in the -// builder and is not represented here yet. +// from the custom resource. type Postgres struct { // UID and GID are the process identity overrides; nil leaves the CNPG default. UID *int64 GID *int64 // PostInitSQL is appended to the mandatory bootstrap SQL. PostInitSQL []string + // Parameters are the resolved PostgreSQL GUC overrides. They start from the + // user-supplied values and may include product-mandated defaults contributed + // by the adapter (for example DocumentDB change streams adds + // wal_level=logical). They are merged on top of the operator's static and + // memory-aware defaults and below the neutral protected parameters when the + // builder assembles the final GUC set. + Parameters map[string]string } -// FeatureGates carries the resolved feature-gate flags the builder acts on. +// FeatureGates carries the resolved, product-neutral feature-gate flags the +// builder acts on. Only genuinely cross-product (infrastructure) gates belong +// here; product-specific gates are expressed through their concrete effect (for +// example Postgres.ProtectedParameters) instead of leaking into this struct. type FeatureGates struct { + // IOUring relaxes the postgres seccomp profile and enables io_method=io_uring. + // It is an infrastructure concern shared across products. IOUring bool } +// (request==limit when set). Empty strings mean "unset". +type ComponentResource struct { + Memory string + CPU string +} + +// Resource is the product-neutral pod resource envelope plus optional +// per-container overrides. It mirrors the shape the builder carves across the +// PostgreSQL, gateway, and OTel collector containers. +type Resource struct { + // Memory and CPU are the total pod envelope (may be empty/unset). + Memory string + CPU string + // Database, Gateway, and OTel optionally override individual containers. + Database *ComponentResource + Gateway *ComponentResource + OTel *ComponentResource +} + +// TLS carries the resolved TLS inputs the builder renders onto the Cluster. +type TLS struct { + // GatewaySecretName is the ready gateway TLS secret surfaced to the plugin. + // Empty when TLS is not yet provisioned. + GatewaySecretName string + // PostgresCertificates is the CNPG certificates passthrough for the Postgres + // server (nil when TLS is not configured). + PostgresCertificates *cnpgv1.CertificatesConfiguration +} + +// Monitoring carries the resolved monitoring flags the builder acts on. The full +// OTel configuration is routed through the intent in a later phase. +type Monitoring struct { + Enabled bool +} + // Recovery describes a bootstrap-from-source request. A nil Recovery on Bootstrap // means default initialization. type Recovery struct { @@ -94,6 +140,22 @@ type ClusterIntent struct { // Postgres is the operator-managed PostgreSQL process and init tuning. Postgres Postgres + // Resource is the product-neutral pod resource envelope and per-container + // overrides the builder carves across containers. + Resource Resource + + // TLS carries the resolved TLS inputs (gateway secret + Postgres certificates). + TLS TLS + + // Monitoring carries the resolved monitoring flags. + Monitoring Monitoring + + // LogLevel is the desired CNPG log level (empty means the builder default). + LogLevel string + + // MaxStopDelay is the resolved CNPG max stop delay (seconds), defaults applied. + MaxStopDelay int32 + // FeatureGates are the resolved feature-gate flags. FeatureGates FeatureGates From a777f596b3bc58a882538f01a4aed025ae1197f9 Mon Sep 17 00:00:00 2001 From: Wenting Wu Date: Wed, 16 Sep 2026 21:57:03 -0400 Subject: [PATCH 2/9] refactor(operator): route OTel/monitoring through ClusterIntent (Phase 0b) Neutralize the monitoring render path so the CNPG builder no longer reads the DocumentDB CRD. internal/otel is now product-agnostic: its config generation takes a neutral otel.MonitoringConfig instead of *dbpreview.MonitoringSpec. product.MonitoringConfigFromSpec is the single CRD->neutral mapper shared by the adapter and the controller's ConfigMap reconcile. GetCnpgClusterSpecFromIntent drops its *DocumentDB parameter; the renderer computes the OTel config-map name, Prometheus port, and the change-detection config hash on the fly from intent.Monitoring + req.Namespace, preserving the original design. The drift guard gains monitoring cases asserting otelConfigMapName/prometheusPort/otelConfigHash and the monitor role match the direct otel computation, proving the render stays byte-identical. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 03549680-3e40-4bea-8144-f0d0cfc6cb46 Signed-off-by: Wenting Wu --- operator/src/internal/cnpg/cnpg_cluster.go | 19 ++- .../internal/cnpg/cnpg_intent_drift_test.go | 62 ++++++++++ .../src/internal/cnpg/cnpg_intent_test.go | 4 +- .../controller/documentdb_controller.go | 4 +- operator/src/internal/otel/config.go | 94 ++++++++------- operator/src/internal/otel/config_test.go | 114 ++++-------------- operator/src/internal/product/documentdb.go | 32 ++++- operator/src/internal/product/intent.go | 16 ++- 8 files changed, 184 insertions(+), 161 deletions(-) diff --git a/operator/src/internal/cnpg/cnpg_cluster.go b/operator/src/internal/cnpg/cnpg_cluster.go index 4d9c7df7d..4912d1496 100644 --- a/operator/src/internal/cnpg/cnpg_cluster.go +++ b/operator/src/internal/cnpg/cnpg_cluster.go @@ -31,14 +31,14 @@ func GetCnpgClusterSpec(req ctrl.Request, documentdb *dbpreview.DocumentDB, docu if documentdbImage != "" { intent.Images.PostgresExtension = documentdbImage } - return GetCnpgClusterSpecFromIntent(req, documentdb, intent, serviceAccountName, storageClass, isPrimaryRegion, log) + return GetCnpgClusterSpecFromIntent(req, intent, serviceAccountName, storageClass, isPrimaryRegion, log) } // GetCnpgClusterSpecFromIntent renders a CNPG Cluster from a product-neutral // ClusterIntent. This is the seam the reconciler drives: product-varying values // (images, credential secret, plugin name) come from the intent rather than from // product-specific lookups. -func GetCnpgClusterSpecFromIntent(req ctrl.Request, documentdb *dbpreview.DocumentDB, intent product.ClusterIntent, serviceAccountName, storageClass string, isPrimaryRegion bool, log logr.Logger) *cnpgv1.Cluster { +func GetCnpgClusterSpecFromIntent(req ctrl.Request, intent product.ClusterIntent, serviceAccountName, storageClass string, isPrimaryRegion bool, log logr.Logger) *cnpgv1.Cluster { split := ComputeResourceSplitFromResource(intent.Resource, intent.Monitoring.Enabled, DefaultSplitConfig()) sidecarPluginName := intent.SidecarInjectorPlugin @@ -110,18 +110,18 @@ func GetCnpgClusterSpecFromIntent(req ctrl.Request, documentdb *dbpreview.Docume // Config hash triggers operator-initiated rolling restart on config changes. if split.MonitoringEnabled { params["otelCollectorImage"] = util.DEFAULT_OTEL_COLLECTOR_IMAGE - params["otelConfigMapName"] = otelcfg.ConfigMapName(documentdb.Name) + params["otelConfigMapName"] = otelcfg.ConfigMapName(intent.Identity.Name) addPluginParamIfSet(params, util.PLUGIN_PARAM_OTEL_MEMORY_REQUEST, split.OTel.MemoryRequest) addPluginParamIfSet(params, util.PLUGIN_PARAM_OTEL_MEMORY_LIMIT, split.OTel.MemoryLimit) addPluginParamIfSet(params, util.PLUGIN_PARAM_OTEL_CPU_REQUEST, split.OTel.CPURequest) addPluginParamIfSet(params, util.PLUGIN_PARAM_OTEL_CPU_LIMIT, split.OTel.CPULimit) - if promPort := otelcfg.ResolvePrometheusPort(documentdb.Spec.Monitoring); promPort > 0 { + if promPort := otelcfg.ResolvePrometheusPort(intent.Monitoring); promPort > 0 { params["prometheusPort"] = fmt.Sprintf("%d", promPort) } // Compute config hash for change detection. The operator triggers a // rolling restart (via restart annotation) when plugin parameters // change, ensuring pods pick up new config. - if configData, err := otelcfg.GenerateConfigMapData(documentdb.Name, req.Namespace, documentdb.Spec.Monitoring); err == nil { + if configData, err := otelcfg.GenerateConfigMapData(intent.Identity.Name, req.Namespace, intent.Monitoring); err == nil { params["otelConfigHash"] = otelcfg.HashConfigMapData(configData) } else { log.Error(err, "Failed to generate OTel config hash; config changes may not trigger rolling restart") @@ -149,7 +149,7 @@ func GetCnpgClusterSpecFromIntent(req ctrl.Request, documentdb *dbpreview.Docume spec.MaxStopDelay = intent.MaxStopDelay applyPostgresProcessIdentity(&spec, intent) applyIOUringSeccomp(&spec, intent) - applyOtelMonitorRole(&spec, documentdb) + applyOtelMonitorRoleFromIntent(&spec, intent.Monitoring.Enabled) return spec }(), @@ -363,15 +363,12 @@ func applyIOUringSeccomp(spec *cnpgv1.ClusterSpec, intent product.ClusterIntent) // PostgreSQL host authentication currently uses trust, so a generated password // would not be checked. Disable the role password explicitly until authentication // is tightened rather than creating an unused credential and widening Secret RBAC. -func applyOtelMonitorRole(spec *cnpgv1.ClusterSpec, documentdb *dbpreview.DocumentDB) { - if documentdb == nil { - return - } +func applyOtelMonitorRoleFromIntent(spec *cnpgv1.ClusterSpec, monitoringEnabled bool) { if spec.Managed == nil { spec.Managed = &cnpgv1.ManagedConfiguration{} } role := absentOtelMonitorRole() - if documentdb.Spec.Monitoring != nil && documentdb.Spec.Monitoring.Enabled { + if monitoringEnabled { // The current health query is SELECT 1, so the role needs LOGIN only // and is not granted broad monitoring memberships. role = cnpgv1.RoleConfiguration{ diff --git a/operator/src/internal/cnpg/cnpg_intent_drift_test.go b/operator/src/internal/cnpg/cnpg_intent_drift_test.go index f0fbee9a8..bdb5cc878 100644 --- a/operator/src/internal/cnpg/cnpg_intent_drift_test.go +++ b/operator/src/internal/cnpg/cnpg_intent_drift_test.go @@ -5,14 +5,18 @@ package cnpg import ( "cmp" + "fmt" "reflect" "testing" cnpgv1 "github.com/cloudnative-pg/cloudnative-pg/api/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/log/zap" dbpreview "github.com/documentdb/documentdb-operator/api/preview" + otelcfg "github.com/documentdb/documentdb-operator/internal/otel" + "github.com/documentdb/documentdb-operator/internal/product" util "github.com/documentdb/documentdb-operator/internal/utils" ) @@ -108,6 +112,33 @@ func TestRenderIntentSeamNoDrift(t *testing.T) { TLS: &dbpreview.TLSStatus{Ready: true, SecretName: "gw-tls-secret"}, }, }, + "monitoring-prometheus": { + ObjectMeta: metav1.ObjectMeta{Name: "mon-prom"}, + Spec: dbpreview.DocumentDBSpec{ + InstancesPerNode: 1, + Resource: dbpreview.Resource{Storage: dbpreview.StorageConfiguration{PvcSize: "10Gi"}}, + Monitoring: &dbpreview.MonitoringSpec{ + Enabled: true, + Exporter: &dbpreview.ExporterSpec{ + Prometheus: &dbpreview.PrometheusExporterSpec{Port: 9090}, + }, + }, + }, + }, + "monitoring-otlp-and-prometheus": { + ObjectMeta: metav1.ObjectMeta{Name: "mon-both"}, + Spec: dbpreview.DocumentDBSpec{ + InstancesPerNode: 1, + Resource: dbpreview.Resource{Storage: dbpreview.StorageConfiguration{PvcSize: "10Gi"}}, + Monitoring: &dbpreview.MonitoringSpec{ + Enabled: true, + Exporter: &dbpreview.ExporterSpec{ + OTLP: &dbpreview.OTLPExporterSpec{Endpoint: "otel-collector:4317"}, + Prometheus: &dbpreview.PrometheusExporterSpec{}, + }, + }, + }, + }, } for name, db := range cases { @@ -162,6 +193,37 @@ func TestRenderIntentSeamNoDrift(t *testing.T) { assertParamEq(t, spec.Plugins[0].Parameters, util.PLUGIN_PARAM_GATEWAY_MEMORY_LIMIT, split.Gateway.MemoryLimit) assertParamEq(t, spec.Plugins[0].Parameters, util.PLUGIN_PARAM_GATEWAY_CPU_REQUEST, split.Gateway.CPURequest) assertParamEq(t, spec.Plugins[0].Parameters, util.PLUGIN_PARAM_GATEWAY_CPU_LIMIT, split.Gateway.CPULimit) + + // OTel plugin params: the intent-driven path must equal the direct + // otel computation from the monitoring spec (config map name, prometheus + // port, and — critically — the config hash that drives pod restarts). + mon := product.MonitoringConfigFromSpec(db.Spec.Monitoring) + wantCM, wantPort, wantHash := "", "", "" + if mon.Enabled { + wantCM = otelcfg.ConfigMapName(db.Name) + if p := otelcfg.ResolvePrometheusPort(mon); p > 0 { + wantPort = fmt.Sprintf("%d", p) + } + if data, err := otelcfg.GenerateConfigMapData(db.Name, req.Namespace, mon); err == nil { + wantHash = otelcfg.HashConfigMapData(data) + } + } + assertParamEq(t, spec.Plugins[0].Parameters, "otelConfigMapName", wantCM) + assertParamEq(t, spec.Plugins[0].Parameters, "prometheusPort", wantPort) + assertParamEq(t, spec.Plugins[0].Parameters, "otelConfigHash", wantHash) + + // OTel monitor role: present (EnsurePresent) only when monitoring is on. + gotRolePresent := false + if spec.Managed != nil { + for _, r := range spec.Managed.Roles { + if r.Name == otelcfg.MonitorRoleName && r.Ensure == cnpgv1.EnsurePresent { + gotRolePresent = true + } + } + } + if gotRolePresent != mon.Enabled { + t.Errorf("otel monitor role presence drift: got %v want %v", gotRolePresent, mon.Enabled) + } }) } } diff --git a/operator/src/internal/cnpg/cnpg_intent_test.go b/operator/src/internal/cnpg/cnpg_intent_test.go index 1aee58ade..86c368803 100644 --- a/operator/src/internal/cnpg/cnpg_intent_test.go +++ b/operator/src/internal/cnpg/cnpg_intent_test.go @@ -44,7 +44,7 @@ var _ = Describe("GetCnpgClusterSpecFromIntent", func() { SidecarInjectorPlugin: "custom-injector.example.io", } - result := GetCnpgClusterSpecFromIntent(newRequest(), newDocumentDB(), intent, "test-sa", "", true, log) + result := GetCnpgClusterSpecFromIntent(newRequest(), intent, "test-sa", "", true, log) Expect(result.Spec.PostgresConfiguration.Extensions[0].ImageVolumeSource.Reference).To(Equal("reg/ext:test")) Expect(result.Spec.Plugins[0].Name).To(Equal("custom-injector.example.io")) @@ -56,7 +56,7 @@ var _ = Describe("GetCnpgClusterSpecFromIntent", func() { documentdb := newDocumentDB() intent := product.DocumentDBAdapter{}.ToClusterIntent(documentdb) - fromIntent := GetCnpgClusterSpecFromIntent(newRequest(), documentdb, intent, "test-sa", "", true, log) + fromIntent := GetCnpgClusterSpecFromIntent(newRequest(), intent, "test-sa", "", true, log) fromWrapper := GetCnpgClusterSpec(newRequest(), documentdb, "", "test-sa", "", true, log) Expect(fromIntent.Spec.PostgresConfiguration.Extensions[0].ImageVolumeSource.Reference). diff --git a/operator/src/internal/controller/documentdb_controller.go b/operator/src/internal/controller/documentdb_controller.go index 9dd8ed856..1c5c04b5d 100644 --- a/operator/src/internal/controller/documentdb_controller.go +++ b/operator/src/internal/controller/documentdb_controller.go @@ -155,7 +155,7 @@ func (r *DocumentDBReconciler) Reconcile(ctx context.Context, req ctrl.Request) intent := product.DocumentDBAdapter{}.ToClusterIntent(documentdb) currentCnpgCluster := &cnpgv1.Cluster{} - desiredCnpgCluster := cnpg.GetCnpgClusterSpecFromIntent(req, documentdb, intent, documentdb.Name, replicationContext.StorageClass, replicationContext.IsPrimary(), logger) + desiredCnpgCluster := cnpg.GetCnpgClusterSpecFromIntent(req, intent, documentdb.Name, replicationContext.StorageClass, replicationContext.IsPrimary(), logger) if replicationContext.IsReplicating() { err = r.AddClusterReplicationToClusterSpec(ctx, documentdb, replicationContext, desiredCnpgCluster) @@ -1032,7 +1032,7 @@ func (r *DocumentDBReconciler) reconcileOtelConfigMap(ctx context.Context, docum return fmt.Errorf("failed to set owner reference: %w", err) } - configData, err := otelcfg.GenerateConfigMapData(documentdb.Name, namespace, documentdb.Spec.Monitoring) + configData, err := otelcfg.GenerateConfigMapData(documentdb.Name, namespace, product.MonitoringConfigFromSpec(documentdb.Spec.Monitoring)) if err != nil { return fmt.Errorf("failed to generate OTel config: %w", err) } diff --git a/operator/src/internal/otel/config.go b/operator/src/internal/otel/config.go index e87eb7e65..51e7afb34 100644 --- a/operator/src/internal/otel/config.go +++ b/operator/src/internal/otel/config.go @@ -10,8 +10,6 @@ import ( "sort" "gopkg.in/yaml.v3" - - dbpreview "github.com/documentdb/documentdb-operator/api/preview" ) //go:embed base_config.yaml @@ -19,6 +17,20 @@ var baseConfigYAML []byte const defaultPrometheusPort = 8888 +// MonitoringConfig is the product-neutral OTel collector configuration input. +// Product adapters map their custom resource's monitoring spec onto this struct +// so the collector config generation stays independent of any product CRD type. +type MonitoringConfig struct { + // Enabled reports whether the OTel Collector sidecar is requested. + Enabled bool + // OTLPEndpoint is the OTLP gRPC exporter endpoint. Empty disables the exporter. + OTLPEndpoint string + // Prometheus reports whether the Prometheus scrape exporter is configured. + Prometheus bool + // PrometheusPort is the Prometheus scrape port; 0 selects the default. + PrometheusPort int32 +} + // MonitorRoleName is the dedicated PostgreSQL identity the OTel Collector // sidecar uses for its health-check query. const MonitorRoleName = "otel_monitor" @@ -66,8 +78,8 @@ func ConfigMapName(clusterName string) string { // disabled, the operator deletes the ConfigMap and removes sidecar parameters, // then triggers a rolling restart (via restart annotation) so that CNPG // recreates pods without the sidecar. -func GenerateConfigMapData(clusterName, namespace string, spec *dbpreview.MonitoringSpec) (map[string]string, error) { - dynamicYAML, err := generateDynamicConfig(clusterName, namespace, spec) +func GenerateConfigMapData(clusterName, namespace string, cfg MonitoringConfig) (map[string]string, error) { + dynamicYAML, err := generateDynamicConfig(clusterName, namespace, cfg) if err != nil { return nil, err } @@ -81,7 +93,7 @@ func GenerateConfigMapData(clusterName, namespace string, spec *dbpreview.Monito // generateDynamicConfig builds the per-cluster dynamic config (resource // processor, exporters, pipeline wiring) that the collector deep-merges // with the embedded base_config.yaml. -func generateDynamicConfig(clusterName, namespace string, spec *dbpreview.MonitoringSpec) (string, error) { +func generateDynamicConfig(clusterName, namespace string, mon MonitoringConfig) (string, error) { cfg := collectorConfig{ Processors: map[string]any{ // `insert` (not `upsert`) so receivers that already emit their @@ -105,42 +117,40 @@ func generateDynamicConfig(clusterName, namespace string, spec *dbpreview.Monito exporterNames := []string{} - if spec.Exporter != nil { - if otlp := spec.Exporter.OTLP; otlp != nil && otlp.Endpoint != "" { - if cfg.Exporters == nil { - cfg.Exporters = map[string]any{} - } - cfg.Exporters["otlp"] = map[string]any{ - "endpoint": otlp.Endpoint, - "tls": map[string]any{ - // TODO: Support TLS for OTLP exporter. Currently hardcoded to - // insecure for in-cluster communication. When TLS is needed, - // add TLS config fields to OTLPExporterSpec (certSecret, etc.). - "insecure": true, - }, - } - exporterNames = append(exporterNames, "otlp") + if mon.OTLPEndpoint != "" { + if cfg.Exporters == nil { + cfg.Exporters = map[string]any{} + } + cfg.Exporters["otlp"] = map[string]any{ + "endpoint": mon.OTLPEndpoint, + "tls": map[string]any{ + // TODO: Support TLS for OTLP exporter. Currently hardcoded to + // insecure for in-cluster communication. When TLS is needed, + // add TLS config fields to the exporter model (certSecret, etc.). + "insecure": true, + }, } + exporterNames = append(exporterNames, "otlp") + } - if prom := spec.Exporter.Prometheus; prom != nil { - if cfg.Exporters == nil { - cfg.Exporters = map[string]any{} - } - port := prom.Port - if port == 0 { - port = defaultPrometheusPort - } - cfg.Exporters["prometheus"] = map[string]any{ - "endpoint": fmt.Sprintf("0.0.0.0:%d", port), - // Surface resource attributes as Prometheus labels (instead - // of burying them in target_info) so dashboards can filter - // by pod/container/cluster. - "resource_to_telemetry_conversion": map[string]any{ - "enabled": true, - }, - } - exporterNames = append(exporterNames, "prometheus") + if mon.Prometheus { + if cfg.Exporters == nil { + cfg.Exporters = map[string]any{} + } + port := mon.PrometheusPort + if port == 0 { + port = defaultPrometheusPort + } + cfg.Exporters["prometheus"] = map[string]any{ + "endpoint": fmt.Sprintf("0.0.0.0:%d", port), + // Surface resource attributes as Prometheus labels (instead + // of burying them in target_info) so dashboards can filter + // by pod/container/cluster. + "resource_to_telemetry_conversion": map[string]any{ + "enabled": true, + }, } + exporterNames = append(exporterNames, "prometheus") } // Wire pipeline: receivers + memory_limiter/batch from static.yaml, @@ -193,12 +203,12 @@ func HashConfigMapData(data map[string]string) string { // ResolvePrometheusPort returns the effective Prometheus port from the spec, // or 0 if Prometheus exporter is not configured. -func ResolvePrometheusPort(spec *dbpreview.MonitoringSpec) int32 { - if spec == nil || spec.Exporter == nil || spec.Exporter.Prometheus == nil { +func ResolvePrometheusPort(mon MonitoringConfig) int32 { + if !mon.Prometheus { return 0 } - if spec.Exporter.Prometheus.Port == 0 { + if mon.PrometheusPort == 0 { return defaultPrometheusPort } - return spec.Exporter.Prometheus.Port + return mon.PrometheusPort } diff --git a/operator/src/internal/otel/config_test.go b/operator/src/internal/otel/config_test.go index 0032c3312..d9aa6092d 100644 --- a/operator/src/internal/otel/config_test.go +++ b/operator/src/internal/otel/config_test.go @@ -10,8 +10,6 @@ import ( . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" "gopkg.in/yaml.v3" - - dbpreview "github.com/documentdb/documentdb-operator/api/preview" ) func TestOtel(t *testing.T) { @@ -82,13 +80,8 @@ var _ = Describe("base_config.yaml embed", func() { var _ = Describe("GenerateConfigMapData", func() { It("returns static.yaml from embedded base_config.yaml", func() { - spec := &dbpreview.MonitoringSpec{ - Enabled: true, - Exporter: &dbpreview.ExporterSpec{ - Prometheus: &dbpreview.PrometheusExporterSpec{Port: 9090}, - }, - } - data, err := GenerateConfigMapData("cluster", "ns", spec) + cfg := MonitoringConfig{Enabled: true, Prometheus: true, PrometheusPort: 9090} + data, err := GenerateConfigMapData("cluster", "ns", cfg) Expect(err).NotTo(HaveOccurred()) // static.yaml should contain the embedded base config @@ -99,13 +92,8 @@ var _ = Describe("GenerateConfigMapData", func() { }) It("generates dynamic.yaml with resource processor and exporters", func() { - spec := &dbpreview.MonitoringSpec{ - Enabled: true, - Exporter: &dbpreview.ExporterSpec{ - Prometheus: &dbpreview.PrometheusExporterSpec{Port: 9090}, - }, - } - data, err := GenerateConfigMapData("test-cluster", "test-ns", spec) + cfg := MonitoringConfig{Enabled: true, Prometheus: true, PrometheusPort: 9090} + data, err := GenerateConfigMapData("test-cluster", "test-ns", cfg) Expect(err).NotTo(HaveOccurred()) dynCfg := parseCfg(data["dynamic.yaml"]) @@ -128,15 +116,8 @@ var _ = Describe("GenerateConfigMapData", func() { }) It("includes OTLP exporter in dynamic.yaml when configured", func() { - spec := &dbpreview.MonitoringSpec{ - Enabled: true, - Exporter: &dbpreview.ExporterSpec{ - OTLP: &dbpreview.OTLPExporterSpec{ - Endpoint: "otel-collector.monitoring:4317", - }, - }, - } - data, err := GenerateConfigMapData("cluster", "ns", spec) + cfg := MonitoringConfig{Enabled: true, OTLPEndpoint: "otel-collector.monitoring:4317"} + data, err := GenerateConfigMapData("cluster", "ns", cfg) Expect(err).NotTo(HaveOccurred()) dynCfg := parseCfg(data["dynamic.yaml"]) @@ -145,14 +126,8 @@ var _ = Describe("GenerateConfigMapData", func() { }) It("skips OTLP exporter when endpoint is empty", func() { - spec := &dbpreview.MonitoringSpec{ - Enabled: true, - Exporter: &dbpreview.ExporterSpec{ - OTLP: &dbpreview.OTLPExporterSpec{Endpoint: ""}, - Prometheus: &dbpreview.PrometheusExporterSpec{Port: 9090}, - }, - } - data, err := GenerateConfigMapData("cluster", "ns", spec) + cfg := MonitoringConfig{Enabled: true, OTLPEndpoint: "", Prometheus: true, PrometheusPort: 9090} + data, err := GenerateConfigMapData("cluster", "ns", cfg) Expect(err).NotTo(HaveOccurred()) dynCfg := parseCfg(data["dynamic.yaml"]) @@ -160,13 +135,8 @@ var _ = Describe("GenerateConfigMapData", func() { }) It("includes Prometheus exporter with default port", func() { - spec := &dbpreview.MonitoringSpec{ - Enabled: true, - Exporter: &dbpreview.ExporterSpec{ - Prometheus: &dbpreview.PrometheusExporterSpec{}, - }, - } - data, err := GenerateConfigMapData("cluster", "ns", spec) + cfg := MonitoringConfig{Enabled: true, Prometheus: true} + data, err := GenerateConfigMapData("cluster", "ns", cfg) Expect(err).NotTo(HaveOccurred()) dynCfg := parseCfg(data["dynamic.yaml"]) @@ -177,13 +147,8 @@ var _ = Describe("GenerateConfigMapData", func() { }) It("includes Prometheus exporter with custom port", func() { - spec := &dbpreview.MonitoringSpec{ - Enabled: true, - Exporter: &dbpreview.ExporterSpec{ - Prometheus: &dbpreview.PrometheusExporterSpec{Port: 9090}, - }, - } - data, err := GenerateConfigMapData("cluster", "ns", spec) + cfg := MonitoringConfig{Enabled: true, Prometheus: true, PrometheusPort: 9090} + data, err := GenerateConfigMapData("cluster", "ns", cfg) Expect(err).NotTo(HaveOccurred()) dynCfg := parseCfg(data["dynamic.yaml"]) @@ -193,14 +158,8 @@ var _ = Describe("GenerateConfigMapData", func() { }) It("includes both OTLP and Prometheus exporters", func() { - spec := &dbpreview.MonitoringSpec{ - Enabled: true, - Exporter: &dbpreview.ExporterSpec{ - OTLP: &dbpreview.OTLPExporterSpec{Endpoint: "otel-collector:4317"}, - Prometheus: &dbpreview.PrometheusExporterSpec{Port: 9090}, - }, - } - data, err := GenerateConfigMapData("cluster", "ns", spec) + cfg := MonitoringConfig{Enabled: true, OTLPEndpoint: "otel-collector:4317", Prometheus: true, PrometheusPort: 9090} + data, err := GenerateConfigMapData("cluster", "ns", cfg) Expect(err).NotTo(HaveOccurred()) dynCfg := parseCfg(data["dynamic.yaml"]) @@ -211,8 +170,8 @@ var _ = Describe("GenerateConfigMapData", func() { }) It("generates no pipeline when no exporters configured", func() { - spec := &dbpreview.MonitoringSpec{Enabled: true, Exporter: nil} - data, err := GenerateConfigMapData("cluster", "ns", spec) + cfg := MonitoringConfig{Enabled: true} + data, err := GenerateConfigMapData("cluster", "ns", cfg) Expect(err).NotTo(HaveOccurred()) dynCfg := parseCfg(data["dynamic.yaml"]) @@ -222,13 +181,8 @@ var _ = Describe("GenerateConfigMapData", func() { // Regression guards — see comments in config.go for the why behind each. It("uses 'insert' (not 'upsert') on the resource processor so per-datapoint k8s attrs survive", func() { - spec := &dbpreview.MonitoringSpec{ - Enabled: true, - Exporter: &dbpreview.ExporterSpec{ - Prometheus: &dbpreview.PrometheusExporterSpec{Port: 9090}, - }, - } - data, err := GenerateConfigMapData("cluster", "ns", spec) + cfg := MonitoringConfig{Enabled: true, Prometheus: true, PrometheusPort: 9090} + data, err := GenerateConfigMapData("cluster", "ns", cfg) Expect(err).NotTo(HaveOccurred()) // Must not contain 'upsert' — that would clobber per-datapoint resource @@ -239,13 +193,8 @@ var _ = Describe("GenerateConfigMapData", func() { }) It("enables resource_to_telemetry_conversion on the prometheus exporter so resource attrs become labels", func() { - spec := &dbpreview.MonitoringSpec{ - Enabled: true, - Exporter: &dbpreview.ExporterSpec{ - Prometheus: &dbpreview.PrometheusExporterSpec{Port: 9090}, - }, - } - data, err := GenerateConfigMapData("cluster", "ns", spec) + cfg := MonitoringConfig{Enabled: true, Prometheus: true, PrometheusPort: 9090} + data, err := GenerateConfigMapData("cluster", "ns", cfg) Expect(err).NotTo(HaveOccurred()) // Without this option the prometheus exporter writes resource attrs only // to target_info, hiding documentdb.cluster / k8s.* labels. @@ -281,32 +230,19 @@ var _ = Describe("HashConfigMapData", func() { }) var _ = Describe("ResolvePrometheusPort", func() { - It("returns 0 when spec is nil", func() { - Expect(ResolvePrometheusPort(nil)).To(Equal(int32(0))) + It("returns 0 when monitoring is empty", func() { + Expect(ResolvePrometheusPort(MonitoringConfig{})).To(Equal(int32(0))) }) It("returns 0 when Prometheus is not configured", func() { - spec := &dbpreview.MonitoringSpec{Enabled: true} - Expect(ResolvePrometheusPort(spec)).To(Equal(int32(0))) + Expect(ResolvePrometheusPort(MonitoringConfig{Enabled: true})).To(Equal(int32(0))) }) It("returns default port when Port is 0", func() { - spec := &dbpreview.MonitoringSpec{ - Enabled: true, - Exporter: &dbpreview.ExporterSpec{ - Prometheus: &dbpreview.PrometheusExporterSpec{}, - }, - } - Expect(ResolvePrometheusPort(spec)).To(Equal(int32(8888))) + Expect(ResolvePrometheusPort(MonitoringConfig{Enabled: true, Prometheus: true})).To(Equal(int32(8888))) }) It("returns custom port when set", func() { - spec := &dbpreview.MonitoringSpec{ - Enabled: true, - Exporter: &dbpreview.ExporterSpec{ - Prometheus: &dbpreview.PrometheusExporterSpec{Port: 9090}, - }, - } - Expect(ResolvePrometheusPort(spec)).To(Equal(int32(9090))) + Expect(ResolvePrometheusPort(MonitoringConfig{Enabled: true, Prometheus: true, PrometheusPort: 9090})).To(Equal(int32(9090))) }) }) diff --git a/operator/src/internal/product/documentdb.go b/operator/src/internal/product/documentdb.go index 39a512c18..d6d01e837 100644 --- a/operator/src/internal/product/documentdb.go +++ b/operator/src/internal/product/documentdb.go @@ -7,6 +7,7 @@ import ( "os" dbpreview "github.com/documentdb/documentdb-operator/api/preview" + otelcfg "github.com/documentdb/documentdb-operator/internal/otel" util "github.com/documentdb/documentdb-operator/internal/utils" ) @@ -160,12 +161,10 @@ func (a DocumentDBAdapter) ToClusterIntent(db *dbpreview.DocumentDB) ClusterInte APIVersion: db.APIVersion, Kind: db.Kind, }, - Postgres: pg, - Resource: ResourceFromSpec(db.Spec.Resource), - TLS: tls, - Monitoring: Monitoring{ - Enabled: db.Spec.Monitoring != nil && db.Spec.Monitoring.Enabled, - }, + Postgres: pg, + Resource: ResourceFromSpec(db.Spec.Resource), + TLS: tls, + Monitoring: MonitoringConfigFromSpec(db.Spec.Monitoring), LogLevel: db.Spec.LogLevel, MaxStopDelay: maxStopDelay, FeatureGates: FeatureGates{ @@ -191,6 +190,27 @@ func ResourceFromSpec(res dbpreview.Resource) Resource { } } +// MonitoringConfigFromSpec maps the DocumentDB monitoring spec onto the +// product-neutral OTel collector config carried on the intent. It is the single +// CRD->neutral mapping shared by the builder and the controller's ConfigMap +// reconciliation, so generated collector config (and its hash) stays consistent. +func MonitoringConfigFromSpec(spec *dbpreview.MonitoringSpec) otelcfg.MonitoringConfig { + if spec == nil { + return otelcfg.MonitoringConfig{} + } + mc := otelcfg.MonitoringConfig{Enabled: spec.Enabled} + if spec.Exporter != nil { + if spec.Exporter.OTLP != nil { + mc.OTLPEndpoint = spec.Exporter.OTLP.Endpoint + } + if spec.Exporter.Prometheus != nil { + mc.Prometheus = true + mc.PrometheusPort = spec.Exporter.Prometheus.Port + } + } + return mc +} + func componentFromSpec(c *dbpreview.ComponentResources) *ComponentResource { if c == nil { return nil diff --git a/operator/src/internal/product/intent.go b/operator/src/internal/product/intent.go index 9e8ffe2ed..55b95bdde 100644 --- a/operator/src/internal/product/intent.go +++ b/operator/src/internal/product/intent.go @@ -7,6 +7,8 @@ import ( cnpgv1 "github.com/cloudnative-pg/cloudnative-pg/api/v1" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/types" + + otelcfg "github.com/documentdb/documentdb-operator/internal/otel" ) // Images holds the fully-resolved container images for a cluster. @@ -66,7 +68,8 @@ type Postgres struct { // FeatureGates carries the resolved, product-neutral feature-gate flags the // builder acts on. Only genuinely cross-product (infrastructure) gates belong // here; product-specific gates are expressed through their concrete effect (for -// example Postgres.ProtectedParameters) instead of leaking into this struct. +// example DocumentDB change streams contributing Postgres.Parameters +// wal_level=logical) instead of leaking into this struct. type FeatureGates struct { // IOUring relaxes the postgres seccomp profile and enables io_method=io_uring. // It is an infrastructure concern shared across products. @@ -102,12 +105,6 @@ type TLS struct { PostgresCertificates *cnpgv1.CertificatesConfiguration } -// Monitoring carries the resolved monitoring flags the builder acts on. The full -// OTel configuration is routed through the intent in a later phase. -type Monitoring struct { - Enabled bool -} - // Recovery describes a bootstrap-from-source request. A nil Recovery on Bootstrap // means default initialization. type Recovery struct { @@ -147,8 +144,9 @@ type ClusterIntent struct { // TLS carries the resolved TLS inputs (gateway secret + Postgres certificates). TLS TLS - // Monitoring carries the resolved monitoring flags. - Monitoring Monitoring + // Monitoring carries the resolved, product-neutral OTel collector config the + // builder renders (config map name/hash + Prometheus port) on the fly. + Monitoring otelcfg.MonitoringConfig // LogLevel is the desired CNPG log level (empty means the builder default). LogLevel string From 6e4cd828c772325180d9a489f599dc4bc82f343b Mon Sep 17 00:00:00 2001 From: Wenting Wu Date: Thu, 17 Sep 2026 13:10:47 -0400 Subject: [PATCH 3/9] refactor: fold render context onto ClusterIntent seam Make GetCnpgClusterSpecFromIntent fully self-contained by moving the remaining loose render inputs onto the neutral ClusterIntent: - Identity gains Namespace; ObjectMeta and the OTel config namespace now source name/namespace from intent.Identity instead of ctrl.Request. - Storage gains StorageClass (a reconcile/replication-context runtime input). - ClusterIntent gains IsPrimaryRegion (gates recovery bootstrap). The renderer signature drops req, serviceAccountName (already dead), storageClass, and isPrimaryRegion in favor of (intent, log). The DocumentDB adapter now sets Identity.Namespace; the GetCnpgClusterSpec wrapper and the controller populate StorageClass/IsPrimaryRegion before calling the seam. This is zero-behavior-change in production (req coordinates always equal the CR's own name/namespace); the drift guard proves the rendered Cluster is byte-identical, including the OTel config hash. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 03549680-3e40-4bea-8144-f0d0cfc6cb46 Signed-off-by: Wenting Wu --- operator/src/internal/cnpg/cnpg_cluster.go | 29 ++++++++++++------- .../src/internal/cnpg/cnpg_cluster_test.go | 4 +++ .../internal/cnpg/cnpg_intent_drift_test.go | 2 +- .../src/internal/cnpg/cnpg_intent_test.go | 11 +++++-- .../controller/documentdb_controller.go | 4 ++- operator/src/internal/product/documentdb.go | 1 + operator/src/internal/product/intent.go | 11 +++++++ 7 files changed, 47 insertions(+), 15 deletions(-) diff --git a/operator/src/internal/cnpg/cnpg_cluster.go b/operator/src/internal/cnpg/cnpg_cluster.go index 4912d1496..b6a47059a 100644 --- a/operator/src/internal/cnpg/cnpg_cluster.go +++ b/operator/src/internal/cnpg/cnpg_cluster.go @@ -27,18 +27,25 @@ import ( // use the image resolved from the instance. Retained for callers that supply an // explicit extension image. func GetCnpgClusterSpec(req ctrl.Request, documentdb *dbpreview.DocumentDB, documentdbImage, serviceAccountName, storageClass string, isPrimaryRegion bool, log logr.Logger) *cnpgv1.Cluster { + _ = req // object coordinates now come from the intent's Identity (adapter-derived) + _ = serviceAccountName // no longer consumed by the renderer; kept for call-site compatibility intent := product.DocumentDBAdapter{}.ToClusterIntent(documentdb) if documentdbImage != "" { intent.Images.PostgresExtension = documentdbImage } - return GetCnpgClusterSpecFromIntent(req, intent, serviceAccountName, storageClass, isPrimaryRegion, log) + // Storage class and region role are runtime inputs from the + // reconcile/replication context, not read from the CR. + intent.Storage.StorageClass = storageClass + intent.IsPrimaryRegion = isPrimaryRegion + return GetCnpgClusterSpecFromIntent(intent, log) } // GetCnpgClusterSpecFromIntent renders a CNPG Cluster from a product-neutral -// ClusterIntent. This is the seam the reconciler drives: product-varying values -// (images, credential secret, plugin name) come from the intent rather than from -// product-specific lookups. -func GetCnpgClusterSpecFromIntent(req ctrl.Request, intent product.ClusterIntent, serviceAccountName, storageClass string, isPrimaryRegion bool, log logr.Logger) *cnpgv1.Cluster { +// ClusterIntent. This is the seam the reconciler drives: every render input — +// including the object coordinates (Identity), storage class, and region role — +// comes from the intent rather than from loose parameters or product-specific +// lookups. +func GetCnpgClusterSpecFromIntent(intent product.ClusterIntent, log logr.Logger) *cnpgv1.Cluster { split := ComputeResourceSplitFromResource(intent.Resource, intent.Monitoring.Enabled, DefaultSplitConfig()) sidecarPluginName := intent.SidecarInjectorPlugin @@ -50,8 +57,8 @@ func GetCnpgClusterSpecFromIntent(req ctrl.Request, intent product.ClusterIntent // Configure storage class - use specified storage class or nil for default var storageClassPointer *string - if storageClass != "" { - storageClassPointer = &storageClass + if sc := intent.Storage.StorageClass; sc != "" { + storageClassPointer = &sc } // Set ImageVolumeSource.PullPolicy for the extension image when configured. @@ -65,8 +72,8 @@ func GetCnpgClusterSpecFromIntent(req ctrl.Request, intent product.ClusterIntent return &cnpgv1.Cluster{ ObjectMeta: metav1.ObjectMeta{ - Name: req.Name, - Namespace: req.Namespace, + Name: intent.Identity.Name, + Namespace: intent.Identity.Namespace, OwnerReferences: []metav1.OwnerReference{ { APIVersion: intent.Identity.APIVersion, @@ -121,7 +128,7 @@ func GetCnpgClusterSpecFromIntent(req ctrl.Request, intent product.ClusterIntent // Compute config hash for change detection. The operator triggers a // rolling restart (via restart annotation) when plugin parameters // change, ensuring pods pick up new config. - if configData, err := otelcfg.GenerateConfigMapData(intent.Identity.Name, req.Namespace, intent.Monitoring); err == nil { + if configData, err := otelcfg.GenerateConfigMapData(intent.Identity.Name, intent.Identity.Namespace, intent.Monitoring); err == nil { params["otelConfigHash"] = otelcfg.HashConfigMapData(configData) } else { log.Error(err, "Failed to generate OTel config hash; config changes may not trigger rolling restart") @@ -134,7 +141,7 @@ func GetCnpgClusterSpecFromIntent(req ctrl.Request, intent product.ClusterIntent }} }(), PostgresConfiguration: buildPostgresConfiguration(MergeParametersResolved(intent.Postgres.Parameters, intent.FeatureGates, split.PostgresMemoryBytes), extensionImageSource), - Bootstrap: bootstrapConfigurationFromIntent(intent, isPrimaryRegion, log), + Bootstrap: bootstrapConfigurationFromIntent(intent, intent.IsPrimaryRegion, log), LogLevel: cmp.Or(intent.LogLevel, "info"), Certificates: intent.TLS.PostgresCertificates, Backup: &cnpgv1.BackupConfiguration{ diff --git a/operator/src/internal/cnpg/cnpg_cluster_test.go b/operator/src/internal/cnpg/cnpg_cluster_test.go index 77dc2bb0a..92be93dd1 100644 --- a/operator/src/internal/cnpg/cnpg_cluster_test.go +++ b/operator/src/internal/cnpg/cnpg_cluster_test.go @@ -264,6 +264,10 @@ var _ = Describe("GetCnpgClusterSpec", func() { req.Namespace = "default" documentdb := &dbpreview.DocumentDB{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cluster", + Namespace: "default", + }, Spec: dbpreview.DocumentDBSpec{ InstancesPerNode: 3, Image: &dbpreview.ImageSpec{ diff --git a/operator/src/internal/cnpg/cnpg_intent_drift_test.go b/operator/src/internal/cnpg/cnpg_intent_drift_test.go index bdb5cc878..54daf6cee 100644 --- a/operator/src/internal/cnpg/cnpg_intent_drift_test.go +++ b/operator/src/internal/cnpg/cnpg_intent_drift_test.go @@ -204,7 +204,7 @@ func TestRenderIntentSeamNoDrift(t *testing.T) { if p := otelcfg.ResolvePrometheusPort(mon); p > 0 { wantPort = fmt.Sprintf("%d", p) } - if data, err := otelcfg.GenerateConfigMapData(db.Name, req.Namespace, mon); err == nil { + if data, err := otelcfg.GenerateConfigMapData(db.Name, db.Namespace, mon); err == nil { wantHash = otelcfg.HashConfigMapData(data) } } diff --git a/operator/src/internal/cnpg/cnpg_intent_test.go b/operator/src/internal/cnpg/cnpg_intent_test.go index 86c368803..44f98705b 100644 --- a/operator/src/internal/cnpg/cnpg_intent_test.go +++ b/operator/src/internal/cnpg/cnpg_intent_test.go @@ -44,7 +44,10 @@ var _ = Describe("GetCnpgClusterSpecFromIntent", func() { SidecarInjectorPlugin: "custom-injector.example.io", } - result := GetCnpgClusterSpecFromIntent(newRequest(), intent, "test-sa", "", true, log) + intent.Identity.Name = "test-cluster" + intent.Identity.Namespace = "default" + intent.IsPrimaryRegion = true + result := GetCnpgClusterSpecFromIntent(intent, log) Expect(result.Spec.PostgresConfiguration.Extensions[0].ImageVolumeSource.Reference).To(Equal("reg/ext:test")) Expect(result.Spec.Plugins[0].Name).To(Equal("custom-injector.example.io")) @@ -56,7 +59,11 @@ var _ = Describe("GetCnpgClusterSpecFromIntent", func() { documentdb := newDocumentDB() intent := product.DocumentDBAdapter{}.ToClusterIntent(documentdb) - fromIntent := GetCnpgClusterSpecFromIntent(newRequest(), intent, "test-sa", "", true, log) + intent.Identity.Name = "test-cluster" + intent.Identity.Namespace = "default" + intent.IsPrimaryRegion = true + + fromIntent := GetCnpgClusterSpecFromIntent(intent, log) fromWrapper := GetCnpgClusterSpec(newRequest(), documentdb, "", "test-sa", "", true, log) Expect(fromIntent.Spec.PostgresConfiguration.Extensions[0].ImageVolumeSource.Reference). diff --git a/operator/src/internal/controller/documentdb_controller.go b/operator/src/internal/controller/documentdb_controller.go index 1c5c04b5d..9e8319db1 100644 --- a/operator/src/internal/controller/documentdb_controller.go +++ b/operator/src/internal/controller/documentdb_controller.go @@ -153,9 +153,11 @@ func (r *DocumentDBReconciler) Reconcile(ctx context.Context, req ctrl.Request) // create the CNPG Cluster intent := product.DocumentDBAdapter{}.ToClusterIntent(documentdb) + intent.Storage.StorageClass = replicationContext.StorageClass + intent.IsPrimaryRegion = replicationContext.IsPrimary() currentCnpgCluster := &cnpgv1.Cluster{} - desiredCnpgCluster := cnpg.GetCnpgClusterSpecFromIntent(req, intent, documentdb.Name, replicationContext.StorageClass, replicationContext.IsPrimary(), logger) + desiredCnpgCluster := cnpg.GetCnpgClusterSpecFromIntent(intent, logger) if replicationContext.IsReplicating() { err = r.AddClusterReplicationToClusterSpec(ctx, documentdb, replicationContext, desiredCnpgCluster) diff --git a/operator/src/internal/product/documentdb.go b/operator/src/internal/product/documentdb.go index d6d01e837..84bb29a72 100644 --- a/operator/src/internal/product/documentdb.go +++ b/operator/src/internal/product/documentdb.go @@ -157,6 +157,7 @@ func (a DocumentDBAdapter) ToClusterIntent(db *dbpreview.DocumentDB) ClusterInte }, Identity: Identity{ Name: db.Name, + Namespace: db.Namespace, UID: db.UID, APIVersion: db.APIVersion, Kind: db.Kind, diff --git a/operator/src/internal/product/intent.go b/operator/src/internal/product/intent.go index 55b95bdde..64ca32d92 100644 --- a/operator/src/internal/product/intent.go +++ b/operator/src/internal/product/intent.go @@ -37,12 +37,18 @@ type Topology struct { type Storage struct { // PvcSize is the persistent volume claim size (for example "10Gi"). PvcSize string + + // StorageClass is the resolved storage class for the data volume. Empty + // means the cluster default. This is a runtime input the reconciler resolves + // (for example from the replication context), not a value read from the CR. + StorageClass string } // Identity carries the owning custom resource's identity for owner references // and resource labels. type Identity struct { Name string + Namespace string UID types.UID APIVersion string Kind string @@ -170,4 +176,9 @@ type ClusterIntent struct { // Product is the profile this intent was produced from. Product ProductProfile + + // IsPrimaryRegion indicates whether this render targets the primary region. + // It gates whether recovery bootstrap is applied. This is a runtime input the + // reconciler resolves (from the replication context), not read from the CR. + IsPrimaryRegion bool } From d8c91a9c451f2bba6f1ce2156e026292264f29fd Mon Sep 17 00:00:00 2001 From: Wenting Wu Date: Thu, 17 Sep 2026 13:48:39 -0400 Subject: [PATCH 4/9] refactor: group ClusterIntent fields to mirror CRD; add RenderContext Restructure the product-neutral ClusterIntent to follow the DocumentDB CRD grouping and vocabulary where the mapping is a direct passthrough: - MaxStopDelay -> Timeouts.StopDelay (new Timeouts struct) - SidecarInjectorPlugin/WALReplicaPlugin -> Plugins.SidecarInjectorName/ WalReplicaName (new Plugins struct) Move reconcile-time inputs off the intent into a narrow neutral RenderContext{StorageClass, IsPrimaryRegion}, since these are not part of any product's desired state. GetCnpgClusterSpecFromIntent now takes (intent, rctx, log). The adapter now honors db.Spec.Plugins.WalReplicaName as the single source of truth for the WAL replica plugin (falling back to the profile default); this is byte-identical today since the WAL replica wiring is a disabled TODO. Zero behavior change: TestRenderIntentSeamNoDrift stays green. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 03549680-3e40-4bea-8144-f0d0cfc6cb46 Signed-off-by: Wenting Wu --- operator/src/internal/cnpg/cnpg_cluster.go | 26 +++++----- .../src/internal/cnpg/cnpg_cluster_test.go | 2 +- .../src/internal/cnpg/cnpg_intent_test.go | 10 ++-- .../controller/documentdb_controller.go | 8 ++-- operator/src/internal/product/documentdb.go | 29 ++++++----- operator/src/internal/product/intent.go | 48 ++++++++++++------- operator/src/internal/product/intent_test.go | 12 ++--- 7 files changed, 79 insertions(+), 56 deletions(-) diff --git a/operator/src/internal/cnpg/cnpg_cluster.go b/operator/src/internal/cnpg/cnpg_cluster.go index b6a47059a..2d0333054 100644 --- a/operator/src/internal/cnpg/cnpg_cluster.go +++ b/operator/src/internal/cnpg/cnpg_cluster.go @@ -34,21 +34,21 @@ func GetCnpgClusterSpec(req ctrl.Request, documentdb *dbpreview.DocumentDB, docu intent.Images.PostgresExtension = documentdbImage } // Storage class and region role are runtime inputs from the - // reconcile/replication context, not read from the CR. - intent.Storage.StorageClass = storageClass - intent.IsPrimaryRegion = isPrimaryRegion - return GetCnpgClusterSpecFromIntent(intent, log) + // reconcile/replication context, not part of the product's desired state. + rctx := product.RenderContext{StorageClass: storageClass, IsPrimaryRegion: isPrimaryRegion} + return GetCnpgClusterSpecFromIntent(intent, rctx, log) } // GetCnpgClusterSpecFromIntent renders a CNPG Cluster from a product-neutral -// ClusterIntent. This is the seam the reconciler drives: every render input — -// including the object coordinates (Identity), storage class, and region role — -// comes from the intent rather than from loose parameters or product-specific -// lookups. -func GetCnpgClusterSpecFromIntent(intent product.ClusterIntent, log logr.Logger) *cnpgv1.Cluster { +// ClusterIntent plus a RenderContext. The intent carries the product's resolved +// desired state; the RenderContext carries reconcile-time inputs (storage class, +// region role) that are not part of any product's spec. This is the seam the +// reconciler drives: every render input comes from these two arguments rather +// than from loose parameters or product-specific lookups. +func GetCnpgClusterSpecFromIntent(intent product.ClusterIntent, rctx product.RenderContext, log logr.Logger) *cnpgv1.Cluster { split := ComputeResourceSplitFromResource(intent.Resource, intent.Monitoring.Enabled, DefaultSplitConfig()) - sidecarPluginName := intent.SidecarInjectorPlugin + sidecarPluginName := intent.Plugins.SidecarInjectorName gatewayImage := intent.Images.Gateway log.Info("Creating CNPG cluster with gateway image", "gatewayImage", gatewayImage, "documentdbName", intent.Identity.Name) @@ -57,7 +57,7 @@ func GetCnpgClusterSpecFromIntent(intent product.ClusterIntent, log logr.Logger) // Configure storage class - use specified storage class or nil for default var storageClassPointer *string - if sc := intent.Storage.StorageClass; sc != "" { + if sc := rctx.StorageClass; sc != "" { storageClassPointer = &sc } @@ -141,7 +141,7 @@ func GetCnpgClusterSpecFromIntent(intent product.ClusterIntent, log logr.Logger) }} }(), PostgresConfiguration: buildPostgresConfiguration(MergeParametersResolved(intent.Postgres.Parameters, intent.FeatureGates, split.PostgresMemoryBytes), extensionImageSource), - Bootstrap: bootstrapConfigurationFromIntent(intent, intent.IsPrimaryRegion, log), + Bootstrap: bootstrapConfigurationFromIntent(intent, rctx.IsPrimaryRegion, log), LogLevel: cmp.Or(intent.LogLevel, "info"), Certificates: intent.TLS.PostgresCertificates, Backup: &cnpgv1.BackupConfiguration{ @@ -153,7 +153,7 @@ func GetCnpgClusterSpecFromIntent(intent product.ClusterIntent, log logr.Logger) Affinity: intent.Topology.Affinity, Resources: buildResourceRequirements(split.Postgres), } - spec.MaxStopDelay = intent.MaxStopDelay + spec.MaxStopDelay = intent.Timeouts.StopDelay applyPostgresProcessIdentity(&spec, intent) applyIOUringSeccomp(&spec, intent) applyOtelMonitorRoleFromIntent(&spec, intent.Monitoring.Enabled) diff --git a/operator/src/internal/cnpg/cnpg_cluster_test.go b/operator/src/internal/cnpg/cnpg_cluster_test.go index 92be93dd1..7d86c2929 100644 --- a/operator/src/internal/cnpg/cnpg_cluster_test.go +++ b/operator/src/internal/cnpg/cnpg_cluster_test.go @@ -1132,7 +1132,7 @@ func TestMaxStopDelayFromIntent(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result := product.DocumentDBAdapter{}.ToClusterIntent(tt.documentdb).MaxStopDelay + result := product.DocumentDBAdapter{}.ToClusterIntent(tt.documentdb).Timeouts.StopDelay if result != tt.expected { t.Errorf("Expected %d, got %d", tt.expected, result) diff --git a/operator/src/internal/cnpg/cnpg_intent_test.go b/operator/src/internal/cnpg/cnpg_intent_test.go index 44f98705b..9921f3e05 100644 --- a/operator/src/internal/cnpg/cnpg_intent_test.go +++ b/operator/src/internal/cnpg/cnpg_intent_test.go @@ -40,14 +40,13 @@ var _ = Describe("GetCnpgClusterSpecFromIntent", func() { PostgresExtension: "reg/ext:test", Gateway: "reg/gw:test", }, - CredentialSecret: "custom-secret", - SidecarInjectorPlugin: "custom-injector.example.io", + CredentialSecret: "custom-secret", + Plugins: product.Plugins{SidecarInjectorName: "custom-injector.example.io"}, } intent.Identity.Name = "test-cluster" intent.Identity.Namespace = "default" - intent.IsPrimaryRegion = true - result := GetCnpgClusterSpecFromIntent(intent, log) + result := GetCnpgClusterSpecFromIntent(intent, product.RenderContext{IsPrimaryRegion: true}, log) Expect(result.Spec.PostgresConfiguration.Extensions[0].ImageVolumeSource.Reference).To(Equal("reg/ext:test")) Expect(result.Spec.Plugins[0].Name).To(Equal("custom-injector.example.io")) @@ -61,9 +60,8 @@ var _ = Describe("GetCnpgClusterSpecFromIntent", func() { intent.Identity.Name = "test-cluster" intent.Identity.Namespace = "default" - intent.IsPrimaryRegion = true - fromIntent := GetCnpgClusterSpecFromIntent(intent, log) + fromIntent := GetCnpgClusterSpecFromIntent(intent, product.RenderContext{IsPrimaryRegion: true}, log) fromWrapper := GetCnpgClusterSpec(newRequest(), documentdb, "", "test-sa", "", true, log) Expect(fromIntent.Spec.PostgresConfiguration.Extensions[0].ImageVolumeSource.Reference). diff --git a/operator/src/internal/controller/documentdb_controller.go b/operator/src/internal/controller/documentdb_controller.go index 9e8319db1..7dd5dc08d 100644 --- a/operator/src/internal/controller/documentdb_controller.go +++ b/operator/src/internal/controller/documentdb_controller.go @@ -153,11 +153,13 @@ func (r *DocumentDBReconciler) Reconcile(ctx context.Context, req ctrl.Request) // create the CNPG Cluster intent := product.DocumentDBAdapter{}.ToClusterIntent(documentdb) - intent.Storage.StorageClass = replicationContext.StorageClass - intent.IsPrimaryRegion = replicationContext.IsPrimary() + rctx := product.RenderContext{ + StorageClass: replicationContext.StorageClass, + IsPrimaryRegion: replicationContext.IsPrimary(), + } currentCnpgCluster := &cnpgv1.Cluster{} - desiredCnpgCluster := cnpg.GetCnpgClusterSpecFromIntent(intent, logger) + desiredCnpgCluster := cnpg.GetCnpgClusterSpecFromIntent(intent, rctx, logger) if replicationContext.IsReplicating() { err = r.AddClusterReplicationToClusterSpec(ctx, documentdb, replicationContext, desiredCnpgCluster) diff --git a/operator/src/internal/product/documentdb.go b/operator/src/internal/product/documentdb.go index 84bb29a72..d68bf0990 100644 --- a/operator/src/internal/product/documentdb.go +++ b/operator/src/internal/product/documentdb.go @@ -91,6 +91,11 @@ func (a DocumentDBAdapter) ToClusterIntent(db *dbpreview.DocumentDB) ClusterInte sidecarPlugin = db.Spec.Plugins.SidecarInjectorName } + walReplicaPlugin := p.WALReplicaPlugin + if db.Spec.Plugins != nil && db.Spec.Plugins.WalReplicaName != "" { + walReplicaPlugin = db.Spec.Plugins.WalReplicaName + } + var postgresImage string if db.Spec.Image != nil { postgresImage = db.Spec.Image.Postgres @@ -162,20 +167,22 @@ func (a DocumentDBAdapter) ToClusterIntent(db *dbpreview.DocumentDB) ClusterInte APIVersion: db.APIVersion, Kind: db.Kind, }, - Postgres: pg, - Resource: ResourceFromSpec(db.Spec.Resource), - TLS: tls, - Monitoring: MonitoringConfigFromSpec(db.Spec.Monitoring), - LogLevel: db.Spec.LogLevel, - MaxStopDelay: maxStopDelay, + Postgres: pg, + Resource: ResourceFromSpec(db.Spec.Resource), + TLS: tls, + Monitoring: MonitoringConfigFromSpec(db.Spec.Monitoring), + LogLevel: db.Spec.LogLevel, + Timeouts: Timeouts{StopDelay: maxStopDelay}, FeatureGates: FeatureGates{ IOUring: dbpreview.IsFeatureGateEnabled(db, dbpreview.FeatureGateIOUring), }, - Bootstrap: bootstrap, - CredentialSecret: credentialSecret, - SidecarInjectorPlugin: sidecarPlugin, - WALReplicaPlugin: p.WALReplicaPlugin, - Product: p, + Bootstrap: bootstrap, + CredentialSecret: credentialSecret, + Plugins: Plugins{ + SidecarInjectorName: sidecarPlugin, + WalReplicaName: walReplicaPlugin, + }, + Product: p, } } diff --git a/operator/src/internal/product/intent.go b/operator/src/internal/product/intent.go index 64ca32d92..77b7e475f 100644 --- a/operator/src/internal/product/intent.go +++ b/operator/src/internal/product/intent.go @@ -32,16 +32,11 @@ type Topology struct { } // Storage describes the persistent volume request. StorageClass is resolved by -// the controller from replication context and passed to the builder separately, -// so it is not part of this adapter-derived model yet. +// the controller from replication context and passed to the builder separately +// (via RenderContext), so it is not part of this adapter-derived model. type Storage struct { // PvcSize is the persistent volume claim size (for example "10Gi"). PvcSize string - - // StorageClass is the resolved storage class for the data volume. Empty - // means the cluster default. This is a runtime input the reconciler resolves - // (for example from the replication context), not a value read from the CR. - StorageClass string } // Identity carries the owning custom resource's identity for owner references @@ -157,8 +152,8 @@ type ClusterIntent struct { // LogLevel is the desired CNPG log level (empty means the builder default). LogLevel string - // MaxStopDelay is the resolved CNPG max stop delay (seconds), defaults applied. - MaxStopDelay int32 + // Timeouts carries the resolved CNPG process timeouts (defaults applied). + Timeouts Timeouts // FeatureGates are the resolved feature-gate flags. FeatureGates FeatureGates @@ -169,16 +164,37 @@ type ClusterIntent struct { // CredentialSecret is the resolved credential secret name. CredentialSecret string - // SidecarInjectorPlugin and WALReplicaPlugin are the resolved CNPG plugin - // names to wire onto the Cluster. - SidecarInjectorPlugin string - WALReplicaPlugin string + // Plugins carries the resolved CNPG plugin names to wire onto the Cluster. + Plugins Plugins // Product is the profile this intent was produced from. Product ProductProfile +} + +// Timeouts carries the resolved CNPG process timeouts. It mirrors the CRD's +// Timeouts grouping; values here are resolved (CNPG defaults applied). +type Timeouts struct { + // StopDelay is the resolved CNPG max stop delay in seconds. + StopDelay int32 +} + +// Plugins carries the resolved CNPG plugin names. It mirrors the CRD's Plugins +// grouping; values here are resolved (profile defaults applied, spec honored). +type Plugins struct { + // SidecarInjectorName is the CNPG sidecar injector plugin name. + SidecarInjectorName string + // WalReplicaName is the CNPG WAL replica plugin name used for cross-cluster + // replication. + WalReplicaName string +} - // IsPrimaryRegion indicates whether this render targets the primary region. - // It gates whether recovery bootstrap is applied. This is a runtime input the - // reconciler resolves (from the replication context), not read from the CR. +// RenderContext carries the runtime, reconcile-time inputs the builder needs +// that are NOT part of the product's desired state (and therefore not on the +// ClusterIntent). The reconciler resolves these from the replication context. +type RenderContext struct { + // StorageClass is the resolved data-volume storage class ("" = cluster default). + StorageClass string + // IsPrimaryRegion reports whether this render targets the primary region; it + // gates whether recovery bootstrap is applied. IsPrimaryRegion bool } diff --git a/operator/src/internal/product/intent_test.go b/operator/src/internal/product/intent_test.go index 77cc91cc7..b1a7ab0c6 100644 --- a/operator/src/internal/product/intent_test.go +++ b/operator/src/internal/product/intent_test.go @@ -32,11 +32,11 @@ func TestToClusterIntentDefaults(t *testing.T) { if intent.CredentialSecret != util.DEFAULT_DOCUMENTDB_CREDENTIALS_SECRET { t.Errorf("CredentialSecret = %q, want %q", intent.CredentialSecret, util.DEFAULT_DOCUMENTDB_CREDENTIALS_SECRET) } - if intent.SidecarInjectorPlugin != util.DEFAULT_SIDECAR_INJECTOR_PLUGIN { - t.Errorf("SidecarInjectorPlugin = %q, want %q", intent.SidecarInjectorPlugin, util.DEFAULT_SIDECAR_INJECTOR_PLUGIN) + if intent.Plugins.SidecarInjectorName != util.DEFAULT_SIDECAR_INJECTOR_PLUGIN { + t.Errorf("Plugins.SidecarInjectorName = %q, want %q", intent.Plugins.SidecarInjectorName, util.DEFAULT_SIDECAR_INJECTOR_PLUGIN) } - if intent.WALReplicaPlugin != util.DEFAULT_WAL_REPLICA_PLUGIN { - t.Errorf("WALReplicaPlugin = %q, want %q", intent.WALReplicaPlugin, util.DEFAULT_WAL_REPLICA_PLUGIN) + if intent.Plugins.WalReplicaName != util.DEFAULT_WAL_REPLICA_PLUGIN { + t.Errorf("Plugins.WalReplicaName = %q, want %q", intent.Plugins.WalReplicaName, util.DEFAULT_WAL_REPLICA_PLUGIN) } if intent.Product.Name != "DocumentDB" { t.Errorf("Product.Name = %q, want DocumentDB", intent.Product.Name) @@ -69,8 +69,8 @@ func TestToClusterIntentOverrides(t *testing.T) { if intent.CredentialSecret != "my-secret" { t.Errorf("CredentialSecret = %q, want my-secret", intent.CredentialSecret) } - if intent.SidecarInjectorPlugin != "custom-injector.example.io" { - t.Errorf("SidecarInjectorPlugin = %q, want custom-injector.example.io", intent.SidecarInjectorPlugin) + if intent.Plugins.SidecarInjectorName != "custom-injector.example.io" { + t.Errorf("Plugins.SidecarInjectorName = %q, want custom-injector.example.io", intent.Plugins.SidecarInjectorName) } } From d61c0ca392b6113429590f3bffe1285840213532 Mon Sep 17 00:00:00 2001 From: Wenting Wu Date: Thu, 17 Sep 2026 14:00:07 -0400 Subject: [PATCH 5/9] refactor: pass storageClass/isPrimaryRegion as params instead of RenderContext Drop the RenderContext struct and pass the two reconcile-time inputs directly to GetCnpgClusterSpecFromIntent(intent, storageClass, isPrimaryRegion, log). Zero behavior change: TestRenderIntentSeamNoDrift stays green. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 03549680-3e40-4bea-8144-f0d0cfc6cb46 Signed-off-by: Wenting Wu --- operator/src/internal/cnpg/cnpg_cluster.go | 18 ++++++++---------- operator/src/internal/cnpg/cnpg_intent_test.go | 4 ++-- .../controller/documentdb_controller.go | 6 +----- operator/src/internal/product/intent.go | 13 +------------ 4 files changed, 12 insertions(+), 29 deletions(-) diff --git a/operator/src/internal/cnpg/cnpg_cluster.go b/operator/src/internal/cnpg/cnpg_cluster.go index 2d0333054..b69b1792e 100644 --- a/operator/src/internal/cnpg/cnpg_cluster.go +++ b/operator/src/internal/cnpg/cnpg_cluster.go @@ -35,17 +35,15 @@ func GetCnpgClusterSpec(req ctrl.Request, documentdb *dbpreview.DocumentDB, docu } // Storage class and region role are runtime inputs from the // reconcile/replication context, not part of the product's desired state. - rctx := product.RenderContext{StorageClass: storageClass, IsPrimaryRegion: isPrimaryRegion} - return GetCnpgClusterSpecFromIntent(intent, rctx, log) + return GetCnpgClusterSpecFromIntent(intent, storageClass, isPrimaryRegion, log) } // GetCnpgClusterSpecFromIntent renders a CNPG Cluster from a product-neutral -// ClusterIntent plus a RenderContext. The intent carries the product's resolved -// desired state; the RenderContext carries reconcile-time inputs (storage class, -// region role) that are not part of any product's spec. This is the seam the -// reconciler drives: every render input comes from these two arguments rather -// than from loose parameters or product-specific lookups. -func GetCnpgClusterSpecFromIntent(intent product.ClusterIntent, rctx product.RenderContext, log logr.Logger) *cnpgv1.Cluster { +// ClusterIntent plus reconcile-time inputs (storage class, region role) that +// are not part of any product's spec. This is the seam the reconciler drives: +// every render input comes from these arguments rather than from loose +// parameters or product-specific lookups. +func GetCnpgClusterSpecFromIntent(intent product.ClusterIntent, storageClass string, isPrimaryRegion bool, log logr.Logger) *cnpgv1.Cluster { split := ComputeResourceSplitFromResource(intent.Resource, intent.Monitoring.Enabled, DefaultSplitConfig()) sidecarPluginName := intent.Plugins.SidecarInjectorName @@ -57,7 +55,7 @@ func GetCnpgClusterSpecFromIntent(intent product.ClusterIntent, rctx product.Ren // Configure storage class - use specified storage class or nil for default var storageClassPointer *string - if sc := rctx.StorageClass; sc != "" { + if sc := storageClass; sc != "" { storageClassPointer = &sc } @@ -141,7 +139,7 @@ func GetCnpgClusterSpecFromIntent(intent product.ClusterIntent, rctx product.Ren }} }(), PostgresConfiguration: buildPostgresConfiguration(MergeParametersResolved(intent.Postgres.Parameters, intent.FeatureGates, split.PostgresMemoryBytes), extensionImageSource), - Bootstrap: bootstrapConfigurationFromIntent(intent, rctx.IsPrimaryRegion, log), + Bootstrap: bootstrapConfigurationFromIntent(intent, isPrimaryRegion, log), LogLevel: cmp.Or(intent.LogLevel, "info"), Certificates: intent.TLS.PostgresCertificates, Backup: &cnpgv1.BackupConfiguration{ diff --git a/operator/src/internal/cnpg/cnpg_intent_test.go b/operator/src/internal/cnpg/cnpg_intent_test.go index 9921f3e05..28244fe62 100644 --- a/operator/src/internal/cnpg/cnpg_intent_test.go +++ b/operator/src/internal/cnpg/cnpg_intent_test.go @@ -46,7 +46,7 @@ var _ = Describe("GetCnpgClusterSpecFromIntent", func() { intent.Identity.Name = "test-cluster" intent.Identity.Namespace = "default" - result := GetCnpgClusterSpecFromIntent(intent, product.RenderContext{IsPrimaryRegion: true}, log) + result := GetCnpgClusterSpecFromIntent(intent, "", true, log) Expect(result.Spec.PostgresConfiguration.Extensions[0].ImageVolumeSource.Reference).To(Equal("reg/ext:test")) Expect(result.Spec.Plugins[0].Name).To(Equal("custom-injector.example.io")) @@ -61,7 +61,7 @@ var _ = Describe("GetCnpgClusterSpecFromIntent", func() { intent.Identity.Name = "test-cluster" intent.Identity.Namespace = "default" - fromIntent := GetCnpgClusterSpecFromIntent(intent, product.RenderContext{IsPrimaryRegion: true}, log) + fromIntent := GetCnpgClusterSpecFromIntent(intent, "", true, log) fromWrapper := GetCnpgClusterSpec(newRequest(), documentdb, "", "test-sa", "", true, log) Expect(fromIntent.Spec.PostgresConfiguration.Extensions[0].ImageVolumeSource.Reference). diff --git a/operator/src/internal/controller/documentdb_controller.go b/operator/src/internal/controller/documentdb_controller.go index 7dd5dc08d..511a0bfdd 100644 --- a/operator/src/internal/controller/documentdb_controller.go +++ b/operator/src/internal/controller/documentdb_controller.go @@ -153,13 +153,9 @@ func (r *DocumentDBReconciler) Reconcile(ctx context.Context, req ctrl.Request) // create the CNPG Cluster intent := product.DocumentDBAdapter{}.ToClusterIntent(documentdb) - rctx := product.RenderContext{ - StorageClass: replicationContext.StorageClass, - IsPrimaryRegion: replicationContext.IsPrimary(), - } currentCnpgCluster := &cnpgv1.Cluster{} - desiredCnpgCluster := cnpg.GetCnpgClusterSpecFromIntent(intent, rctx, logger) + desiredCnpgCluster := cnpg.GetCnpgClusterSpecFromIntent(intent, replicationContext.StorageClass, replicationContext.IsPrimary(), logger) if replicationContext.IsReplicating() { err = r.AddClusterReplicationToClusterSpec(ctx, documentdb, replicationContext, desiredCnpgCluster) diff --git a/operator/src/internal/product/intent.go b/operator/src/internal/product/intent.go index 77b7e475f..7dc3f3459 100644 --- a/operator/src/internal/product/intent.go +++ b/operator/src/internal/product/intent.go @@ -33,7 +33,7 @@ type Topology struct { // Storage describes the persistent volume request. StorageClass is resolved by // the controller from replication context and passed to the builder separately -// (via RenderContext), so it is not part of this adapter-derived model. +// (as a render parameter), so it is not part of this adapter-derived model. type Storage struct { // PvcSize is the persistent volume claim size (for example "10Gi"). PvcSize string @@ -187,14 +187,3 @@ type Plugins struct { // replication. WalReplicaName string } - -// RenderContext carries the runtime, reconcile-time inputs the builder needs -// that are NOT part of the product's desired state (and therefore not on the -// ClusterIntent). The reconciler resolves these from the replication context. -type RenderContext struct { - // StorageClass is the resolved data-volume storage class ("" = cluster default). - StorageClass string - // IsPrimaryRegion reports whether this render targets the primary region; it - // gates whether recovery bootstrap is applied. - IsPrimaryRegion bool -} From e5c91c34a4e34b98bc7494628bac9c12dc03ff5c Mon Sep 17 00:00:00 2001 From: Wenting Wu Date: Thu, 17 Sep 2026 14:34:46 -0400 Subject: [PATCH 6/9] refactor: tidy ClusterIntent seam comments and dead alias - restore the orphaned ComponentResource doc comment - drop a redundant wrapper comment already covered by the function doc - remove a pointless local alias when taking storageClass's address No behavior change; TestRenderIntentSeamNoDrift stays green. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 03549680-3e40-4bea-8144-f0d0cfc6cb46 Signed-off-by: Wenting Wu --- operator/src/internal/cnpg/cnpg_cluster.go | 6 ++---- operator/src/internal/product/intent.go | 3 ++- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/operator/src/internal/cnpg/cnpg_cluster.go b/operator/src/internal/cnpg/cnpg_cluster.go index b69b1792e..b47c07ca7 100644 --- a/operator/src/internal/cnpg/cnpg_cluster.go +++ b/operator/src/internal/cnpg/cnpg_cluster.go @@ -33,8 +33,6 @@ func GetCnpgClusterSpec(req ctrl.Request, documentdb *dbpreview.DocumentDB, docu if documentdbImage != "" { intent.Images.PostgresExtension = documentdbImage } - // Storage class and region role are runtime inputs from the - // reconcile/replication context, not part of the product's desired state. return GetCnpgClusterSpecFromIntent(intent, storageClass, isPrimaryRegion, log) } @@ -55,8 +53,8 @@ func GetCnpgClusterSpecFromIntent(intent product.ClusterIntent, storageClass str // Configure storage class - use specified storage class or nil for default var storageClassPointer *string - if sc := storageClass; sc != "" { - storageClassPointer = &sc + if storageClass != "" { + storageClassPointer = &storageClass } // Set ImageVolumeSource.PullPolicy for the extension image when configured. diff --git a/operator/src/internal/product/intent.go b/operator/src/internal/product/intent.go index 7dc3f3459..08d8ea124 100644 --- a/operator/src/internal/product/intent.go +++ b/operator/src/internal/product/intent.go @@ -77,7 +77,8 @@ type FeatureGates struct { IOUring bool } -// (request==limit when set). Empty strings mean "unset". +// ComponentResource is a per-container resource override (request==limit when +// set). Empty strings mean "unset". type ComponentResource struct { Memory string CPU string From 88f76aa3188cf52fcfd99d1ce5d0f317fa310f84 Mon Sep 17 00:00:00 2001 From: Wenting Wu Date: Thu, 17 Sep 2026 14:37:16 -0400 Subject: [PATCH 7/9] refactor: drop unnecessary blank-assignments for unused params Go does not require unused function parameters to be discarded (only unused locals). Remove `_ = req` / `_ = serviceAccountName` and fold the rationale into the function doc comment. No behavior change. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 03549680-3e40-4bea-8144-f0d0cfc6cb46 Signed-off-by: Wenting Wu --- operator/src/internal/cnpg/cnpg_cluster.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/operator/src/internal/cnpg/cnpg_cluster.go b/operator/src/internal/cnpg/cnpg_cluster.go index b47c07ca7..3d854793b 100644 --- a/operator/src/internal/cnpg/cnpg_cluster.go +++ b/operator/src/internal/cnpg/cnpg_cluster.go @@ -24,11 +24,11 @@ import ( // GetCnpgClusterSpec renders the Cluster for a DocumentDB instance. The // documentdbImage argument overrides the resolved extension image; pass "" to -// use the image resolved from the instance. Retained for callers that supply an -// explicit extension image. +// use the image resolved from the instance. req and serviceAccountName are +// accepted for call-site compatibility but no longer consumed: object +// coordinates now come from the intent's Identity, and the service account is +// not read by the renderer. func GetCnpgClusterSpec(req ctrl.Request, documentdb *dbpreview.DocumentDB, documentdbImage, serviceAccountName, storageClass string, isPrimaryRegion bool, log logr.Logger) *cnpgv1.Cluster { - _ = req // object coordinates now come from the intent's Identity (adapter-derived) - _ = serviceAccountName // no longer consumed by the renderer; kept for call-site compatibility intent := product.DocumentDBAdapter{}.ToClusterIntent(documentdb) if documentdbImage != "" { intent.Images.PostgresExtension = documentdbImage From b30f24645c584a000034b8f3427014e2bd220571 Mon Sep 17 00:00:00 2001 From: Wenting Wu Date: Fri, 18 Sep 2026 13:22:37 -0400 Subject: [PATCH 8/9] refactor: add NodeCount and rename Topology.Instances to InstancesPerNode Mirror the DocumentDB CR's topology vocabulary on the intent: carry NodeCount and rename the per-node instance count to InstancesPerNode. NodeCount is carried for completeness but not yet rendered (no active reader today), so the CNPG output is unchanged. No behavior change; TestRenderIntentSeamNoDrift stays green. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 03549680-3e40-4bea-8144-f0d0cfc6cb46 Signed-off-by: Wenting Wu --- operator/src/internal/cnpg/cnpg_cluster.go | 2 +- operator/src/internal/product/documentdb.go | 5 +++-- operator/src/internal/product/intent.go | 6 ++++-- operator/src/internal/product/intent_test.go | 4 ++-- 4 files changed, 10 insertions(+), 7 deletions(-) diff --git a/operator/src/internal/cnpg/cnpg_cluster.go b/operator/src/internal/cnpg/cnpg_cluster.go index 3d854793b..d4d611f42 100644 --- a/operator/src/internal/cnpg/cnpg_cluster.go +++ b/operator/src/internal/cnpg/cnpg_cluster.go @@ -83,7 +83,7 @@ func GetCnpgClusterSpecFromIntent(intent product.ClusterIntent, storageClass str }, Spec: func() cnpgv1.ClusterSpec { spec := cnpgv1.ClusterSpec{ - Instances: intent.Topology.Instances, + Instances: intent.Topology.InstancesPerNode, ImageName: intent.Images.Postgres, ImagePullSecrets: toCNPGImagePullSecrets(intent.Images.PullSecrets), PrimaryUpdateMethod: cnpgv1.PrimaryUpdateMethodSwitchover, diff --git a/operator/src/internal/product/documentdb.go b/operator/src/internal/product/documentdb.go index d68bf0990..6c73b4e9e 100644 --- a/operator/src/internal/product/documentdb.go +++ b/operator/src/internal/product/documentdb.go @@ -154,8 +154,9 @@ func (a DocumentDBAdapter) ToClusterIntent(db *dbpreview.DocumentDB) ClusterInte PullSecrets: db.Spec.ImagePullSecrets, }, Topology: Topology{ - Instances: db.Spec.InstancesPerNode, - Affinity: db.Spec.Affinity, + NodeCount: db.Spec.NodeCount, + InstancesPerNode: db.Spec.InstancesPerNode, + Affinity: db.Spec.Affinity, }, Storage: Storage{ PvcSize: db.Spec.Resource.Storage.PvcSize, diff --git a/operator/src/internal/product/intent.go b/operator/src/internal/product/intent.go index 08d8ea124..009e0a1d1 100644 --- a/operator/src/internal/product/intent.go +++ b/operator/src/internal/product/intent.go @@ -25,8 +25,10 @@ type Images struct { // Topology describes the cluster shape and scheduling. type Topology struct { - // Instances is the number of PostgreSQL instances in the cluster. - Instances int + // NodeCount is the number of nodes (shards) in the cluster. + NodeCount int + // InstancesPerNode is the number of PostgreSQL instances per node. + InstancesPerNode int // Affinity is the CNPG affinity/anti-affinity passthrough. Affinity cnpgv1.AffinityConfiguration } diff --git a/operator/src/internal/product/intent_test.go b/operator/src/internal/product/intent_test.go index b1a7ab0c6..50f3ea060 100644 --- a/operator/src/internal/product/intent_test.go +++ b/operator/src/internal/product/intent_test.go @@ -90,8 +90,8 @@ func TestToClusterIntentTopologyStorageIdentity(t *testing.T) { intent := a.ToClusterIntent(db) - if intent.Topology.Instances != 3 { - t.Errorf("Topology.Instances = %d, want 3", intent.Topology.Instances) + if intent.Topology.InstancesPerNode != 3 { + t.Errorf("Topology.InstancesPerNode = %d, want 3", intent.Topology.InstancesPerNode) } if intent.Storage.PvcSize != "20Gi" { t.Errorf("Storage.PvcSize = %q, want 20Gi", intent.Storage.PvcSize) From 1802f797cb274deddf9776a5fc45106dd5a982d3 Mon Sep 17 00:00:00 2001 From: Wenting Wu Date: Tue, 22 Sep 2026 16:09:50 -0400 Subject: [PATCH 9/9] refactor: conciseness pass on ClusterIntent render seam Address reviewer feedback requesting reduced ceremony without changing rendered output (verified byte-identical to the pre-cleanup commit via the new golden fixtures): - Replace the 243-line field-by-field drift test with a compact full-object golden test (cnpg_render_golden_test.go) that renders the whole CNPG Cluster for the same spec matrix and diffs it against testdata/render fixtures. Shorter test code, stronger regression guard. - Remove the test-only *DocumentDB wrappers ComputeResourceSplit and MergeParameters, leaving a single neutral API (ComputeResourceSplitFromResource / MergeParametersResolved). Test call sites use small local helpers. - Drop process-narration comments ("first product adapter", "progressively rewired onto the seam", "retained for direct callers"); keep invariant docs. go.mod: promote go-cmp and sigs.k8s.io/yaml from indirect to direct (used by the golden test); no new dependencies. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 03549680-3e40-4bea-8144-f0d0cfc6cb46 Signed-off-by: Wenting Wu --- operator/src/go.mod | 4 +- operator/src/internal/cnpg/cnpg_cluster.go | 8 +- .../internal/cnpg/cnpg_intent_drift_test.go | 243 ------------------ .../internal/cnpg/cnpg_render_golden_test.go | 173 +++++++++++++ operator/src/internal/cnpg/pg_defaults.go | 20 +- .../src/internal/cnpg/pg_defaults_test.go | 20 +- operator/src/internal/cnpg/resource_split.go | 19 +- .../src/internal/cnpg/resource_split_test.go | 24 +- .../custom-loglevel-and-stopdelay.golden.yaml | 92 +++++++ .../render/gateway-tls-ready.golden.yaml | 93 +++++++ .../iouring-and-changestreams.golden.yaml | 97 +++++++ .../cnpg/testdata/render/minimal.golden.yaml | 92 +++++++ ...monitoring-otlp-and-prometheus.golden.yaml | 104 ++++++++ .../render/monitoring-prometheus.golden.yaml | 104 ++++++++ .../testdata/render/postgres-tls.golden.yaml | 95 +++++++ .../render/process-identity.golden.yaml | 94 +++++++ .../render/resource-envelope.golden.yaml | 100 +++++++ .../render/resource-overrides.golden.yaml | 102 ++++++++ .../testdata/render/user-params.golden.yaml | 92 +++++++ operator/src/internal/product/documentdb.go | 4 +- operator/src/internal/product/intent.go | 3 +- 21 files changed, 1286 insertions(+), 297 deletions(-) delete mode 100644 operator/src/internal/cnpg/cnpg_intent_drift_test.go create mode 100644 operator/src/internal/cnpg/cnpg_render_golden_test.go create mode 100644 operator/src/internal/cnpg/testdata/render/custom-loglevel-and-stopdelay.golden.yaml create mode 100644 operator/src/internal/cnpg/testdata/render/gateway-tls-ready.golden.yaml create mode 100644 operator/src/internal/cnpg/testdata/render/iouring-and-changestreams.golden.yaml create mode 100644 operator/src/internal/cnpg/testdata/render/minimal.golden.yaml create mode 100644 operator/src/internal/cnpg/testdata/render/monitoring-otlp-and-prometheus.golden.yaml create mode 100644 operator/src/internal/cnpg/testdata/render/monitoring-prometheus.golden.yaml create mode 100644 operator/src/internal/cnpg/testdata/render/postgres-tls.golden.yaml create mode 100644 operator/src/internal/cnpg/testdata/render/process-identity.golden.yaml create mode 100644 operator/src/internal/cnpg/testdata/render/resource-envelope.golden.yaml create mode 100644 operator/src/internal/cnpg/testdata/render/resource-overrides.golden.yaml create mode 100644 operator/src/internal/cnpg/testdata/render/user-params.golden.yaml diff --git a/operator/src/go.mod b/operator/src/go.mod index 8156141fc..5dbcc9faa 100644 --- a/operator/src/go.mod +++ b/operator/src/go.mod @@ -68,7 +68,7 @@ require ( github.com/go-task/slim-sprig/v3 v3.0.0 // indirect github.com/google/cel-go v0.26.0 // indirect github.com/google/gnostic-models v0.7.1 // indirect - github.com/google/go-cmp v0.7.0 // indirect + github.com/google/go-cmp v0.7.0 github.com/google/pprof v0.0.0-20260402051712-545e8a4df936 // indirect github.com/google/uuid v1.6.0 // indirect github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect @@ -126,5 +126,5 @@ require ( sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.34.0 // indirect sigs.k8s.io/gateway-api v1.4.0 // indirect sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect - sigs.k8s.io/yaml v1.6.0 // indirect + sigs.k8s.io/yaml v1.6.0 ) diff --git a/operator/src/internal/cnpg/cnpg_cluster.go b/operator/src/internal/cnpg/cnpg_cluster.go index d4d611f42..3ba97650d 100644 --- a/operator/src/internal/cnpg/cnpg_cluster.go +++ b/operator/src/internal/cnpg/cnpg_cluster.go @@ -212,8 +212,8 @@ func bootstrapConfigurationFromIntent(intent product.ClusterIntent, isPrimaryReg return defaultBootstrapConfigurationFromIntent(intent) } -// getBootstrapConfiguration adapts a DocumentDB instance onto the intent-based -// bootstrap builder. Retained for direct callers that hold the custom resource. +// getBootstrapConfiguration builds the bootstrap configuration for a DocumentDB +// instance via the intent-based builder. func getBootstrapConfiguration(documentdb *dbpreview.DocumentDB, isPrimaryRegion bool, log logr.Logger) *cnpgv1.BootstrapConfiguration { return bootstrapConfigurationFromIntent(product.DocumentDBAdapter{}.ToClusterIntent(documentdb), isPrimaryRegion, log) } @@ -234,8 +234,8 @@ func defaultBootstrapConfigurationFromIntent(intent product.ClusterIntent) *cnpg } } -// getDefaultBootstrapConfiguration adapts a DocumentDB instance onto the -// intent-based default bootstrap builder. Retained for direct callers. +// getDefaultBootstrapConfiguration builds the default bootstrap configuration for +// a DocumentDB instance via the intent-based builder. func getDefaultBootstrapConfiguration(documentdb *dbpreview.DocumentDB) *cnpgv1.BootstrapConfiguration { return defaultBootstrapConfigurationFromIntent(product.DocumentDBAdapter{}.ToClusterIntent(documentdb)) } diff --git a/operator/src/internal/cnpg/cnpg_intent_drift_test.go b/operator/src/internal/cnpg/cnpg_intent_drift_test.go deleted file mode 100644 index 54daf6cee..000000000 --- a/operator/src/internal/cnpg/cnpg_intent_drift_test.go +++ /dev/null @@ -1,243 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -package cnpg - -import ( - "cmp" - "fmt" - "reflect" - "testing" - - cnpgv1 "github.com/cloudnative-pg/cloudnative-pg/api/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - ctrl "sigs.k8s.io/controller-runtime" - "sigs.k8s.io/controller-runtime/pkg/log/zap" - - dbpreview "github.com/documentdb/documentdb-operator/api/preview" - otelcfg "github.com/documentdb/documentdb-operator/internal/otel" - "github.com/documentdb/documentdb-operator/internal/product" - util "github.com/documentdb/documentdb-operator/internal/utils" -) - -var cnpgCertsForTest = cnpgv1.CertificatesConfiguration{ServerCASecret: "pg-ca", ServerTLSSecret: "pg-tls"} - -// TestRenderIntentSeamNoDrift is the Phase 0 drift guard: it proves that routing -// the builder inputs through ClusterIntent produces exactly the same rendered -// Cluster as the retained *DocumentDB computations, across a spec matrix. -func TestRenderIntentSeamNoDrift(t *testing.T) { - log := zap.New() - req := ctrl.Request{} - req.Name = "drift" - req.Namespace = "default" - - ptr := func(v int64) *int64 { return &v } - - cases := map[string]*dbpreview.DocumentDB{ - "minimal": { - Spec: dbpreview.DocumentDBSpec{ - InstancesPerNode: 1, - Resource: dbpreview.Resource{Storage: dbpreview.StorageConfiguration{PvcSize: "10Gi"}}, - }, - }, - "custom-loglevel-and-stopdelay": { - Spec: dbpreview.DocumentDBSpec{ - InstancesPerNode: 1, - LogLevel: "debug", - Timeouts: dbpreview.Timeouts{StopDelay: 120}, - Resource: dbpreview.Resource{Storage: dbpreview.StorageConfiguration{PvcSize: "10Gi"}}, - }, - }, - "user-params": { - Spec: dbpreview.DocumentDBSpec{ - InstancesPerNode: 1, - Resource: dbpreview.Resource{Storage: dbpreview.StorageConfiguration{PvcSize: "10Gi"}}, - Postgres: &dbpreview.PostgresSpec{ - Parameters: map[string]string{"work_mem": "64MB", "max_connections": "200"}, - }, - }, - }, - "resource-envelope": { - Spec: dbpreview.DocumentDBSpec{ - InstancesPerNode: 1, - Resource: dbpreview.Resource{ - Storage: dbpreview.StorageConfiguration{PvcSize: "10Gi"}, - Memory: "8Gi", - CPU: "4", - }, - }, - }, - "resource-overrides": { - Spec: dbpreview.DocumentDBSpec{ - InstancesPerNode: 1, - Resource: dbpreview.Resource{ - Storage: dbpreview.StorageConfiguration{PvcSize: "10Gi"}, - Gateway: &dbpreview.ComponentResources{Memory: "512Mi", CPU: "500m"}, - Database: &dbpreview.ComponentResources{Memory: "4Gi", CPU: "2"}, - }, - }, - }, - "process-identity": { - Spec: dbpreview.DocumentDBSpec{ - InstancesPerNode: 1, - Resource: dbpreview.Resource{Storage: dbpreview.StorageConfiguration{PvcSize: "10Gi"}}, - Postgres: &dbpreview.PostgresSpec{UID: ptr(26), GID: ptr(26)}, - }, - }, - "iouring-and-changestreams": { - Spec: dbpreview.DocumentDBSpec{ - InstancesPerNode: 1, - Resource: dbpreview.Resource{Storage: dbpreview.StorageConfiguration{PvcSize: "10Gi"}}, - FeatureGates: map[string]bool{ - string(dbpreview.FeatureGateIOUring): true, - string(dbpreview.FeatureGateChangeStreams): true, - }, - }, - }, - "postgres-tls": { - Spec: dbpreview.DocumentDBSpec{ - InstancesPerNode: 1, - Resource: dbpreview.Resource{Storage: dbpreview.StorageConfiguration{PvcSize: "10Gi"}}, - TLS: &dbpreview.TLSConfiguration{ - Postgres: &cnpgCertsForTest, - }, - }, - }, - "gateway-tls-ready": { - Spec: dbpreview.DocumentDBSpec{ - InstancesPerNode: 1, - Resource: dbpreview.Resource{Storage: dbpreview.StorageConfiguration{PvcSize: "10Gi"}}, - }, - Status: dbpreview.DocumentDBStatus{ - TLS: &dbpreview.TLSStatus{Ready: true, SecretName: "gw-tls-secret"}, - }, - }, - "monitoring-prometheus": { - ObjectMeta: metav1.ObjectMeta{Name: "mon-prom"}, - Spec: dbpreview.DocumentDBSpec{ - InstancesPerNode: 1, - Resource: dbpreview.Resource{Storage: dbpreview.StorageConfiguration{PvcSize: "10Gi"}}, - Monitoring: &dbpreview.MonitoringSpec{ - Enabled: true, - Exporter: &dbpreview.ExporterSpec{ - Prometheus: &dbpreview.PrometheusExporterSpec{Port: 9090}, - }, - }, - }, - }, - "monitoring-otlp-and-prometheus": { - ObjectMeta: metav1.ObjectMeta{Name: "mon-both"}, - Spec: dbpreview.DocumentDBSpec{ - InstancesPerNode: 1, - Resource: dbpreview.Resource{Storage: dbpreview.StorageConfiguration{PvcSize: "10Gi"}}, - Monitoring: &dbpreview.MonitoringSpec{ - Enabled: true, - Exporter: &dbpreview.ExporterSpec{ - OTLP: &dbpreview.OTLPExporterSpec{Endpoint: "otel-collector:4317"}, - Prometheus: &dbpreview.PrometheusExporterSpec{}, - }, - }, - }, - }, - } - - for name, db := range cases { - t.Run(name, func(t *testing.T) { - spec := GetCnpgClusterSpec(req, db, "", "test-sa", "", true, log).Spec - - // Parameters: intent path must equal the direct MergeParameters over the - // same memory-aware split. - wantMem := ComputeResourceSplit(db, DefaultSplitConfig()).PostgresMemoryBytes - wantParams := MergeParameters(db, wantMem) - if !reflect.DeepEqual(spec.PostgresConfiguration.Parameters, wantParams) { - t.Errorf("parameters drift:\n got %v\n want %v", spec.PostgresConfiguration.Parameters, wantParams) - } - - // LogLevel - wantLog := cmp.Or(db.Spec.LogLevel, "info") - if spec.LogLevel != wantLog { - t.Errorf("logLevel drift: got %q want %q", spec.LogLevel, wantLog) - } - - // MaxStopDelay - wantStop := int32(util.CNPG_DEFAULT_STOP_DELAY) - if db.Spec.Timeouts.StopDelay != 0 { - wantStop = db.Spec.Timeouts.StopDelay - } - if spec.MaxStopDelay != wantStop { - t.Errorf("maxStopDelay drift: got %d want %d", spec.MaxStopDelay, wantStop) - } - - // Postgres certificates - var wantCerts interface{} - if db.Spec.TLS != nil { - wantCerts = db.Spec.TLS.Postgres - } - if !reflect.DeepEqual(spec.Certificates, wantCerts) && !(spec.Certificates == nil && wantCerts == nil) { - t.Errorf("certificates drift: got %v want %v", spec.Certificates, wantCerts) - } - - // Gateway TLS secret plugin param - gotTLS := spec.Plugins[0].Parameters["gatewayTLSSecret"] - wantTLS := "" - if db.Status.TLS != nil && db.Status.TLS.Ready && db.Status.TLS.SecretName != "" { - wantTLS = db.Status.TLS.SecretName - } - if gotTLS != wantTLS { - t.Errorf("gatewayTLSSecret drift: got %q want %q", gotTLS, wantTLS) - } - - // Gateway resource params reflect the resource split. - split := ComputeResourceSplit(db, DefaultSplitConfig()) - assertParamEq(t, spec.Plugins[0].Parameters, util.PLUGIN_PARAM_GATEWAY_MEMORY_REQUEST, split.Gateway.MemoryRequest) - assertParamEq(t, spec.Plugins[0].Parameters, util.PLUGIN_PARAM_GATEWAY_MEMORY_LIMIT, split.Gateway.MemoryLimit) - assertParamEq(t, spec.Plugins[0].Parameters, util.PLUGIN_PARAM_GATEWAY_CPU_REQUEST, split.Gateway.CPURequest) - assertParamEq(t, spec.Plugins[0].Parameters, util.PLUGIN_PARAM_GATEWAY_CPU_LIMIT, split.Gateway.CPULimit) - - // OTel plugin params: the intent-driven path must equal the direct - // otel computation from the monitoring spec (config map name, prometheus - // port, and — critically — the config hash that drives pod restarts). - mon := product.MonitoringConfigFromSpec(db.Spec.Monitoring) - wantCM, wantPort, wantHash := "", "", "" - if mon.Enabled { - wantCM = otelcfg.ConfigMapName(db.Name) - if p := otelcfg.ResolvePrometheusPort(mon); p > 0 { - wantPort = fmt.Sprintf("%d", p) - } - if data, err := otelcfg.GenerateConfigMapData(db.Name, db.Namespace, mon); err == nil { - wantHash = otelcfg.HashConfigMapData(data) - } - } - assertParamEq(t, spec.Plugins[0].Parameters, "otelConfigMapName", wantCM) - assertParamEq(t, spec.Plugins[0].Parameters, "prometheusPort", wantPort) - assertParamEq(t, spec.Plugins[0].Parameters, "otelConfigHash", wantHash) - - // OTel monitor role: present (EnsurePresent) only when monitoring is on. - gotRolePresent := false - if spec.Managed != nil { - for _, r := range spec.Managed.Roles { - if r.Name == otelcfg.MonitorRoleName && r.Ensure == cnpgv1.EnsurePresent { - gotRolePresent = true - } - } - } - if gotRolePresent != mon.Enabled { - t.Errorf("otel monitor role presence drift: got %v want %v", gotRolePresent, mon.Enabled) - } - }) - } -} - -func assertParamEq(t *testing.T, params map[string]string, key, want string) { - t.Helper() - got, present := params[key] - if want == "" { - if present { - t.Errorf("param %q unexpectedly set to %q", key, got) - } - return - } - if got != want { - t.Errorf("param %q drift: got %q want %q", key, got, want) - } -} diff --git a/operator/src/internal/cnpg/cnpg_render_golden_test.go b/operator/src/internal/cnpg/cnpg_render_golden_test.go new file mode 100644 index 000000000..fb6462cd0 --- /dev/null +++ b/operator/src/internal/cnpg/cnpg_render_golden_test.go @@ -0,0 +1,173 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +package cnpg + +import ( + "flag" + "os" + "path/filepath" + "testing" + + "github.com/google/go-cmp/cmp" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/log/zap" + "sigs.k8s.io/yaml" + + cnpgv1 "github.com/cloudnative-pg/cloudnative-pg/api/v1" + dbpreview "github.com/documentdb/documentdb-operator/api/preview" +) + +var updateGolden = flag.Bool("update-golden", false, "rewrite golden render fixtures") + +var goldenCerts = cnpgv1.CertificatesConfiguration{ServerCASecret: "pg-ca", ServerTLSSecret: "pg-tls"} + +// TestRenderGolden renders the full CNPG Cluster for a DocumentDB spec matrix and +// compares it against committed golden fixtures. It is the render regression +// guard for the DocumentDB -> ClusterIntent -> CNPG pipeline: any change that +// alters a rendered Cluster must be reflected in testdata/render/*.golden.yaml. +// Regenerate with: go test ./internal/cnpg -run TestRenderGolden -update-golden +func TestRenderGolden(t *testing.T) { + log := zap.New() + req := ctrl.Request{} + req.Name = "drift" + req.Namespace = "default" + + ptr := func(v int64) *int64 { return &v } + + cases := map[string]*dbpreview.DocumentDB{ + "minimal": { + Spec: dbpreview.DocumentDBSpec{ + InstancesPerNode: 1, + Resource: dbpreview.Resource{Storage: dbpreview.StorageConfiguration{PvcSize: "10Gi"}}, + }, + }, + "custom-loglevel-and-stopdelay": { + Spec: dbpreview.DocumentDBSpec{ + InstancesPerNode: 1, + LogLevel: "debug", + Timeouts: dbpreview.Timeouts{StopDelay: 120}, + Resource: dbpreview.Resource{Storage: dbpreview.StorageConfiguration{PvcSize: "10Gi"}}, + }, + }, + "user-params": { + Spec: dbpreview.DocumentDBSpec{ + InstancesPerNode: 1, + Resource: dbpreview.Resource{Storage: dbpreview.StorageConfiguration{PvcSize: "10Gi"}}, + Postgres: &dbpreview.PostgresSpec{ + Parameters: map[string]string{"work_mem": "64MB", "max_connections": "200"}, + }, + }, + }, + "resource-envelope": { + Spec: dbpreview.DocumentDBSpec{ + InstancesPerNode: 1, + Resource: dbpreview.Resource{ + Storage: dbpreview.StorageConfiguration{PvcSize: "10Gi"}, + Memory: "8Gi", + CPU: "4", + }, + }, + }, + "resource-overrides": { + Spec: dbpreview.DocumentDBSpec{ + InstancesPerNode: 1, + Resource: dbpreview.Resource{ + Storage: dbpreview.StorageConfiguration{PvcSize: "10Gi"}, + Gateway: &dbpreview.ComponentResources{Memory: "512Mi", CPU: "500m"}, + Database: &dbpreview.ComponentResources{Memory: "4Gi", CPU: "2"}, + }, + }, + }, + "process-identity": { + Spec: dbpreview.DocumentDBSpec{ + InstancesPerNode: 1, + Resource: dbpreview.Resource{Storage: dbpreview.StorageConfiguration{PvcSize: "10Gi"}}, + Postgres: &dbpreview.PostgresSpec{UID: ptr(26), GID: ptr(26)}, + }, + }, + "iouring-and-changestreams": { + Spec: dbpreview.DocumentDBSpec{ + InstancesPerNode: 1, + Resource: dbpreview.Resource{Storage: dbpreview.StorageConfiguration{PvcSize: "10Gi"}}, + FeatureGates: map[string]bool{ + string(dbpreview.FeatureGateIOUring): true, + string(dbpreview.FeatureGateChangeStreams): true, + }, + }, + }, + "postgres-tls": { + Spec: dbpreview.DocumentDBSpec{ + InstancesPerNode: 1, + Resource: dbpreview.Resource{Storage: dbpreview.StorageConfiguration{PvcSize: "10Gi"}}, + TLS: &dbpreview.TLSConfiguration{ + Postgres: &goldenCerts, + }, + }, + }, + "gateway-tls-ready": { + Spec: dbpreview.DocumentDBSpec{ + InstancesPerNode: 1, + Resource: dbpreview.Resource{Storage: dbpreview.StorageConfiguration{PvcSize: "10Gi"}}, + }, + Status: dbpreview.DocumentDBStatus{ + TLS: &dbpreview.TLSStatus{Ready: true, SecretName: "gw-tls-secret"}, + }, + }, + "monitoring-prometheus": { + ObjectMeta: metav1.ObjectMeta{Name: "mon-prom"}, + Spec: dbpreview.DocumentDBSpec{ + InstancesPerNode: 1, + Resource: dbpreview.Resource{Storage: dbpreview.StorageConfiguration{PvcSize: "10Gi"}}, + Monitoring: &dbpreview.MonitoringSpec{ + Enabled: true, + Exporter: &dbpreview.ExporterSpec{ + Prometheus: &dbpreview.PrometheusExporterSpec{Port: 9090}, + }, + }, + }, + }, + "monitoring-otlp-and-prometheus": { + ObjectMeta: metav1.ObjectMeta{Name: "mon-both"}, + Spec: dbpreview.DocumentDBSpec{ + InstancesPerNode: 1, + Resource: dbpreview.Resource{Storage: dbpreview.StorageConfiguration{PvcSize: "10Gi"}}, + Monitoring: &dbpreview.MonitoringSpec{ + Enabled: true, + Exporter: &dbpreview.ExporterSpec{ + OTLP: &dbpreview.OTLPExporterSpec{Endpoint: "otel-collector:4317"}, + Prometheus: &dbpreview.PrometheusExporterSpec{}, + }, + }, + }, + }, + } + + for name, db := range cases { + t.Run(name, func(t *testing.T) { + cluster := GetCnpgClusterSpec(req, db, "", "test-sa", "", true, log) + got, err := yaml.Marshal(cluster) + if err != nil { + t.Fatalf("marshal cluster: %v", err) + } + path := filepath.Join("testdata", "render", name+".golden.yaml") + if *updateGolden { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, got, 0o644); err != nil { + t.Fatal(err) + } + return + } + want, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read golden (regenerate with -update-golden): %v", err) + } + if diff := cmp.Diff(string(want), string(got)); diff != "" { + t.Errorf("render drift (-want +got):\n%s", diff) + } + }) + } +} diff --git a/operator/src/internal/cnpg/pg_defaults.go b/operator/src/internal/cnpg/pg_defaults.go index 32a8e3c79..08313609e 100644 --- a/operator/src/internal/cnpg/pg_defaults.go +++ b/operator/src/internal/cnpg/pg_defaults.go @@ -102,25 +102,13 @@ func protectedParameters(gates product.FeatureGates) map[string]string { return params } -// MergeParameters merges all parameter sources in priority order (last write wins): +// MergeParametersResolved merges all parameter sources in priority order (last +// write wins): // 1. StaticDefaults // 2. ComputeMemoryAwareDefaults -// 3. Resolved parameters (user overrides plus product-mandated defaults such as -// change streams' wal_level=logical, supplied by the adapter) +// 3. userParams (adapter-resolved: user overrides plus product-mandated defaults +// such as change streams' wal_level=logical) // 4. ProtectedParameters (always wins) -// -// It delegates parameter resolution to the DocumentDB adapter so wal_level and -// any future product defaults have a single source of truth shared with the -// intent-driven builder. -func MergeParameters(documentdb *dbpreview.DocumentDB, memoryLimitBytes int64) map[string]string { - intent := product.DocumentDBAdapter{}.ToClusterIntent(documentdb) - return MergeParametersResolved(intent.Postgres.Parameters, intent.FeatureGates, memoryLimitBytes) -} - -// MergeParametersResolved merges the parameter sources from product-neutral -// inputs. userParams are the adapter-resolved parameters (user overrides plus any -// product-mandated defaults). It is the seam the builder drives; the *DocumentDB -// wrapper above is retained for direct callers and tests. func MergeParametersResolved(userParams map[string]string, gates product.FeatureGates, memoryLimitBytes int64) map[string]string { result := make(map[string]string) diff --git a/operator/src/internal/cnpg/pg_defaults_test.go b/operator/src/internal/cnpg/pg_defaults_test.go index c3a256ed4..69440c5c3 100644 --- a/operator/src/internal/cnpg/pg_defaults_test.go +++ b/operator/src/internal/cnpg/pg_defaults_test.go @@ -8,8 +8,16 @@ import ( . "github.com/onsi/gomega" dbpreview "github.com/documentdb/documentdb-operator/api/preview" + "github.com/documentdb/documentdb-operator/internal/product" ) +// mergeParams merges PostgreSQL parameters for a DocumentDB through the +// product-neutral MergeParametersResolved seam. +func mergeParams(documentdb *dbpreview.DocumentDB, memoryLimitBytes int64) map[string]string { + intent := product.DocumentDBAdapter{}.ToClusterIntent(documentdb) + return MergeParametersResolved(intent.Postgres.Parameters, intent.FeatureGates, memoryLimitBytes) +} + var _ = Describe("formatMB", func() { It("formats plain megabytes", func() { Expect(formatMB(512)).To(Equal("512MB")) @@ -261,7 +269,7 @@ var _ = Describe("MergeParameters", func() { }, }, } - result := MergeParameters(documentdb, 0) + result := mergeParams(documentdb, 0) Expect(result["max_connections"]).To(Equal("500")) }) }) @@ -277,7 +285,7 @@ var _ = Describe("MergeParameters", func() { }, }, } - result := MergeParameters(documentdb, 0) + result := mergeParams(documentdb, 0) Expect(result["cron.database_name"]).To(Equal("postgres")) }) }) @@ -287,7 +295,7 @@ var _ = Describe("MergeParameters", func() { documentdb := &dbpreview.DocumentDB{ Spec: dbpreview.DocumentDBSpec{}, } - result := MergeParameters(documentdb, 8*1024*1024*1024) + result := mergeParams(documentdb, 8*1024*1024*1024) Expect(result["shared_buffers"]).To(Equal("2GB")) }) }) @@ -307,7 +315,7 @@ var _ = Describe("MergeParameters", func() { }, }, } - result := MergeParameters(documentdb, 8*1024*1024*1024) + result := mergeParams(documentdb, 8*1024*1024*1024) // User overrides win for non-protected params Expect(result["max_connections"]).To(Equal("500")) @@ -329,7 +337,7 @@ var _ = Describe("MergeParameters", func() { documentdb := &dbpreview.DocumentDB{ Spec: dbpreview.DocumentDBSpec{}, } - result := MergeParameters(documentdb, 8*1024*1024*1024) + result := mergeParams(documentdb, 8*1024*1024*1024) Expect(result["max_connections"]).To(Equal("300")) Expect(result["shared_buffers"]).To(Equal("2GB")) @@ -342,7 +350,7 @@ var _ = Describe("MergeParameters", func() { documentdb := &dbpreview.DocumentDB{ Spec: dbpreview.DocumentDBSpec{}, } - result := MergeParameters(documentdb, 0) + result := mergeParams(documentdb, 0) Expect(result["shared_buffers"]).To(Equal("256MB")) Expect(result["effective_cache_size"]).To(Equal("512MB")) diff --git a/operator/src/internal/cnpg/resource_split.go b/operator/src/internal/cnpg/resource_split.go index 178dfac8f..5e3351964 100644 --- a/operator/src/internal/cnpg/resource_split.go +++ b/operator/src/internal/cnpg/resource_split.go @@ -9,7 +9,6 @@ import ( "k8s.io/apimachinery/pkg/api/resource" - dbpreview "github.com/documentdb/documentdb-operator/api/preview" "github.com/documentdb/documentdb-operator/internal/product" util "github.com/documentdb/documentdb-operator/internal/utils" ) @@ -85,9 +84,9 @@ func DefaultSplitConfig() SplitConfig { } } -// ComputeResourceSplit resolves how the pod memory and CPU envelopes -// (spec.resource.memory / spec.resource.cpu) are divided across the PostgreSQL, -// gateway, and (when monitoring is enabled) OTel collector containers. +// ComputeResourceSplitFromResource resolves how the pod memory and CPU envelopes +// (resource.memory / resource.cpu) are divided across the PostgreSQL, gateway, +// and (when monitoring is enabled) OTel collector containers. // // The envelope is OPTIONAL. For each dimension: // - If the envelope is set, the operator carves it: the gateway and OTel @@ -101,16 +100,8 @@ func DefaultSplitConfig() SplitConfig { // PostgreSQL remainder) can only be derived when the envelope is set, so the // omitted-envelope path requires those to be explicit — see ValidateResources. // -// Legacy behavior is preserved: when neither the envelope nor any per-container -// value is set for a dimension, that dimension is left unmanaged (no limits). -func ComputeResourceSplit(documentdb *dbpreview.DocumentDB, cfg SplitConfig) ResourceSplit { - monitoring := documentdb.Spec.Monitoring != nil && documentdb.Spec.Monitoring.Enabled - return ComputeResourceSplitFromResource(product.ResourceFromSpec(documentdb.Spec.Resource), monitoring, cfg) -} - -// ComputeResourceSplitFromResource resolves the pod resource carve-out from the -// product-neutral Resource model. It is the seam the builder drives; the -// *DocumentDB wrapper above is retained for direct callers and tests. +// When neither the envelope nor any per-container value is set for a dimension, +// that dimension is left unmanaged (no limits). func ComputeResourceSplitFromResource(res product.Resource, monitoring bool, cfg SplitConfig) ResourceSplit { envelopeBytes := parseMemoryToBytes(res.Memory) split := ResourceSplit{MonitoringEnabled: monitoring} diff --git a/operator/src/internal/cnpg/resource_split_test.go b/operator/src/internal/cnpg/resource_split_test.go index 0a25b828c..d6b1a150c 100644 --- a/operator/src/internal/cnpg/resource_split_test.go +++ b/operator/src/internal/cnpg/resource_split_test.go @@ -7,8 +7,16 @@ import ( "testing" dbpreview "github.com/documentdb/documentdb-operator/api/preview" + "github.com/documentdb/documentdb-operator/internal/product" ) +// computeSplit resolves the pod resource carve-out for a DocumentDB through the +// product-neutral ComputeResourceSplitFromResource seam. +func computeSplit(documentdb *dbpreview.DocumentDB, cfg SplitConfig) ResourceSplit { + monitoring := documentdb.Spec.Monitoring != nil && documentdb.Spec.Monitoring.Enabled + return ComputeResourceSplitFromResource(product.ResourceFromSpec(documentdb.Spec.Resource), monitoring, cfg) +} + // prodSplitConfig mirrors the documented production defaults (18.75%, cap 32Gi, // otel 48Mi/128Mi) without depending on environment variables. func prodSplitConfig() SplitConfig { @@ -47,7 +55,7 @@ func TestComputeResourceSplit_ProductionRows(t *testing.T) { cfg := prodSplitConfig() for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - s := ComputeResourceSplit(ddbWithMemory(tc.envelope, false), cfg) + s := computeSplit(ddbWithMemory(tc.envelope, false), cfg) if s.Gateway.MemoryLimit != tc.wantGW { t.Errorf("gateway memory = %q, want %q", s.Gateway.MemoryLimit, tc.wantGW) } @@ -69,7 +77,7 @@ func TestComputeResourceSplit_ProductionRows(t *testing.T) { func TestComputeResourceSplit_MonitoringCarvesOTel(t *testing.T) { cfg := prodSplitConfig() - s := ComputeResourceSplit(ddbWithMemory("16Gi", true), cfg) + s := computeSplit(ddbWithMemory("16Gi", true), cfg) if !s.MonitoringEnabled { t.Fatalf("monitoring should be enabled") @@ -98,7 +106,7 @@ func TestComputeResourceSplit_ExplicitOverridesWin(t *testing.T) { d.Spec.Resource.Database = &dbpreview.ComponentResources{Memory: "10Gi"} d.Spec.Resource.OTel = &dbpreview.ComponentResources{Memory: "256Mi", CPU: "150m"} - s := ComputeResourceSplit(d, cfg) + s := computeSplit(d, cfg) if s.Gateway.MemoryLimit != "2Gi" || s.Gateway.MemoryRequest != "2Gi" { t.Errorf("gateway override not applied: %+v", s.Gateway) @@ -122,7 +130,7 @@ func TestComputeResourceSplit_ExplicitOverridesWin(t *testing.T) { func TestComputeResourceSplit_UnsetMemoryNoCarveOut(t *testing.T) { cfg := prodSplitConfig() // No envelope memory set -> no automatic carve-out (legacy behavior). - s := ComputeResourceSplit(ddbWithMemory("", false), cfg) + s := computeSplit(ddbWithMemory("", false), cfg) if s.Gateway.MemoryLimit != "" || s.Postgres.MemoryLimit != "" { t.Errorf("expected no memory set, got gw=%q pg=%q", s.Gateway.MemoryLimit, s.Postgres.MemoryLimit) } @@ -135,7 +143,7 @@ func TestComputeResourceSplit_CPUFromEnvelope(t *testing.T) { cfg := prodSplitConfig() d := ddbWithMemory("8Gi", false) d.Spec.Resource.CPU = "4" - s := ComputeResourceSplit(d, cfg) + s := computeSplit(d, cfg) if s.Postgres.CPULimit != "4" || s.Postgres.CPURequest != "4" { t.Errorf("postgres cpu = %q/%q, want 4/4", s.Postgres.CPURequest, s.Postgres.CPULimit) } @@ -145,7 +153,7 @@ func TestComputeResourceSplit_GatewayCPULimitDefault(t *testing.T) { cfg := prodSplitConfig() cfg.GatewayCPULimit = "2" d := ddbWithMemory("8Gi", false) - s := ComputeResourceSplit(d, cfg) + s := computeSplit(d, cfg) if s.Gateway.CPULimit != "2" || s.Gateway.CPURequest != "2" { t.Errorf("gateway cpu = %q/%q, want 2/2", s.Gateway.CPURequest, s.Gateway.CPULimit) } @@ -159,7 +167,7 @@ func TestComputeResourceSplit_EnvelopeOmittedAllExplicit(t *testing.T) { d.Spec.Resource.Gateway = &dbpreview.ComponentResources{Memory: "512Mi", CPU: "500m"} d.Spec.Resource.Database = &dbpreview.ComponentResources{Memory: "4Gi", CPU: "3"} - s := ComputeResourceSplit(d, cfg) + s := computeSplit(d, cfg) if s.Gateway.MemoryLimit != "512Mi" || s.Gateway.CPULimit != "500m" { t.Errorf("gateway = %+v, want 512Mi/500m", s.Gateway) } @@ -175,7 +183,7 @@ func TestComputeResourceSplit_CPUCarvedWithMonitoring(t *testing.T) { cfg := prodSplitConfig() d := ddbWithMemory("8Gi", true) d.Spec.Resource.CPU = "4" - s := ComputeResourceSplit(d, cfg) + s := computeSplit(d, cfg) // otel cpu reservation defaults to 50m request / 200m limit (Burstable); // only the request is carved from the envelope, so postgres = 4 - 50m = 3950m. if s.OTel.CPURequest != "50m" { diff --git a/operator/src/internal/cnpg/testdata/render/custom-loglevel-and-stopdelay.golden.yaml b/operator/src/internal/cnpg/testdata/render/custom-loglevel-and-stopdelay.golden.yaml new file mode 100644 index 000000000..a0920dd2a --- /dev/null +++ b/operator/src/internal/cnpg/testdata/render/custom-loglevel-and-stopdelay.golden.yaml @@ -0,0 +1,92 @@ +metadata: + ownerReferences: + - apiVersion: "" + blockOwnerDeletion: true + controller: true + kind: "" + name: "" + uid: "" +spec: + affinity: {} + backup: + target: primary + volumeSnapshot: + onlineConfiguration: {} + snapshotOwnerReference: backup + bootstrap: + initdb: + postInitSQL: + - CREATE EXTENSION documentdb CASCADE + - CREATE ROLE documentdb WITH LOGIN PASSWORD 'Admin100' + - ALTER ROLE documentdb WITH SUPERUSER CREATEDB CREATEROLE REPLICATION BYPASSRLS + inheritedMetadata: + labels: + app: "" + replica_type: primary + instances: 1 + logLevel: debug + managed: + roles: + - connectionLimit: -1 + ensure: absent + inherit: true + name: otel_monitor + plugins: + - enabled: true + name: cnpg-i-sidecar-injector.documentdb.io + parameters: + documentDbCredentialSecret: documentdb-credentials + gatewayImage: ghcr.io/documentdb/documentdb-kubernetes-operator/gateway:0.113.0 + postgresql: + extensions: + - dynamic_library_path: + - lib + extension_control_path: + - share + image: + reference: ghcr.io/documentdb/documentdb-kubernetes-operator/documentdb:0.113.0 + ld_library_path: + - lib + - system + name: documentdb + parameters: + autovacuum_analyze_scale_factor: "0.05" + autovacuum_max_workers: "4" + autovacuum_vacuum_cost_delay: 2ms + autovacuum_vacuum_scale_factor: "0.1" + checkpoint_completion_target: "0.9" + cron.database_name: postgres + effective_cache_size: 512MB + effective_io_concurrency: "200" + maintenance_work_mem: 128MB + max_connections: "300" + max_prepared_transactions: "100" + max_replication_slots: "10" + max_wal_senders: "10" + max_wal_size: 2GB + min_wal_size: 256MB + random_page_cost: "1.1" + shared_buffers: 256MB + wal_buffers: 16MB + work_mem: 16MB + pg_hba: + - host all all localhost trust + - hostssl replication streaming_replica all cert + shared_preload_libraries: + - pg_cron + - pg_documentdb_core + - pg_documentdb + syncReplicaElectionConstraint: + enabled: false + primaryUpdateMethod: switchover + resources: {} + stopDelay: 120 + storage: + size: 10Gi +status: + certificates: {} + configMapResourceVersion: {} + managedRolesStatus: {} + secretsResourceVersion: {} + switchReplicaClusterStatus: {} + topology: {} diff --git a/operator/src/internal/cnpg/testdata/render/gateway-tls-ready.golden.yaml b/operator/src/internal/cnpg/testdata/render/gateway-tls-ready.golden.yaml new file mode 100644 index 000000000..6141ee088 --- /dev/null +++ b/operator/src/internal/cnpg/testdata/render/gateway-tls-ready.golden.yaml @@ -0,0 +1,93 @@ +metadata: + ownerReferences: + - apiVersion: "" + blockOwnerDeletion: true + controller: true + kind: "" + name: "" + uid: "" +spec: + affinity: {} + backup: + target: primary + volumeSnapshot: + onlineConfiguration: {} + snapshotOwnerReference: backup + bootstrap: + initdb: + postInitSQL: + - CREATE EXTENSION documentdb CASCADE + - CREATE ROLE documentdb WITH LOGIN PASSWORD 'Admin100' + - ALTER ROLE documentdb WITH SUPERUSER CREATEDB CREATEROLE REPLICATION BYPASSRLS + inheritedMetadata: + labels: + app: "" + replica_type: primary + instances: 1 + logLevel: info + managed: + roles: + - connectionLimit: -1 + ensure: absent + inherit: true + name: otel_monitor + plugins: + - enabled: true + name: cnpg-i-sidecar-injector.documentdb.io + parameters: + documentDbCredentialSecret: documentdb-credentials + gatewayImage: ghcr.io/documentdb/documentdb-kubernetes-operator/gateway:0.113.0 + gatewayTLSSecret: gw-tls-secret + postgresql: + extensions: + - dynamic_library_path: + - lib + extension_control_path: + - share + image: + reference: ghcr.io/documentdb/documentdb-kubernetes-operator/documentdb:0.113.0 + ld_library_path: + - lib + - system + name: documentdb + parameters: + autovacuum_analyze_scale_factor: "0.05" + autovacuum_max_workers: "4" + autovacuum_vacuum_cost_delay: 2ms + autovacuum_vacuum_scale_factor: "0.1" + checkpoint_completion_target: "0.9" + cron.database_name: postgres + effective_cache_size: 512MB + effective_io_concurrency: "200" + maintenance_work_mem: 128MB + max_connections: "300" + max_prepared_transactions: "100" + max_replication_slots: "10" + max_wal_senders: "10" + max_wal_size: 2GB + min_wal_size: 256MB + random_page_cost: "1.1" + shared_buffers: 256MB + wal_buffers: 16MB + work_mem: 16MB + pg_hba: + - host all all localhost trust + - hostssl replication streaming_replica all cert + shared_preload_libraries: + - pg_cron + - pg_documentdb_core + - pg_documentdb + syncReplicaElectionConstraint: + enabled: false + primaryUpdateMethod: switchover + resources: {} + stopDelay: 30 + storage: + size: 10Gi +status: + certificates: {} + configMapResourceVersion: {} + managedRolesStatus: {} + secretsResourceVersion: {} + switchReplicaClusterStatus: {} + topology: {} diff --git a/operator/src/internal/cnpg/testdata/render/iouring-and-changestreams.golden.yaml b/operator/src/internal/cnpg/testdata/render/iouring-and-changestreams.golden.yaml new file mode 100644 index 000000000..b3d5fcf17 --- /dev/null +++ b/operator/src/internal/cnpg/testdata/render/iouring-and-changestreams.golden.yaml @@ -0,0 +1,97 @@ +metadata: + ownerReferences: + - apiVersion: "" + blockOwnerDeletion: true + controller: true + kind: "" + name: "" + uid: "" +spec: + affinity: {} + backup: + target: primary + volumeSnapshot: + onlineConfiguration: {} + snapshotOwnerReference: backup + bootstrap: + initdb: + postInitSQL: + - CREATE EXTENSION documentdb CASCADE + - CREATE ROLE documentdb WITH LOGIN PASSWORD 'Admin100' + - ALTER ROLE documentdb WITH SUPERUSER CREATEDB CREATEROLE REPLICATION BYPASSRLS + inheritedMetadata: + labels: + app: "" + replica_type: primary + instances: 1 + logLevel: info + managed: + roles: + - connectionLimit: -1 + ensure: absent + inherit: true + name: otel_monitor + plugins: + - enabled: true + name: cnpg-i-sidecar-injector.documentdb.io + parameters: + documentDbCredentialSecret: documentdb-credentials + gatewayImage: ghcr.io/wentingwu666666/documentdb-kubernetes-operator/documentdb-gateway:16-changestream + postgresql: + extensions: + - dynamic_library_path: + - lib + extension_control_path: + - share + image: + reference: ghcr.io/wentingwu666666/documentdb-kubernetes-operator/documentdb-oss:16-changestream + ld_library_path: + - lib + - system + name: documentdb + parameters: + autovacuum_analyze_scale_factor: "0.05" + autovacuum_max_workers: "4" + autovacuum_vacuum_cost_delay: 2ms + autovacuum_vacuum_scale_factor: "0.1" + checkpoint_completion_target: "0.9" + cron.database_name: postgres + effective_cache_size: 512MB + effective_io_concurrency: "200" + io_method: io_uring + maintenance_work_mem: 128MB + max_connections: "300" + max_prepared_transactions: "100" + max_replication_slots: "10" + max_wal_senders: "10" + max_wal_size: 2GB + min_wal_size: 256MB + random_page_cost: "1.1" + shared_buffers: 256MB + wal_buffers: 16MB + wal_level: logical + work_mem: 16MB + pg_hba: + - host all all localhost trust + - hostssl replication streaming_replica all cert + shared_preload_libraries: + - pg_cron + - pg_documentdb_core + - pg_documentdb + syncReplicaElectionConstraint: + enabled: false + primaryUpdateMethod: switchover + resources: {} + seccompProfile: + localhostProfile: profiles/documentdb-iouring.json + type: Localhost + stopDelay: 30 + storage: + size: 10Gi +status: + certificates: {} + configMapResourceVersion: {} + managedRolesStatus: {} + secretsResourceVersion: {} + switchReplicaClusterStatus: {} + topology: {} diff --git a/operator/src/internal/cnpg/testdata/render/minimal.golden.yaml b/operator/src/internal/cnpg/testdata/render/minimal.golden.yaml new file mode 100644 index 000000000..a4636f95f --- /dev/null +++ b/operator/src/internal/cnpg/testdata/render/minimal.golden.yaml @@ -0,0 +1,92 @@ +metadata: + ownerReferences: + - apiVersion: "" + blockOwnerDeletion: true + controller: true + kind: "" + name: "" + uid: "" +spec: + affinity: {} + backup: + target: primary + volumeSnapshot: + onlineConfiguration: {} + snapshotOwnerReference: backup + bootstrap: + initdb: + postInitSQL: + - CREATE EXTENSION documentdb CASCADE + - CREATE ROLE documentdb WITH LOGIN PASSWORD 'Admin100' + - ALTER ROLE documentdb WITH SUPERUSER CREATEDB CREATEROLE REPLICATION BYPASSRLS + inheritedMetadata: + labels: + app: "" + replica_type: primary + instances: 1 + logLevel: info + managed: + roles: + - connectionLimit: -1 + ensure: absent + inherit: true + name: otel_monitor + plugins: + - enabled: true + name: cnpg-i-sidecar-injector.documentdb.io + parameters: + documentDbCredentialSecret: documentdb-credentials + gatewayImage: ghcr.io/documentdb/documentdb-kubernetes-operator/gateway:0.113.0 + postgresql: + extensions: + - dynamic_library_path: + - lib + extension_control_path: + - share + image: + reference: ghcr.io/documentdb/documentdb-kubernetes-operator/documentdb:0.113.0 + ld_library_path: + - lib + - system + name: documentdb + parameters: + autovacuum_analyze_scale_factor: "0.05" + autovacuum_max_workers: "4" + autovacuum_vacuum_cost_delay: 2ms + autovacuum_vacuum_scale_factor: "0.1" + checkpoint_completion_target: "0.9" + cron.database_name: postgres + effective_cache_size: 512MB + effective_io_concurrency: "200" + maintenance_work_mem: 128MB + max_connections: "300" + max_prepared_transactions: "100" + max_replication_slots: "10" + max_wal_senders: "10" + max_wal_size: 2GB + min_wal_size: 256MB + random_page_cost: "1.1" + shared_buffers: 256MB + wal_buffers: 16MB + work_mem: 16MB + pg_hba: + - host all all localhost trust + - hostssl replication streaming_replica all cert + shared_preload_libraries: + - pg_cron + - pg_documentdb_core + - pg_documentdb + syncReplicaElectionConstraint: + enabled: false + primaryUpdateMethod: switchover + resources: {} + stopDelay: 30 + storage: + size: 10Gi +status: + certificates: {} + configMapResourceVersion: {} + managedRolesStatus: {} + secretsResourceVersion: {} + switchReplicaClusterStatus: {} + topology: {} diff --git a/operator/src/internal/cnpg/testdata/render/monitoring-otlp-and-prometheus.golden.yaml b/operator/src/internal/cnpg/testdata/render/monitoring-otlp-and-prometheus.golden.yaml new file mode 100644 index 000000000..13221835d --- /dev/null +++ b/operator/src/internal/cnpg/testdata/render/monitoring-otlp-and-prometheus.golden.yaml @@ -0,0 +1,104 @@ +metadata: + name: mon-both + ownerReferences: + - apiVersion: "" + blockOwnerDeletion: true + controller: true + kind: "" + name: mon-both + uid: "" +spec: + affinity: {} + backup: + target: primary + volumeSnapshot: + onlineConfiguration: {} + snapshotOwnerReference: backup + bootstrap: + initdb: + postInitSQL: + - CREATE EXTENSION documentdb CASCADE + - CREATE ROLE documentdb WITH LOGIN PASSWORD 'Admin100' + - ALTER ROLE documentdb WITH SUPERUSER CREATEDB CREATEROLE REPLICATION BYPASSRLS + inheritedMetadata: + labels: + app: mon-both + replica_type: primary + instances: 1 + logLevel: info + managed: + roles: + - comment: Dedicated role for the OTel Collector monitoring sidecar + connectionLimit: -1 + disablePassword: true + ensure: present + inherit: true + login: true + name: otel_monitor + plugins: + - enabled: true + name: cnpg-i-sidecar-injector.documentdb.io + parameters: + documentDbCredentialSecret: documentdb-credentials + gatewayImage: ghcr.io/documentdb/documentdb-kubernetes-operator/gateway:0.113.0 + otelCollectorImage: otel/opentelemetry-collector-contrib:0.149.0 + otelConfigHash: 37f5d3890c5ab450 + otelConfigMapName: mon-both-otel-config + otelCpuLimit: 200m + otelCpuRequest: 50m + otelMemoryLimit: 128Mi + otelMemoryRequest: 48Mi + prometheusPort: "8888" + postgresql: + extensions: + - dynamic_library_path: + - lib + extension_control_path: + - share + image: + reference: ghcr.io/documentdb/documentdb-kubernetes-operator/documentdb:0.113.0 + ld_library_path: + - lib + - system + name: documentdb + parameters: + autovacuum_analyze_scale_factor: "0.05" + autovacuum_max_workers: "4" + autovacuum_vacuum_cost_delay: 2ms + autovacuum_vacuum_scale_factor: "0.1" + checkpoint_completion_target: "0.9" + cron.database_name: postgres + effective_cache_size: 512MB + effective_io_concurrency: "200" + maintenance_work_mem: 128MB + max_connections: "300" + max_prepared_transactions: "100" + max_replication_slots: "10" + max_wal_senders: "10" + max_wal_size: 2GB + min_wal_size: 256MB + random_page_cost: "1.1" + shared_buffers: 256MB + wal_buffers: 16MB + work_mem: 16MB + pg_hba: + - host all all localhost trust + - hostssl replication streaming_replica all cert + shared_preload_libraries: + - pg_cron + - pg_documentdb_core + - pg_documentdb + syncReplicaElectionConstraint: + enabled: false + primaryUpdateMethod: switchover + resources: {} + stopDelay: 30 + storage: + size: 10Gi +status: + certificates: {} + configMapResourceVersion: {} + managedRolesStatus: {} + secretsResourceVersion: {} + switchReplicaClusterStatus: {} + topology: {} diff --git a/operator/src/internal/cnpg/testdata/render/monitoring-prometheus.golden.yaml b/operator/src/internal/cnpg/testdata/render/monitoring-prometheus.golden.yaml new file mode 100644 index 000000000..329cda52c --- /dev/null +++ b/operator/src/internal/cnpg/testdata/render/monitoring-prometheus.golden.yaml @@ -0,0 +1,104 @@ +metadata: + name: mon-prom + ownerReferences: + - apiVersion: "" + blockOwnerDeletion: true + controller: true + kind: "" + name: mon-prom + uid: "" +spec: + affinity: {} + backup: + target: primary + volumeSnapshot: + onlineConfiguration: {} + snapshotOwnerReference: backup + bootstrap: + initdb: + postInitSQL: + - CREATE EXTENSION documentdb CASCADE + - CREATE ROLE documentdb WITH LOGIN PASSWORD 'Admin100' + - ALTER ROLE documentdb WITH SUPERUSER CREATEDB CREATEROLE REPLICATION BYPASSRLS + inheritedMetadata: + labels: + app: mon-prom + replica_type: primary + instances: 1 + logLevel: info + managed: + roles: + - comment: Dedicated role for the OTel Collector monitoring sidecar + connectionLimit: -1 + disablePassword: true + ensure: present + inherit: true + login: true + name: otel_monitor + plugins: + - enabled: true + name: cnpg-i-sidecar-injector.documentdb.io + parameters: + documentDbCredentialSecret: documentdb-credentials + gatewayImage: ghcr.io/documentdb/documentdb-kubernetes-operator/gateway:0.113.0 + otelCollectorImage: otel/opentelemetry-collector-contrib:0.149.0 + otelConfigHash: e8ff8355aabfc3dd + otelConfigMapName: mon-prom-otel-config + otelCpuLimit: 200m + otelCpuRequest: 50m + otelMemoryLimit: 128Mi + otelMemoryRequest: 48Mi + prometheusPort: "9090" + postgresql: + extensions: + - dynamic_library_path: + - lib + extension_control_path: + - share + image: + reference: ghcr.io/documentdb/documentdb-kubernetes-operator/documentdb:0.113.0 + ld_library_path: + - lib + - system + name: documentdb + parameters: + autovacuum_analyze_scale_factor: "0.05" + autovacuum_max_workers: "4" + autovacuum_vacuum_cost_delay: 2ms + autovacuum_vacuum_scale_factor: "0.1" + checkpoint_completion_target: "0.9" + cron.database_name: postgres + effective_cache_size: 512MB + effective_io_concurrency: "200" + maintenance_work_mem: 128MB + max_connections: "300" + max_prepared_transactions: "100" + max_replication_slots: "10" + max_wal_senders: "10" + max_wal_size: 2GB + min_wal_size: 256MB + random_page_cost: "1.1" + shared_buffers: 256MB + wal_buffers: 16MB + work_mem: 16MB + pg_hba: + - host all all localhost trust + - hostssl replication streaming_replica all cert + shared_preload_libraries: + - pg_cron + - pg_documentdb_core + - pg_documentdb + syncReplicaElectionConstraint: + enabled: false + primaryUpdateMethod: switchover + resources: {} + stopDelay: 30 + storage: + size: 10Gi +status: + certificates: {} + configMapResourceVersion: {} + managedRolesStatus: {} + secretsResourceVersion: {} + switchReplicaClusterStatus: {} + topology: {} diff --git a/operator/src/internal/cnpg/testdata/render/postgres-tls.golden.yaml b/operator/src/internal/cnpg/testdata/render/postgres-tls.golden.yaml new file mode 100644 index 000000000..597da0bdf --- /dev/null +++ b/operator/src/internal/cnpg/testdata/render/postgres-tls.golden.yaml @@ -0,0 +1,95 @@ +metadata: + ownerReferences: + - apiVersion: "" + blockOwnerDeletion: true + controller: true + kind: "" + name: "" + uid: "" +spec: + affinity: {} + backup: + target: primary + volumeSnapshot: + onlineConfiguration: {} + snapshotOwnerReference: backup + bootstrap: + initdb: + postInitSQL: + - CREATE EXTENSION documentdb CASCADE + - CREATE ROLE documentdb WITH LOGIN PASSWORD 'Admin100' + - ALTER ROLE documentdb WITH SUPERUSER CREATEDB CREATEROLE REPLICATION BYPASSRLS + certificates: + serverCASecret: pg-ca + serverTLSSecret: pg-tls + inheritedMetadata: + labels: + app: "" + replica_type: primary + instances: 1 + logLevel: info + managed: + roles: + - connectionLimit: -1 + ensure: absent + inherit: true + name: otel_monitor + plugins: + - enabled: true + name: cnpg-i-sidecar-injector.documentdb.io + parameters: + documentDbCredentialSecret: documentdb-credentials + gatewayImage: ghcr.io/documentdb/documentdb-kubernetes-operator/gateway:0.113.0 + postgresql: + extensions: + - dynamic_library_path: + - lib + extension_control_path: + - share + image: + reference: ghcr.io/documentdb/documentdb-kubernetes-operator/documentdb:0.113.0 + ld_library_path: + - lib + - system + name: documentdb + parameters: + autovacuum_analyze_scale_factor: "0.05" + autovacuum_max_workers: "4" + autovacuum_vacuum_cost_delay: 2ms + autovacuum_vacuum_scale_factor: "0.1" + checkpoint_completion_target: "0.9" + cron.database_name: postgres + effective_cache_size: 512MB + effective_io_concurrency: "200" + maintenance_work_mem: 128MB + max_connections: "300" + max_prepared_transactions: "100" + max_replication_slots: "10" + max_wal_senders: "10" + max_wal_size: 2GB + min_wal_size: 256MB + random_page_cost: "1.1" + shared_buffers: 256MB + wal_buffers: 16MB + work_mem: 16MB + pg_hba: + - host all all localhost trust + - hostssl replication streaming_replica all cert + shared_preload_libraries: + - pg_cron + - pg_documentdb_core + - pg_documentdb + syncReplicaElectionConstraint: + enabled: false + primaryUpdateMethod: switchover + resources: {} + stopDelay: 30 + storage: + size: 10Gi +status: + certificates: {} + configMapResourceVersion: {} + managedRolesStatus: {} + secretsResourceVersion: {} + switchReplicaClusterStatus: {} + topology: {} diff --git a/operator/src/internal/cnpg/testdata/render/process-identity.golden.yaml b/operator/src/internal/cnpg/testdata/render/process-identity.golden.yaml new file mode 100644 index 000000000..cbf77016f --- /dev/null +++ b/operator/src/internal/cnpg/testdata/render/process-identity.golden.yaml @@ -0,0 +1,94 @@ +metadata: + ownerReferences: + - apiVersion: "" + blockOwnerDeletion: true + controller: true + kind: "" + name: "" + uid: "" +spec: + affinity: {} + backup: + target: primary + volumeSnapshot: + onlineConfiguration: {} + snapshotOwnerReference: backup + bootstrap: + initdb: + postInitSQL: + - CREATE EXTENSION documentdb CASCADE + - CREATE ROLE documentdb WITH LOGIN PASSWORD 'Admin100' + - ALTER ROLE documentdb WITH SUPERUSER CREATEDB CREATEROLE REPLICATION BYPASSRLS + inheritedMetadata: + labels: + app: "" + replica_type: primary + instances: 1 + logLevel: info + managed: + roles: + - connectionLimit: -1 + ensure: absent + inherit: true + name: otel_monitor + plugins: + - enabled: true + name: cnpg-i-sidecar-injector.documentdb.io + parameters: + documentDbCredentialSecret: documentdb-credentials + gatewayImage: ghcr.io/documentdb/documentdb-kubernetes-operator/gateway:0.113.0 + postgresGID: 26 + postgresUID: 26 + postgresql: + extensions: + - dynamic_library_path: + - lib + extension_control_path: + - share + image: + reference: ghcr.io/documentdb/documentdb-kubernetes-operator/documentdb:0.113.0 + ld_library_path: + - lib + - system + name: documentdb + parameters: + autovacuum_analyze_scale_factor: "0.05" + autovacuum_max_workers: "4" + autovacuum_vacuum_cost_delay: 2ms + autovacuum_vacuum_scale_factor: "0.1" + checkpoint_completion_target: "0.9" + cron.database_name: postgres + effective_cache_size: 512MB + effective_io_concurrency: "200" + maintenance_work_mem: 128MB + max_connections: "300" + max_prepared_transactions: "100" + max_replication_slots: "10" + max_wal_senders: "10" + max_wal_size: 2GB + min_wal_size: 256MB + random_page_cost: "1.1" + shared_buffers: 256MB + wal_buffers: 16MB + work_mem: 16MB + pg_hba: + - host all all localhost trust + - hostssl replication streaming_replica all cert + shared_preload_libraries: + - pg_cron + - pg_documentdb_core + - pg_documentdb + syncReplicaElectionConstraint: + enabled: false + primaryUpdateMethod: switchover + resources: {} + stopDelay: 30 + storage: + size: 10Gi +status: + certificates: {} + configMapResourceVersion: {} + managedRolesStatus: {} + secretsResourceVersion: {} + switchReplicaClusterStatus: {} + topology: {} diff --git a/operator/src/internal/cnpg/testdata/render/resource-envelope.golden.yaml b/operator/src/internal/cnpg/testdata/render/resource-envelope.golden.yaml new file mode 100644 index 000000000..05f1b4482 --- /dev/null +++ b/operator/src/internal/cnpg/testdata/render/resource-envelope.golden.yaml @@ -0,0 +1,100 @@ +metadata: + ownerReferences: + - apiVersion: "" + blockOwnerDeletion: true + controller: true + kind: "" + name: "" + uid: "" +spec: + affinity: {} + backup: + target: primary + volumeSnapshot: + onlineConfiguration: {} + snapshotOwnerReference: backup + bootstrap: + initdb: + postInitSQL: + - CREATE EXTENSION documentdb CASCADE + - CREATE ROLE documentdb WITH LOGIN PASSWORD 'Admin100' + - ALTER ROLE documentdb WITH SUPERUSER CREATEDB CREATEROLE REPLICATION BYPASSRLS + inheritedMetadata: + labels: + app: "" + replica_type: primary + instances: 1 + logLevel: info + managed: + roles: + - connectionLimit: -1 + ensure: absent + inherit: true + name: otel_monitor + plugins: + - enabled: true + name: cnpg-i-sidecar-injector.documentdb.io + parameters: + documentDbCredentialSecret: documentdb-credentials + gatewayImage: ghcr.io/documentdb/documentdb-kubernetes-operator/gateway:0.113.0 + gatewayMemoryLimit: 1536Mi + gatewayMemoryRequest: 1536Mi + postgresql: + extensions: + - dynamic_library_path: + - lib + extension_control_path: + - share + image: + reference: ghcr.io/documentdb/documentdb-kubernetes-operator/documentdb:0.113.0 + ld_library_path: + - lib + - system + name: documentdb + parameters: + autovacuum_analyze_scale_factor: "0.05" + autovacuum_max_workers: "4" + autovacuum_vacuum_cost_delay: 2ms + autovacuum_vacuum_scale_factor: "0.1" + checkpoint_completion_target: "0.9" + cron.database_name: postgres + effective_cache_size: 4992MB + effective_io_concurrency: "200" + maintenance_work_mem: 665MB + max_connections: "300" + max_prepared_transactions: "100" + max_replication_slots: "10" + max_wal_senders: "10" + max_wal_size: 2GB + min_wal_size: 256MB + random_page_cost: "1.1" + shared_buffers: 1664MB + wal_buffers: 16MB + work_mem: 5MB + pg_hba: + - host all all localhost trust + - hostssl replication streaming_replica all cert + shared_preload_libraries: + - pg_cron + - pg_documentdb_core + - pg_documentdb + syncReplicaElectionConstraint: + enabled: false + primaryUpdateMethod: switchover + resources: + limits: + cpu: "4" + memory: 6656Mi + requests: + cpu: "4" + memory: 6656Mi + stopDelay: 30 + storage: + size: 10Gi +status: + certificates: {} + configMapResourceVersion: {} + managedRolesStatus: {} + secretsResourceVersion: {} + switchReplicaClusterStatus: {} + topology: {} diff --git a/operator/src/internal/cnpg/testdata/render/resource-overrides.golden.yaml b/operator/src/internal/cnpg/testdata/render/resource-overrides.golden.yaml new file mode 100644 index 000000000..83e2ecf69 --- /dev/null +++ b/operator/src/internal/cnpg/testdata/render/resource-overrides.golden.yaml @@ -0,0 +1,102 @@ +metadata: + ownerReferences: + - apiVersion: "" + blockOwnerDeletion: true + controller: true + kind: "" + name: "" + uid: "" +spec: + affinity: {} + backup: + target: primary + volumeSnapshot: + onlineConfiguration: {} + snapshotOwnerReference: backup + bootstrap: + initdb: + postInitSQL: + - CREATE EXTENSION documentdb CASCADE + - CREATE ROLE documentdb WITH LOGIN PASSWORD 'Admin100' + - ALTER ROLE documentdb WITH SUPERUSER CREATEDB CREATEROLE REPLICATION BYPASSRLS + inheritedMetadata: + labels: + app: "" + replica_type: primary + instances: 1 + logLevel: info + managed: + roles: + - connectionLimit: -1 + ensure: absent + inherit: true + name: otel_monitor + plugins: + - enabled: true + name: cnpg-i-sidecar-injector.documentdb.io + parameters: + documentDbCredentialSecret: documentdb-credentials + gatewayCpuLimit: 500m + gatewayCpuRequest: 500m + gatewayImage: ghcr.io/documentdb/documentdb-kubernetes-operator/gateway:0.113.0 + gatewayMemoryLimit: 512Mi + gatewayMemoryRequest: 512Mi + postgresql: + extensions: + - dynamic_library_path: + - lib + extension_control_path: + - share + image: + reference: ghcr.io/documentdb/documentdb-kubernetes-operator/documentdb:0.113.0 + ld_library_path: + - lib + - system + name: documentdb + parameters: + autovacuum_analyze_scale_factor: "0.05" + autovacuum_max_workers: "4" + autovacuum_vacuum_cost_delay: 2ms + autovacuum_vacuum_scale_factor: "0.1" + checkpoint_completion_target: "0.9" + cron.database_name: postgres + effective_cache_size: 3GB + effective_io_concurrency: "200" + maintenance_work_mem: 409MB + max_connections: "300" + max_prepared_transactions: "100" + max_replication_slots: "10" + max_wal_senders: "10" + max_wal_size: 2GB + min_wal_size: 256MB + random_page_cost: "1.1" + shared_buffers: 1GB + wal_buffers: 16MB + work_mem: 4MB + pg_hba: + - host all all localhost trust + - hostssl replication streaming_replica all cert + shared_preload_libraries: + - pg_cron + - pg_documentdb_core + - pg_documentdb + syncReplicaElectionConstraint: + enabled: false + primaryUpdateMethod: switchover + resources: + limits: + cpu: "2" + memory: 4Gi + requests: + cpu: "2" + memory: 4Gi + stopDelay: 30 + storage: + size: 10Gi +status: + certificates: {} + configMapResourceVersion: {} + managedRolesStatus: {} + secretsResourceVersion: {} + switchReplicaClusterStatus: {} + topology: {} diff --git a/operator/src/internal/cnpg/testdata/render/user-params.golden.yaml b/operator/src/internal/cnpg/testdata/render/user-params.golden.yaml new file mode 100644 index 000000000..0b22d7292 --- /dev/null +++ b/operator/src/internal/cnpg/testdata/render/user-params.golden.yaml @@ -0,0 +1,92 @@ +metadata: + ownerReferences: + - apiVersion: "" + blockOwnerDeletion: true + controller: true + kind: "" + name: "" + uid: "" +spec: + affinity: {} + backup: + target: primary + volumeSnapshot: + onlineConfiguration: {} + snapshotOwnerReference: backup + bootstrap: + initdb: + postInitSQL: + - CREATE EXTENSION documentdb CASCADE + - CREATE ROLE documentdb WITH LOGIN PASSWORD 'Admin100' + - ALTER ROLE documentdb WITH SUPERUSER CREATEDB CREATEROLE REPLICATION BYPASSRLS + inheritedMetadata: + labels: + app: "" + replica_type: primary + instances: 1 + logLevel: info + managed: + roles: + - connectionLimit: -1 + ensure: absent + inherit: true + name: otel_monitor + plugins: + - enabled: true + name: cnpg-i-sidecar-injector.documentdb.io + parameters: + documentDbCredentialSecret: documentdb-credentials + gatewayImage: ghcr.io/documentdb/documentdb-kubernetes-operator/gateway:0.113.0 + postgresql: + extensions: + - dynamic_library_path: + - lib + extension_control_path: + - share + image: + reference: ghcr.io/documentdb/documentdb-kubernetes-operator/documentdb:0.113.0 + ld_library_path: + - lib + - system + name: documentdb + parameters: + autovacuum_analyze_scale_factor: "0.05" + autovacuum_max_workers: "4" + autovacuum_vacuum_cost_delay: 2ms + autovacuum_vacuum_scale_factor: "0.1" + checkpoint_completion_target: "0.9" + cron.database_name: postgres + effective_cache_size: 512MB + effective_io_concurrency: "200" + maintenance_work_mem: 128MB + max_connections: "200" + max_prepared_transactions: "100" + max_replication_slots: "10" + max_wal_senders: "10" + max_wal_size: 2GB + min_wal_size: 256MB + random_page_cost: "1.1" + shared_buffers: 256MB + wal_buffers: 16MB + work_mem: 64MB + pg_hba: + - host all all localhost trust + - hostssl replication streaming_replica all cert + shared_preload_libraries: + - pg_cron + - pg_documentdb_core + - pg_documentdb + syncReplicaElectionConstraint: + enabled: false + primaryUpdateMethod: switchover + resources: {} + stopDelay: 30 + storage: + size: 10Gi +status: + certificates: {} + configMapResourceVersion: {} + managedRolesStatus: {} + secretsResourceVersion: {} + switchReplicaClusterStatus: {} + topology: {} diff --git a/operator/src/internal/product/documentdb.go b/operator/src/internal/product/documentdb.go index 6c73b4e9e..d5b10e2e4 100644 --- a/operator/src/internal/product/documentdb.go +++ b/operator/src/internal/product/documentdb.go @@ -27,8 +27,8 @@ func DocumentDBProfile() ProductProfile { } } -// DocumentDBAdapter is the first product adapter. It maps the DocumentDB custom -// resource onto the product-neutral model consumed by the reconciler. +// DocumentDBAdapter maps the DocumentDB custom resource onto the product-neutral +// model consumed by the reconciler. type DocumentDBAdapter struct{} // Profile returns the DocumentDB product profile. diff --git a/operator/src/internal/product/intent.go b/operator/src/internal/product/intent.go index 009e0a1d1..892fb8470 100644 --- a/operator/src/internal/product/intent.go +++ b/operator/src/internal/product/intent.go @@ -123,8 +123,7 @@ type Bootstrap struct { // ClusterIntent is the product-neutral desired state the reconciler renders into // a CNPG Cluster. Product adapters populate it from their custom resource; the -// reconciler consumes it without product-branding logic. Fields are added to -// this struct as the builder is progressively rewired onto the seam. +// reconciler consumes it without product-branding logic. type ClusterIntent struct { // Images are the resolved extension, gateway, and postgres images. Images Images