From 3e1eda41c35ab8b00fd0815049cd63031c685ac9 Mon Sep 17 00:00:00 2001 From: Tyler Potts <49161327+tylerpotts@users.noreply.github.com> Date: Fri, 10 Jul 2026 15:59:16 -0500 Subject: [PATCH 1/7] feat(api): add spec.database to the NebariApp CRD Managed PostgreSQL request (provider cloudnativepg, instances, size), DatabaseReady condition, databaseSecretRef status field, and event reasons. The reconciler lands separately. Part of https://github.com/nebari-dev/nebari-infrastructure-core/issues/303 --- api/v1/nebariapp_types.go | 81 ++++++++++++++++++- api/v1/zz_generated.deepcopy.go | 25 ++++++ .../reconcilers.nebari.dev_nebariapps.yaml | 65 ++++++++++++++- 3 files changed, 167 insertions(+), 4 deletions(-) diff --git a/api/v1/nebariapp_types.go b/api/v1/nebariapp_types.go index bb25dbe..72715f7 100644 --- a/api/v1/nebariapp_types.go +++ b/api/v1/nebariapp_types.go @@ -51,8 +51,8 @@ type NebariAppSpec struct { Gateway string `json:"gateway,omitempty"` // ServiceAccountName is the name of the Kubernetes ServiceAccount used by the - // app's pods. Used for RBAC scoping of OIDC secrets so only the app's pods - // can read its credentials. Defaults to the NebariApp's name if omitted. + // app's pods. Used for RBAC scoping of OIDC and database credentials secrets + // so only the app's pods can read them. Defaults to the NebariApp's name if omitted. // +optional // +kubebuilder:validation:MinLength=1 ServiceAccountName string `json:"serviceAccountName,omitempty"` @@ -61,6 +61,21 @@ type NebariAppSpec struct { // When enabled, the service will be discoverable through the landing page portal. // +optional LandingPage *LandingPageConfig `json:"landingPage,omitempty"` + + // Database requests a managed PostgreSQL database for this application. + // When enabled, the operator provisions a CloudNativePG Cluster named + // "-db" and writes connection credentials to the Secret + // "-db-credentials" with keys: host, port, username, password, + // database, uri. Read access is scoped to the app's ServiceAccount. + // + // Requires the CloudNativePG operator on the cluster (in Nebari, NIC's + // top-level database.enabled toggle installs it). + // + // Disabling later stops management but never deletes the database. + // Deleting the NebariApp deletes the database and its data via owner + // references. + // +optional + Database *DatabaseConfig `json:"database,omitempty"` } // ServiceReference identifies the Kubernetes Service that backs this application. @@ -478,6 +493,36 @@ type LandingPageConfig struct { HealthCheck *HealthCheckConfig `json:"healthCheck,omitempty"` } +// DatabaseConfig specifies a managed database request backed by a database +// operator (CloudNativePG). +type DatabaseConfig struct { + // Enabled determines whether a managed database is provisioned. + // +kubebuilder:default=false + // +optional + Enabled bool `json:"enabled,omitempty"` + + // Provider selects the database operator backing this request. + // +kubebuilder:validation:Enum=cloudnativepg + // +kubebuilder:default=cloudnativepg + // +optional + Provider string `json:"provider,omitempty"` + + // Instances is the number of PostgreSQL instances (1 primary plus N-1 replicas, maximum 9). + // +kubebuilder:default=1 + // +kubebuilder:validation:Minimum=1 + // +kubebuilder:validation:Maximum=9 + // +optional + Instances int32 `json:"instances,omitempty"` + + // Size is the storage request for each instance, as a Kubernetes quantity. + // Size cannot be decreased once set (CloudNativePG restriction). + // +kubebuilder:default="1Gi" + // +kubebuilder:validation:XValidation:rule="sign(quantity(self)) > 0",message="size must be greater than zero" + // +kubebuilder:validation:Pattern=`^[0-9]+(\.[0-9]+)?(Ki|Mi|Gi|Ti|Pi|Ei|k|M|G|T|P|E)?$` + // +optional + Size string `json:"size,omitempty"` +} + // HealthCheckConfig defines health check parameters for a service. type HealthCheckConfig struct { // Enabled determines if health checks are performed. @@ -574,6 +619,7 @@ type NebariAppStatus struct { // - "RoutingReady": HTTPRoute has been created and is functioning // - "TLSReady": TLS certificate is available and configured // - "AuthReady": Authentication policy is configured (if auth is enabled) + // - "DatabaseReady": Managed database is provisioned and credentials are available (if database is enabled) // - "Ready": All components are ready (aggregate condition) // +listType=map // +listMapKey=type @@ -598,6 +644,11 @@ type NebariAppStatus struct { // +optional ClientSecretRef *ResourceReference `json:"clientSecretRef,omitempty"` + // DatabaseSecretRef identifies the Secret containing database connection + // credentials (keys: host, port, username, password, database, uri). + // +optional + DatabaseSecretRef *ResourceReference `json:"databaseSecretRef,omitempty"` + // AuthConfigHash stores a SHA-256 hash of the last successfully provisioned OIDC // client configuration. When this matches the hash of the current spec, and the // AuthReady condition is True, ProvisionClient is skipped to avoid unnecessary @@ -649,6 +700,10 @@ const ( // This includes the SecurityPolicy being created and the client secret being available. ConditionTypeAuthReady = "AuthReady" + // ConditionTypeDatabaseReady indicates that the managed database is + // provisioned and its credentials Secret is available. + ConditionTypeDatabaseReady = "DatabaseReady" + // ConditionTypeReady is an aggregate condition indicating all components are ready. ConditionTypeReady = "Ready" ) @@ -685,6 +740,18 @@ const ( // ReasonCertificateNotReady indicates the cert-manager Certificate is not ready ReasonCertificateNotReady = "CertificateNotReady" + // ReasonDatabaseProvisioning indicates the database cluster is being created + ReasonDatabaseProvisioning = "DatabaseProvisioning" + + // ReasonDatabaseDisabled indicates database management was disabled while a + // previously provisioned database still exists (it is never deleted by a + // toggle; see the spec.database field documentation) + ReasonDatabaseDisabled = "DatabaseDisabled" + + // ReasonCNPGNotInstalled indicates the CloudNativePG operator (and its CRDs) + // is not installed on this cluster + ReasonCNPGNotInstalled = "CNPGNotInstalled" + // ReasonGatewayListenerConflict indicates the Gateway listener conflicts with another NebariApp ReasonGatewayListenerConflict = "GatewayListenerConflict" @@ -748,6 +815,16 @@ const ( // EventReasonClientProvisionFailed is used when OIDC client provisioning fails EventReasonClientProvisionFailed = "ClientProvisionFailed" + // EventReasonDatabaseProvisioned is used when the managed database becomes ready + EventReasonDatabaseProvisioned = "DatabaseProvisioned" + + // EventReasonDatabaseProvisionFailed is used when database provisioning fails + EventReasonDatabaseProvisionFailed = "DatabaseProvisionFailed" + + // EventReasonDatabaseOrphaned is used when database management is disabled + // while the database still exists + EventReasonDatabaseOrphaned = "DatabaseOrphaned" + // EventReasonSecurityPolicyCreated is used when SecurityPolicy is created EventReasonSecurityPolicyCreated = "SecurityPolicyCreated" diff --git a/api/v1/zz_generated.deepcopy.go b/api/v1/zz_generated.deepcopy.go index ab03041..26eb8d2 100644 --- a/api/v1/zz_generated.deepcopy.go +++ b/api/v1/zz_generated.deepcopy.go @@ -95,6 +95,21 @@ func (in *AuthConfig) DeepCopy() *AuthConfig { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DatabaseConfig) DeepCopyInto(out *DatabaseConfig) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DatabaseConfig. +func (in *DatabaseConfig) DeepCopy() *DatabaseConfig { + if in == nil { + return nil + } + out := new(DatabaseConfig) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *DenyRedirectHeader) DeepCopyInto(out *DenyRedirectHeader) { *out = *in @@ -344,6 +359,11 @@ func (in *NebariAppSpec) DeepCopyInto(out *NebariAppSpec) { *out = new(LandingPageConfig) (*in).DeepCopyInto(*out) } + if in.Database != nil { + in, out := &in.Database, &out.Database + *out = new(DatabaseConfig) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NebariAppSpec. @@ -376,6 +396,11 @@ func (in *NebariAppStatus) DeepCopyInto(out *NebariAppStatus) { *out = new(ResourceReference) **out = **in } + if in.DatabaseSecretRef != nil { + in, out := &in.DatabaseSecretRef, &out.DatabaseSecretRef + *out = new(ResourceReference) + **out = **in + } if in.ServiceDiscovery != nil { in, out := &in.ServiceDiscovery, &out.ServiceDiscovery *out = new(ServiceDiscoveryStatus) diff --git a/config/crd/bases/reconcilers.nebari.dev_nebariapps.yaml b/config/crd/bases/reconcilers.nebari.dev_nebariapps.yaml index a93fd38..971335d 100644 --- a/config/crd/bases/reconcilers.nebari.dev_nebariapps.yaml +++ b/config/crd/bases/reconcilers.nebari.dev_nebariapps.yaml @@ -311,6 +311,52 @@ spec: rule: '!has(self.forwardAccessToken) || self.forwardAccessToken == false || (has(self.enforceAtGateway) && self.enforceAtGateway == true)' + database: + description: |- + Database requests a managed PostgreSQL database for this application. + When enabled, the operator provisions a CloudNativePG Cluster named + "-db" and writes connection credentials to the Secret + "-db-credentials" with keys: host, port, username, password, + database, uri. Read access is scoped to the app's ServiceAccount. + + Requires the CloudNativePG operator on the cluster (in Nebari, NIC's + top-level database.enabled toggle installs it). + + Disabling later stops management but never deletes the database. + Deleting the NebariApp deletes the database and its data via owner + references. + properties: + enabled: + default: false + description: Enabled determines whether a managed database is + provisioned. + type: boolean + instances: + default: 1 + description: Instances is the number of PostgreSQL instances (1 + primary plus N-1 replicas, maximum 9). + format: int32 + maximum: 9 + minimum: 1 + type: integer + provider: + default: cloudnativepg + description: Provider selects the database operator backing this + request. + enum: + - cloudnativepg + type: string + size: + default: 1Gi + description: |- + Size is the storage request for each instance, as a Kubernetes quantity. + Size cannot be decreased once set (CloudNativePG restriction). + pattern: ^[0-9]+(\.[0-9]+)?(Ki|Mi|Gi|Ti|Pi|Ei|k|M|G|T|P|E)?$ + type: string + x-kubernetes-validations: + - message: size must be greater than zero + rule: sign(quantity(self)) > 0 + type: object gateway: default: public description: |- @@ -572,8 +618,8 @@ spec: serviceAccountName: description: |- ServiceAccountName is the name of the Kubernetes ServiceAccount used by the - app's pods. Used for RBAC scoping of OIDC secrets so only the app's pods - can read its credentials. Defaults to the NebariApp's name if omitted. + app's pods. Used for RBAC scoping of OIDC and database credentials secrets + so only the app's pods can read them. Defaults to the NebariApp's name if omitted. minLength: 1 type: string required: @@ -613,6 +659,7 @@ spec: - "RoutingReady": HTTPRoute has been created and is functioning - "TLSReady": TLS certificate is available and configured - "AuthReady": Authentication policy is configured (if auth is enabled) + - "DatabaseReady": Managed database is provisioned and credentials are available (if database is enabled) - "Ready": All components are ready (aggregate condition) items: description: Condition contains details for one aspect of the current @@ -672,6 +719,20 @@ spec: x-kubernetes-list-map-keys: - type x-kubernetes-list-type: map + databaseSecretRef: + description: |- + DatabaseSecretRef identifies the Secret containing database connection + credentials (keys: host, port, username, password, database, uri). + properties: + name: + description: Name of the resource. + type: string + namespace: + description: Namespace of the resource (if namespaced). + type: string + required: + - name + type: object gatewayRef: description: GatewayRef identifies the Gateway resource that routes traffic to this application. From f710c65b70b67ce9b36e206ea4460b8e97503971 Mon Sep 17 00:00:00 2001 From: Tyler Potts <49161327+tylerpotts@users.noreply.github.com> Date: Fri, 10 Jul 2026 16:21:53 -0500 Subject: [PATCH 2/7] feat(naming): database cluster and credentials secret names Part of https://github.com/nebari-dev/nebari-infrastructure-core/issues/303 --- .../controller/utils/constants/constants.go | 3 ++ internal/controller/utils/naming/naming.go | 40 +++++++++++++++++++ .../controller/utils/naming/naming_test.go | 37 +++++++++++++++++ 3 files changed, 80 insertions(+) diff --git a/internal/controller/utils/constants/constants.go b/internal/controller/utils/constants/constants.go index 7c826f7..bed6e0d 100644 --- a/internal/controller/utils/constants/constants.go +++ b/internal/controller/utils/constants/constants.go @@ -56,6 +56,9 @@ const ( // ClientSecretSuffix is appended to NebariApp name for OIDC client secret resources ClientSecretSuffix = "oidc-client" + + // DatabaseSuffix is appended to the NebariApp name to form the CloudNativePG Cluster name. + DatabaseSuffix = "db" ) // Annotation constants diff --git a/internal/controller/utils/naming/naming.go b/internal/controller/utils/naming/naming.go index fc3e5d7..cfc2c61 100644 --- a/internal/controller/utils/naming/naming.go +++ b/internal/controller/utils/naming/naming.go @@ -25,6 +25,9 @@ func ValidateResourceNames(nebariApp *appsv1.NebariApp) error { {"CertificateSecret", CertificateSecretName(nebariApp)}, {"GatewayListener", ListenerName(nebariApp)}, {"OIDCClientSecret", ClientSecretName(nebariApp)}, + {"DatabaseCluster", DatabaseClusterName(nebariApp)}, + {"DatabaseAppSecret", DatabaseAppSecretName(nebariApp)}, + {"DatabaseSecret", DatabaseSecretName(nebariApp)}, } for _, c := range checks { @@ -112,3 +115,40 @@ func GatewayName(nebariApp *appsv1.NebariApp) string { } return constants.PublicGatewayName } + +// DatabaseClusterName returns the name of the CloudNativePG Cluster resource +// provisioned for a NebariApp's managed database. +func DatabaseClusterName(nebariApp *appsv1.NebariApp) string { + return ResourceName(nebariApp, constants.DatabaseSuffix) +} + +// DatabaseAppSecretName returns the name of the connection Secret that +// CloudNativePG generates for the Cluster's application user +// ("-app" by CNPG convention). +func DatabaseAppSecretName(nebariApp *appsv1.NebariApp) string { + return DatabaseClusterName(nebariApp) + "-app" +} + +// DatabaseSecretName returns the name of the normalized database credentials +// Secret the operator writes for the app (keys: host, port, username, +// password, database, uri). +func DatabaseSecretName(nebariApp *appsv1.NebariApp) string { + return DatabaseClusterName(nebariApp) + "-credentials" +} + +// maxCNPGClusterNameLength is CloudNativePG's validating-webhook cap on +// Cluster names: the name seeds Service names like "-rw" that must +// fit 63-character DNS labels, so CNPG rejects names longer than 50. +const maxCNPGClusterNameLength = 50 + +// ValidateDatabaseClusterName rejects NebariApp names whose derived CNPG +// Cluster name would be refused by CloudNativePG's admission webhook. Called +// only when spec.database is enabled so long names without a database stay +// valid. +func ValidateDatabaseClusterName(nebariApp *appsv1.NebariApp) error { + name := DatabaseClusterName(nebariApp) + if len(name) > maxCNPGClusterNameLength { + return fmt.Errorf("database cluster name %q is %d characters; CloudNativePG requires at most %d (shorten the NebariApp name)", name, len(name), maxCNPGClusterNameLength) + } + return nil +} diff --git a/internal/controller/utils/naming/naming_test.go b/internal/controller/utils/naming/naming_test.go index 3ae4f38..d012141 100644 --- a/internal/controller/utils/naming/naming_test.go +++ b/internal/controller/utils/naming/naming_test.go @@ -265,6 +265,12 @@ func TestValidateResourceNames(t *testing.T) { namespace: strings.Repeat("b", 60), expectError: false, }, + { + name: "name that only exceeds limit via database credentials secret", + appName: strings.Repeat("a", 240), + namespace: "x", + expectError: true, + }, } for _, tt := range tests { @@ -308,3 +314,34 @@ func TestGatewayName(t *testing.T) { }) } } + +func TestDatabaseNames(t *testing.T) { + app := &appsv1.NebariApp{ + ObjectMeta: metav1.ObjectMeta{Name: "myapp", Namespace: "team-a"}, + } + + if got := DatabaseClusterName(app); got != "myapp-db" { + t.Errorf("DatabaseClusterName() = %q, want %q", got, "myapp-db") + } + if got := DatabaseAppSecretName(app); got != "myapp-db-app" { + t.Errorf("DatabaseAppSecretName() = %q, want %q", got, "myapp-db-app") + } + if got := DatabaseSecretName(app); got != "myapp-db-credentials" { + t.Errorf("DatabaseSecretName() = %q, want %q", got, "myapp-db-credentials") + } +} + +func TestValidateDatabaseClusterName(t *testing.T) { + ok := &appsv1.NebariApp{ObjectMeta: metav1.ObjectMeta{Name: strings.Repeat("a", 47)}} + if err := ValidateDatabaseClusterName(ok); err != nil { + t.Errorf("47-char name (50-char cluster name) should validate, got: %v", err) + } + long := &appsv1.NebariApp{ObjectMeta: metav1.ObjectMeta{Name: strings.Repeat("a", 48)}} + err := ValidateDatabaseClusterName(long) + if err == nil { + t.Fatal("48-char name (51-char cluster name) must fail") + } + if !strings.Contains(err.Error(), "50") { + t.Errorf("error should state the 50-character limit, got: %v", err) + } +} From 48b6c73490014927f0db09774ffcc09ef05604aa Mon Sep 17 00:00:00 2001 From: Tyler Potts <49161327+tylerpotts@users.noreply.github.com> Date: Fri, 10 Jul 2026 16:40:42 -0500 Subject: [PATCH 3/7] feat(database): CNPG-backed database reconciler Provisions a CloudNativePG Cluster per NebariApp with spec.database enabled, polls readiness (no informer: the CNPG CRDs are optional infrastructure and a watch on a missing CRD breaks manager startup), normalizes CNPG's generated app secret into -db-credentials (host, port, username, password, database, uri), and scopes read access to the app's ServiceAccount. Validates the 50-char CNPG cluster-name cap up front. Missing CNPG degrades to a DatabaseReady=False/CNPGNotInstalled condition. Disabling never deletes the database; deleting the NebariApp cascades via owner references. Pinned to cloudnative-pg/api v1.28.0, the newest types-only release whose transitive deps stay on k8s.io 0.34 (v1.28.1+ forces 0.35+); go mod tidy incidentally bumps ginkgo/gomega test deps. Part of https://github.com/nebari-dev/nebari-infrastructure-core/issues/303 --- go.mod | 19 +- go.sum | 58 ++- .../controller/reconcilers/database/rbac.go | 91 ++++ .../reconcilers/database/reconciler.go | 249 ++++++++++ .../reconcilers/database/reconciler_test.go | 433 ++++++++++++++++++ .../controller/reconcilers/database/secret.go | 92 ++++ 6 files changed, 922 insertions(+), 20 deletions(-) create mode 100644 internal/controller/reconcilers/database/rbac.go create mode 100644 internal/controller/reconcilers/database/reconciler.go create mode 100644 internal/controller/reconcilers/database/reconciler_test.go create mode 100644 internal/controller/reconcilers/database/secret.go diff --git a/go.mod b/go.mod index 3709dc1..93bf1ff 100644 --- a/go.mod +++ b/go.mod @@ -5,11 +5,14 @@ go 1.25.6 require ( github.com/Nerzal/gocloak/v13 v13.9.0 github.com/cert-manager/cert-manager v1.18.6 + // v1.28.0 is the newest cloudnative-pg/api whose transitive deps stay on + // k8s.io 0.34; v1.28.1 and v1.29+ force k8s.io 0.35+. + github.com/cloudnative-pg/api v1.28.0 github.com/envoyproxy/gateway v1.6.3 - github.com/onsi/ginkgo/v2 v2.23.4 - github.com/onsi/gomega v1.37.0 - k8s.io/api v0.34.1 - k8s.io/apimachinery v0.34.1 + github.com/onsi/ginkgo/v2 v2.27.2 + github.com/onsi/gomega v1.38.2 + k8s.io/api v0.34.2 + k8s.io/apimachinery v0.34.2 k8s.io/client-go v0.34.1 sigs.k8s.io/controller-runtime v0.22.4 sigs.k8s.io/gateway-api v1.4.1 @@ -17,11 +20,14 @@ require ( require ( cel.dev/expr v0.24.0 // indirect + github.com/Masterminds/semver/v3 v3.4.0 // indirect github.com/antlr4-go/antlr/v4 v4.13.1 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/blang/semver/v4 v4.0.0 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/cloudnative-pg/barman-cloud v0.3.3 // indirect + github.com/cloudnative-pg/machinery v0.3.1 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/emicklei/go-restful/v3 v3.13.0 // indirect github.com/evanphx/json-patch v5.9.11+incompatible // indirect @@ -57,6 +63,7 @@ require ( github.com/opentracing/opentracing-go v1.2.0 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/prometheus-operator/prometheus-operator/pkg/apis/monitoring v0.86.2 // indirect github.com/prometheus/client_golang v1.23.2 // indirect github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.67.1 // indirect @@ -75,12 +82,12 @@ require ( go.opentelemetry.io/otel/sdk v1.38.0 // indirect go.opentelemetry.io/otel/trace v1.38.0 // indirect go.opentelemetry.io/proto/otlp v1.8.0 // indirect - go.uber.org/automaxprocs v1.6.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.0 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/exp v0.0.0-20250718183923-645b1fa84792 // indirect + golang.org/x/mod v0.29.0 // indirect golang.org/x/net v0.47.0 // indirect golang.org/x/oauth2 v0.32.0 // indirect golang.org/x/sync v0.18.0 // indirect @@ -102,7 +109,7 @@ require ( k8s.io/component-base v0.34.1 // indirect k8s.io/klog/v2 v2.130.1 // indirect k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 // indirect - k8s.io/utils v0.0.0-20250820121507-0af2bda4dd1d // indirect + k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 // indirect sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.33.0 // indirect sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect sigs.k8s.io/randfill v1.0.0 // indirect diff --git a/go.sum b/go.sum index 7c414e3..96d63dd 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ cel.dev/expr v0.24.0 h1:56OvJKSH3hDGL0ml5uSxZmz3/3Pq4tJ+fb1unVLAFcY= cel.dev/expr v0.24.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw= +github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= +github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/Nerzal/gocloak/v13 v13.9.0 h1:YWsJsdM5b0yhM2Ba3MLydiOlujkBry4TtdzfIzSVZhw= github.com/Nerzal/gocloak/v13 v13.9.0/go.mod h1:YYuDcXZ7K2zKECyVP7pPqjKxx2AzYSpKDj8d6GuyM10= github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= @@ -14,6 +16,12 @@ github.com/cert-manager/cert-manager v1.18.6 h1:Upk+QQCHbWFmjCxuLKgd1yEyK0KF9JRO github.com/cert-manager/cert-manager v1.18.6/go.mod h1:HbPSO5MW/44wu19t84eY/K4c4/WwyPB4bA3uffOH92s= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cloudnative-pg/api v1.28.0 h1:xElzHliO0eKkVQafkfMhDJo0aIRCmB1ItEt+SGh6B58= +github.com/cloudnative-pg/api v1.28.0/go.mod h1:puXJBOsEaJd8JLgvCtxgl2TO/ZANap/z7bPepKRUgrk= +github.com/cloudnative-pg/barman-cloud v0.3.3 h1:EEcjeV+IUivDpmyF/H/XGY1pGaKJ5LS5MYeB6wgGcak= +github.com/cloudnative-pg/barman-cloud v0.3.3/go.mod h1:5CM4MncAxAjnqxjDt0I5E/oVd7gsMLL0/o/wQ+vUSgs= +github.com/cloudnative-pg/machinery v0.3.1 h1:KtPA6EwELTUNisCMLiFYkK83GU9606rkGQhDJGPB8Yw= +github.com/cloudnative-pg/machinery v0.3.1/go.mod h1:jebuqKxZAbrRKDEEpVCIDMKW+FbWtB9Kf/hb2kMUu9o= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -33,6 +41,12 @@ github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/gkampitakis/ciinfo v0.3.2 h1:JcuOPk8ZU7nZQjdUhctuhQofk7BGHuIy0c9Ez8BNhXs= +github.com/gkampitakis/ciinfo v0.3.2/go.mod h1:1NIwaOcFChN4fa/B0hEBdAb6npDlFL8Bwx4dfRLRqAo= +github.com/gkampitakis/go-diff v1.3.2 h1:Qyn0J9XJSDTgnsgHRdz9Zp24RaJeKMUHg2+PDZZdC4M= +github.com/gkampitakis/go-diff v1.3.2/go.mod h1:LLgOrpqleQe26cte8s36HTWcTmMEur6OPYerdAAS9tk= +github.com/gkampitakis/go-snaps v0.5.15 h1:amyJrvM1D33cPHwVrjo9jQxX8g/7E2wYdZ+01KS3zGE= +github.com/gkampitakis/go-snaps v0.5.15/go.mod h1:HNpx/9GoKisdhw9AFOBT1N7DBs9DiHo/hGheFGBZ+mc= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= @@ -52,6 +66,8 @@ github.com/go-resty/resty/v2 v2.7.0 h1:me+K9p3uhSmXtrBZ4k9jcEAfJmuC8IivWHwaLZwPr github.com/go-resty/resty/v2 v2.7.0/go.mod h1:9PWDzw47qPphMRFfhsyk0NnSgvluHcljSMVIq3w7q0I= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= +github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= +github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= @@ -79,6 +95,8 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/joshdk/go-junit v1.0.0 h1:S86cUKIdwBHWwA6xCmFlf3RTLfVXYQfvanM5Uh+K6GE= +github.com/joshdk/go-junit v1.0.0/go.mod h1:TiiV0PqkaNfFXjEiyjWM3XXrhVyCa1K4Zfga6W52ung= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= @@ -93,6 +111,10 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0 github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= +github.com/maruel/natural v1.1.1 h1:Hja7XhhmvEFhcByqDoHz9QZbkWey+COd9xWfCfn1ioo= +github.com/maruel/natural v1.1.1/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg= +github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE= +github.com/mfridman/tparse v0.18.0/go.mod h1:gEvqZTuCgEhPbYk/2lS3Kcxg1GmTxxU7kTC8DvP0i/A= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -101,10 +123,10 @@ github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFd github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= -github.com/onsi/ginkgo/v2 v2.23.4 h1:ktYTpKJAVZnDT4VjxSbiBenUjmlL/5QkBEocaWXiQus= -github.com/onsi/ginkgo/v2 v2.23.4/go.mod h1:Bt66ApGPBFzHyR+JO10Zbt0Gsp4uWxu5mIOTusL46e8= -github.com/onsi/gomega v1.37.0 h1:CdEG8g0S133B4OswTDC/5XPSzE1OeP29QOioj2PID2Y= -github.com/onsi/gomega v1.37.0/go.mod h1:8D9+Txp43QWKhM24yyOBEdpkzN8FvJyAwecBgsU4KU0= +github.com/onsi/ginkgo/v2 v2.27.2 h1:LzwLj0b89qtIy6SSASkzlNvX6WktqurSHwkk2ipF/Ns= +github.com/onsi/ginkgo/v2 v2.27.2/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo= +github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A= +github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k= github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs= github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= @@ -112,8 +134,8 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g= -github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U= +github.com/prometheus-operator/prometheus-operator/pkg/apis/monitoring v0.86.2 h1:VRXUgbGmpmjZgFYiUnTwlC+JjfCUs5KKFsorJhI1ZKQ= +github.com/prometheus-operator/prometheus-operator/pkg/apis/monitoring v0.86.2/go.mod h1:nPk0OteXBkbT0CRCa2oZQL1jRLW6RJ2fuIijHypeJdk= github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= @@ -145,6 +167,14 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= +github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= @@ -169,8 +199,6 @@ go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJr go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= go.opentelemetry.io/proto/otlp v1.8.0 h1:fRAZQDcAFHySxpJ1TwlA1cJ4tvcrw7nXl9xWWC8N5CE= go.opentelemetry.io/proto/otlp v1.8.0/go.mod h1:tIeYOeNBU4cvmPqpaji1P+KbB4Oloai8wN4rWzRrFF0= -go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs= -go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= @@ -190,6 +218,8 @@ golang.org/x/exp v0.0.0-20250718183923-645b1fa84792 h1:R9PFI6EUdfVKgwKjZef7QIwGc golang.org/x/exp v0.0.0-20250718183923-645b1fa84792/go.mod h1:A+z0yzpGtvnG90cToK5n2tu8UJVP2XUATh+r+sfOOOc= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= +golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= @@ -253,12 +283,12 @@ gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -k8s.io/api v0.34.1 h1:jC+153630BMdlFukegoEL8E/yT7aLyQkIVuwhmwDgJM= -k8s.io/api v0.34.1/go.mod h1:SB80FxFtXn5/gwzCoN6QCtPD7Vbu5w2n1S0J5gFfTYk= +k8s.io/api v0.34.2 h1:fsSUNZhV+bnL6Aqrp6O7lMTy6o5x2C4XLjnh//8SLYY= +k8s.io/api v0.34.2/go.mod h1:MMBPaWlED2a8w4RSeanD76f7opUoypY8TFYkSM+3XHw= k8s.io/apiextensions-apiserver v0.34.1 h1:NNPBva8FNAPt1iSVwIE0FsdrVriRXMsaWFMqJbII2CI= k8s.io/apiextensions-apiserver v0.34.1/go.mod h1:hP9Rld3zF5Ay2Of3BeEpLAToP+l4s5UlxiHfqRaRcMc= -k8s.io/apimachinery v0.34.1 h1:dTlxFls/eikpJxmAC7MVE8oOeP1zryV7iRyIjB0gky4= -k8s.io/apimachinery v0.34.1/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw= +k8s.io/apimachinery v0.34.2 h1:zQ12Uk3eMHPxrsbUJgNF8bTauTVR2WgqJsTmwTE/NW4= +k8s.io/apimachinery v0.34.2/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw= k8s.io/apiserver v0.34.1 h1:U3JBGdgANK3dfFcyknWde1G6X1F4bg7PXuvlqt8lITA= k8s.io/apiserver v0.34.1/go.mod h1:eOOc9nrVqlBI1AFCvVzsob0OxtPZUCPiUJL45JOTBG0= k8s.io/client-go v0.34.1 h1:ZUPJKgXsnKwVwmKKdPfw4tB58+7/Ik3CrjOEhsiZ7mY= @@ -269,8 +299,8 @@ k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 h1:Y3gxNAuB0OBLImH611+UDZcmKS3g6CthxToOb37KgwE= k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ= -k8s.io/utils v0.0.0-20250820121507-0af2bda4dd1d h1:wAhiDyZ4Tdtt7e46e9M5ZSAJ/MnPGPs+Ki1gHw4w1R0= -k8s.io/utils v0.0.0-20250820121507-0af2bda4dd1d/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 h1:SjGebBtkBqHFOli+05xYbK8YF1Dzkbzn+gDM4X9T4Ck= +k8s.io/utils v0.0.0-20251002143259-bc988d571ff4/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.33.0 h1:qPrZsv1cwQiFeieFlRqT627fVZ+tyfou/+S5S0H5ua0= sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.33.0/go.mod h1:Ve9uj1L+deCXFrPOk1LpFXqTg7LCFzFso6PA48q/XZw= sigs.k8s.io/controller-runtime v0.22.4 h1:GEjV7KV3TY8e+tJ2LCTxUTanW4z/FmNB7l327UfMq9A= diff --git a/internal/controller/reconcilers/database/rbac.go b/internal/controller/reconcilers/database/rbac.go new file mode 100644 index 0000000..37ab0ed --- /dev/null +++ b/internal/controller/reconcilers/database/rbac.go @@ -0,0 +1,91 @@ +/* +Copyright 2026, OpenTeams. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package database + +import ( + "context" + "fmt" + + rbacv1 "k8s.io/api/rbac/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/controller-runtime/pkg/log" + + appsv1 "github.com/nebari-dev/nebari-operator/api/v1" + "github.com/nebari-dev/nebari-operator/internal/controller/utils/naming" +) + +// reconcileSecretRBAC creates or updates a Role and RoleBinding that scopes +// read access to the database credentials Secret to the app's ServiceAccount. +func (r *DatabaseReconciler) reconcileSecretRBAC(ctx context.Context, nebariApp *appsv1.NebariApp) error { + logger := log.FromContext(ctx) + + secretName := naming.DatabaseSecretName(nebariApp) + rbacName := fmt.Sprintf("%s-db-secret-reader", nebariApp.Name) + + saName := nebariApp.Spec.ServiceAccountName + if saName == "" { + saName = nebariApp.Name + } + + role := &rbacv1.Role{ + ObjectMeta: metav1.ObjectMeta{ + Name: rbacName, + Namespace: nebariApp.Namespace, + }, + } + if _, err := controllerutil.CreateOrUpdate(ctx, r.Client, role, func() error { + role.Rules = []rbacv1.PolicyRule{ + { + APIGroups: []string{""}, + Resources: []string{"secrets"}, + ResourceNames: []string{secretName}, + Verbs: []string{"get"}, + }, + } + return controllerReference(nebariApp, role, r.Scheme) + }); err != nil { + return fmt.Errorf("failed to reconcile database secret reader Role: %w", err) + } + + rb := &rbacv1.RoleBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: rbacName, + Namespace: nebariApp.Namespace, + }, + } + if _, err := controllerutil.CreateOrUpdate(ctx, r.Client, rb, func() error { + rb.RoleRef = rbacv1.RoleRef{ + APIGroup: "rbac.authorization.k8s.io", + Kind: "Role", + Name: rbacName, + } + rb.Subjects = []rbacv1.Subject{ + { + Kind: "ServiceAccount", + Name: saName, + Namespace: nebariApp.Namespace, + }, + } + return controllerReference(nebariApp, rb, r.Scheme) + }); err != nil { + return fmt.Errorf("failed to reconcile database secret reader RoleBinding: %w", err) + } + + logger.Info("Reconciled database secret RBAC", "name", rbacName, "serviceAccount", saName) + return nil +} diff --git a/internal/controller/reconcilers/database/reconciler.go b/internal/controller/reconcilers/database/reconciler.go new file mode 100644 index 0000000..2ad887c --- /dev/null +++ b/internal/controller/reconcilers/database/reconciler.go @@ -0,0 +1,249 @@ +/* +Copyright 2026, OpenTeams. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package database + +import ( + "context" + "fmt" + "time" + + cnpgv1 "github.com/cloudnative-pg/api/pkg/api/v1" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + apimeta "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/tools/record" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/controller-runtime/pkg/log" + + appsv1 "github.com/nebari-dev/nebari-operator/api/v1" + "github.com/nebari-dev/nebari-operator/internal/controller/utils/conditions" + "github.com/nebari-dev/nebari-operator/internal/controller/utils/naming" +) + +const ( + // provisioningRequeue is how often to poll a CNPG Cluster that is not + // ready yet. There is deliberately no startup Watches on CNPG types (the + // CRDs are optional infrastructure and a watch on a missing CRD would + // fail manager startup); the cached client may still start an informer + // lazily once the CRD exists, which is fine. + provisioningRequeue = 15 * time.Second + + // settledRequeue is how often to re-check states that only change + // through external action: the CNPG CRDs being installed, or an invalid + // app name being renamed (which also triggers reconciliation on its own). + settledRequeue = 5 * time.Minute +) + +// DatabaseReconciler provisions managed PostgreSQL databases for NebariApps +// through CloudNativePG. +type DatabaseReconciler struct { + Client client.Client + Scheme *runtime.Scheme + Recorder record.EventRecorder +} + +// ReconcileDatabase handles spec.database for a NebariApp. A non-nil result +// means the caller should persist status and return it (polling while the +// database provisions, or backing off on settled non-ready states). A nil +// result with nil error means the database subsystem is settled and healthy +// (ready, disabled, or not configured). +// Once ready, the normalized credentials Secret is refreshed only when +// this function runs again; keeping it in sync with CNPG password +// rotation therefore relies on the caller reconciling periodically or +// watching the CNPG-generated source Secret. +func (r *DatabaseReconciler) ReconcileDatabase(ctx context.Context, nebariApp *appsv1.NebariApp) (*ctrl.Result, error) { + db := nebariApp.Spec.Database + if db == nil || !db.Enabled { + return nil, r.handleDisabled(ctx, nebariApp) + } + + // CNPG's admission webhook caps Cluster names at 50 characters; fail + // with a friendly preflight message instead of a webhook rejection + // buried in reconcile logs. + if err := naming.ValidateDatabaseClusterName(nebariApp); err != nil { + // Emit the event only on transition into this exact failure to avoid + // spamming an identical warning on every slow requeue. + if cond := conditions.GetCondition(nebariApp, appsv1.ConditionTypeDatabaseReady); cond == nil || + cond.Reason != appsv1.ReasonFailed || cond.Message != err.Error() { + r.Recorder.Event(nebariApp, corev1.EventTypeWarning, appsv1.EventReasonDatabaseProvisionFailed, err.Error()) + } + conditions.SetCondition(nebariApp, appsv1.ConditionTypeDatabaseReady, metav1.ConditionFalse, + appsv1.ReasonFailed, err.Error()) + return &ctrl.Result{RequeueAfter: settledRequeue}, nil + } + + cluster, result, err := r.reconcileCluster(ctx, nebariApp) + if result != nil || err != nil { + return result, err + } + + if !apimeta.IsStatusConditionTrue(cluster.Status.Conditions, string(cnpgv1.ConditionClusterReady)) { + msg := "Waiting for the database cluster to become ready" + if cluster.Status.Phase != "" { + msg = fmt.Sprintf("%s (phase: %s)", msg, cluster.Status.Phase) + } + conditions.SetCondition(nebariApp, appsv1.ConditionTypeDatabaseReady, metav1.ConditionFalse, + appsv1.ReasonDatabaseProvisioning, msg) + return &ctrl.Result{RequeueAfter: provisioningRequeue}, nil + } + + if requeue, err := r.reconcileCredentialsSecret(ctx, nebariApp); requeue || err != nil { + if err != nil { + r.recordFailure(nebariApp, err) + return nil, err + } + conditions.SetCondition(nebariApp, appsv1.ConditionTypeDatabaseReady, metav1.ConditionFalse, + appsv1.ReasonDatabaseProvisioning, "Waiting for CloudNativePG to publish connection credentials") + return &ctrl.Result{RequeueAfter: provisioningRequeue}, nil + } + + if err := r.reconcileSecretRBAC(ctx, nebariApp); err != nil { + r.recordFailure(nebariApp, err) + return nil, err + } + + nebariApp.Status.DatabaseSecretRef = &appsv1.ResourceReference{ + Name: naming.DatabaseSecretName(nebariApp), + Namespace: nebariApp.Namespace, + } + + // Emit the provisioned event only on the transition to ready. + if !conditions.IsConditionTrue(nebariApp, appsv1.ConditionTypeDatabaseReady) { + r.Recorder.Event(nebariApp, corev1.EventTypeNormal, appsv1.EventReasonDatabaseProvisioned, + fmt.Sprintf("Managed database %s is ready; credentials in Secret %s", + naming.DatabaseClusterName(nebariApp), naming.DatabaseSecretName(nebariApp))) + } + conditions.SetCondition(nebariApp, appsv1.ConditionTypeDatabaseReady, metav1.ConditionTrue, + appsv1.ReasonAvailable, "Database is ready and credentials are available") + return nil, nil +} + +// reconcileCluster creates or updates the CNPG Cluster. A non-nil result is +// returned when CNPG is not installed (degrade to condition + slow requeue). +func (r *DatabaseReconciler) reconcileCluster(ctx context.Context, nebariApp *appsv1.NebariApp) (*cnpgv1.Cluster, *ctrl.Result, error) { + logger := log.FromContext(ctx) + db := nebariApp.Spec.Database + + instances := int(db.Instances) + if instances < 1 { + instances = 1 + } + size := db.Size + if size == "" { + size = "1Gi" + } + + cluster := &cnpgv1.Cluster{ + ObjectMeta: metav1.ObjectMeta{ + Name: naming.DatabaseClusterName(nebariApp), + Namespace: nebariApp.Namespace, + }, + } + op, err := controllerutil.CreateOrUpdate(ctx, r.Client, cluster, func() error { + if cluster.Labels == nil { + cluster.Labels = map[string]string{} + } + cluster.Labels["nebari.dev/nebariapp-name"] = nebariApp.Name + cluster.Labels["nebari.dev/nebariapp-namespace"] = nebariApp.Namespace + cluster.Spec.Instances = instances + cluster.Spec.StorageConfiguration.Size = size + return controllerReference(nebariApp, cluster, r.Scheme) + }) + if err != nil { + if apimeta.IsNoMatchError(err) || runtime.IsNotRegisteredError(err) { + conditions.SetCondition(nebariApp, appsv1.ConditionTypeDatabaseReady, metav1.ConditionFalse, + appsv1.ReasonCNPGNotInstalled, + "CloudNativePG is not installed on this cluster; in Nebari, set the top-level database.enabled toggle in the NIC config to install it") + return nil, &ctrl.Result{RequeueAfter: settledRequeue}, nil + } + r.recordFailure(nebariApp, err) + return nil, nil, fmt.Errorf("failed to reconcile CNPG Cluster: %w", err) + } + if op != controllerutil.OperationResultNone { + logger.Info("Reconciled CNPG Cluster", "cluster", cluster.Name, "operation", op) + } + return cluster, nil, nil +} + +// handleDisabled covers spec.database nil or enabled: false. A previously +// provisioned Cluster is deliberately never deleted by the toggle: the +// operator only stops managing it and surfaces that through the condition +// and an event. Deleting the NebariApp still cascades via owner references. +func (r *DatabaseReconciler) handleDisabled(ctx context.Context, nebariApp *appsv1.NebariApp) error { + // Without any status evidence of a previous database there is nothing to + // orphan-check; skipping the Get also avoids lazily starting a + // cluster-wide CNPG informer for the many apps that never use a database. + if nebariApp.Status.DatabaseSecretRef == nil && conditions.GetCondition(nebariApp, appsv1.ConditionTypeDatabaseReady) == nil { + return nil + } + + cluster := &cnpgv1.Cluster{} + err := r.Client.Get(ctx, types.NamespacedName{ + Name: naming.DatabaseClusterName(nebariApp), + Namespace: nebariApp.Namespace, + }, cluster) + if err != nil { + // Not found, CRD missing, or CNPG types not registered: nothing was + // ever provisioned (or CNPG is gone entirely). Clear any stale + // condition and finish. + if apierrors.IsNotFound(err) || apimeta.IsNoMatchError(err) || runtime.IsNotRegisteredError(err) { + apimeta.RemoveStatusCondition(&nebariApp.Status.Conditions, appsv1.ConditionTypeDatabaseReady) + nebariApp.Status.DatabaseSecretRef = nil + return nil + } + return fmt.Errorf("failed to check for existing database cluster: %w", err) + } + + // A same-named Cluster we do not own is none of our business; clear any + // stale condition left from a failed attempt against it. + if !metav1.IsControlledBy(cluster, nebariApp) { + apimeta.RemoveStatusCondition(&nebariApp.Status.Conditions, appsv1.ConditionTypeDatabaseReady) + nebariApp.Status.DatabaseSecretRef = nil + return nil + } + + if cond := conditions.GetCondition(nebariApp, appsv1.ConditionTypeDatabaseReady); cond == nil || cond.Reason != appsv1.ReasonDatabaseDisabled { + r.Recorder.Event(nebariApp, corev1.EventTypeWarning, appsv1.EventReasonDatabaseOrphaned, + fmt.Sprintf("database.enabled is false but Cluster %s still exists; it is never deleted by the toggle. Delete the Cluster resource to remove the database and its data", cluster.Name)) + } + conditions.SetCondition(nebariApp, appsv1.ConditionTypeDatabaseReady, metav1.ConditionFalse, + appsv1.ReasonDatabaseDisabled, + fmt.Sprintf("Database management is disabled; Cluster %s is retained and no longer managed", cluster.Name)) + return nil +} + +func (r *DatabaseReconciler) recordFailure(nebariApp *appsv1.NebariApp, err error) { + // Emit the event only on transition into this exact failure to avoid + // spamming an identical warning on every retry. + if cond := conditions.GetCondition(nebariApp, appsv1.ConditionTypeDatabaseReady); cond == nil || + cond.Reason != appsv1.ReasonFailed || cond.Message != err.Error() { + r.Recorder.Event(nebariApp, corev1.EventTypeWarning, appsv1.EventReasonDatabaseProvisionFailed, err.Error()) + } + conditions.SetCondition(nebariApp, appsv1.ConditionTypeDatabaseReady, metav1.ConditionFalse, + appsv1.ReasonFailed, err.Error()) +} + +// controllerReference is a seam shared with tests so fixtures carry the same +// owner reference the reconciler sets. +func controllerReference(owner, object metav1.Object, scheme *runtime.Scheme) error { + return controllerutil.SetControllerReference(owner, object, scheme) +} diff --git a/internal/controller/reconcilers/database/reconciler_test.go b/internal/controller/reconcilers/database/reconciler_test.go new file mode 100644 index 0000000..4fda81e --- /dev/null +++ b/internal/controller/reconcilers/database/reconciler_test.go @@ -0,0 +1,433 @@ +/* +Copyright 2026, OpenTeams. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package database + +import ( + "context" + "strings" + "testing" + "time" + + cnpgv1 "github.com/cloudnative-pg/api/pkg/api/v1" + corev1 "k8s.io/api/core/v1" + rbacv1 "k8s.io/api/rbac/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" + "k8s.io/client-go/tools/record" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + appsv1 "github.com/nebari-dev/nebari-operator/api/v1" + "github.com/nebari-dev/nebari-operator/internal/controller/utils/conditions" +) + +func newScheme(t *testing.T, withCNPG bool) *runtime.Scheme { + t.Helper() + scheme := runtime.NewScheme() + if err := clientgoscheme.AddToScheme(scheme); err != nil { + t.Fatalf("add client-go scheme: %v", err) + } + if err := appsv1.AddToScheme(scheme); err != nil { + t.Fatalf("add appsv1 scheme: %v", err) + } + if withCNPG { + if err := cnpgv1.AddToScheme(scheme); err != nil { + t.Fatalf("add cnpg scheme: %v", err) + } + } + return scheme +} + +func newApp(database *appsv1.DatabaseConfig) *appsv1.NebariApp { + return &appsv1.NebariApp{ + ObjectMeta: metav1.ObjectMeta{Name: "myapp", Namespace: "team-a", UID: "uid-1"}, + Spec: appsv1.NebariAppSpec{ + Hostname: "myapp.example.com", + Service: appsv1.ServiceReference{Name: "myapp", Port: 8080}, + Database: database, + }, + } +} + +func newReconciler(c client.Client, scheme *runtime.Scheme) *DatabaseReconciler { + return &DatabaseReconciler{ + Client: c, + Scheme: scheme, + Recorder: record.NewFakeRecorder(16), + } +} + +// readyCluster returns a CNPG Cluster as the reconciler would have created it, +// with the Ready condition set (as the CNPG operator would in a real cluster). +func readyCluster(app *appsv1.NebariApp, scheme *runtime.Scheme, t *testing.T) *cnpgv1.Cluster { + t.Helper() + cluster := &cnpgv1.Cluster{ + ObjectMeta: metav1.ObjectMeta{Name: app.Name + "-db", Namespace: app.Namespace}, + Spec: cnpgv1.ClusterSpec{ + Instances: 1, + StorageConfiguration: cnpgv1.StorageConfiguration{Size: "1Gi"}, + }, + Status: cnpgv1.ClusterStatus{ + Conditions: []metav1.Condition{{ + Type: string(cnpgv1.ConditionClusterReady), + Status: metav1.ConditionTrue, + Reason: "ClusterIsReady", + LastTransitionTime: metav1.Now(), + }}, + }, + } + if err := controllerReference(app, cluster, scheme); err != nil { + t.Fatalf("set owner ref: %v", err) + } + return cluster +} + +func cnpgAppSecret(app *appsv1.NebariApp) *corev1.Secret { + return &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: app.Name + "-db-app", Namespace: app.Namespace}, + Data: map[string][]byte{ + "host": []byte("myapp-db-rw"), + "port": []byte("5432"), + "username": []byte("app"), + "password": []byte("hunter2"), + "dbname": []byte("app"), + "uri": []byte("postgresql://app:hunter2@myapp-db-rw.team-a:5432/app"), + }, + } +} + +func TestReconcileDatabase_CreatesCluster(t *testing.T) { + scheme := newScheme(t, true) + c := fake.NewClientBuilder().WithScheme(scheme).Build() + app := newApp(&appsv1.DatabaseConfig{Enabled: true, Instances: 3, Size: "10Gi"}) + + result, err := newReconciler(c, scheme).ReconcileDatabase(context.Background(), app) + if err != nil { + t.Fatalf("ReconcileDatabase: %v", err) + } + if result == nil || result.RequeueAfter != 15*time.Second { + t.Fatalf("expected 15s provisioning requeue, got %+v", result) + } + + cluster := &cnpgv1.Cluster{} + if err := c.Get(context.Background(), types.NamespacedName{Name: "myapp-db", Namespace: "team-a"}, cluster); err != nil { + t.Fatalf("expected Cluster to be created: %v", err) + } + if cluster.Spec.Instances != 3 { + t.Errorf("Instances = %d, want 3", cluster.Spec.Instances) + } + if cluster.Spec.StorageConfiguration.Size != "10Gi" { + t.Errorf("storage size = %q, want 10Gi", cluster.Spec.StorageConfiguration.Size) + } + if len(cluster.OwnerReferences) != 1 || cluster.OwnerReferences[0].UID != app.UID { + t.Errorf("expected controller owner reference to the NebariApp, got %+v", cluster.OwnerReferences) + } + + cond := conditions.GetCondition(app, appsv1.ConditionTypeDatabaseReady) + if cond == nil || cond.Status != metav1.ConditionFalse || cond.Reason != appsv1.ReasonDatabaseProvisioning { + t.Errorf("expected DatabaseReady=False/DatabaseProvisioning, got %+v", cond) + } +} + +func TestReconcileDatabase_Defaults(t *testing.T) { + scheme := newScheme(t, true) + c := fake.NewClientBuilder().WithScheme(scheme).Build() + // Zero-valued instances/size simulate a spec that skipped API defaulting. + app := newApp(&appsv1.DatabaseConfig{Enabled: true}) + + if _, err := newReconciler(c, scheme).ReconcileDatabase(context.Background(), app); err != nil { + t.Fatalf("ReconcileDatabase: %v", err) + } + + cluster := &cnpgv1.Cluster{} + if err := c.Get(context.Background(), types.NamespacedName{Name: "myapp-db", Namespace: "team-a"}, cluster); err != nil { + t.Fatalf("get cluster: %v", err) + } + if cluster.Spec.Instances != 1 { + t.Errorf("Instances = %d, want default 1", cluster.Spec.Instances) + } + if cluster.Spec.StorageConfiguration.Size != "1Gi" { + t.Errorf("storage size = %q, want default 1Gi", cluster.Spec.StorageConfiguration.Size) + } +} + +func TestReconcileDatabase_ReadyWritesSecretAndRBAC(t *testing.T) { + scheme := newScheme(t, true) + app := newApp(&appsv1.DatabaseConfig{Enabled: true}) + c := fake.NewClientBuilder().WithScheme(scheme). + WithObjects(readyCluster(app, scheme, t), cnpgAppSecret(app)). + Build() + + result, err := newReconciler(c, scheme).ReconcileDatabase(context.Background(), app) + if err != nil { + t.Fatalf("ReconcileDatabase: %v", err) + } + if result != nil { + t.Fatalf("expected completion (nil result), got %+v", result) + } + + secret := &corev1.Secret{} + if err := c.Get(context.Background(), types.NamespacedName{Name: "myapp-db-credentials", Namespace: "team-a"}, secret); err != nil { + t.Fatalf("expected credentials secret: %v", err) + } + want := map[string]string{ + "host": "myapp-db-rw", + "port": "5432", + "username": "app", + "password": "hunter2", + "database": "app", + "uri": "postgresql://app:hunter2@myapp-db-rw.team-a:5432/app", + } + for k, v := range want { + if got := string(secret.Data[k]); got != v { + t.Errorf("credentials[%s] = %q, want %q", k, got, v) + } + } + if len(secret.Data) != len(want) { + t.Errorf("credentials secret has %d keys, want %d (no CNPG-internal keys copied)", len(secret.Data), len(want)) + } + + role := &rbacv1.Role{} + if err := c.Get(context.Background(), types.NamespacedName{Name: "myapp-db-secret-reader", Namespace: "team-a"}, role); err != nil { + t.Fatalf("expected Role: %v", err) + } + if len(role.Rules) != 1 || role.Rules[0].ResourceNames[0] != "myapp-db-credentials" { + t.Errorf("Role rules = %+v, want get on myapp-db-credentials", role.Rules) + } + rb := &rbacv1.RoleBinding{} + if err := c.Get(context.Background(), types.NamespacedName{Name: "myapp-db-secret-reader", Namespace: "team-a"}, rb); err != nil { + t.Fatalf("expected RoleBinding: %v", err) + } + if rb.Subjects[0].Name != "myapp" { + t.Errorf("RoleBinding subject = %q, want the default ServiceAccount name myapp", rb.Subjects[0].Name) + } + + if app.Status.DatabaseSecretRef == nil || app.Status.DatabaseSecretRef.Name != "myapp-db-credentials" { + t.Errorf("status.databaseSecretRef = %+v, want myapp-db-credentials", app.Status.DatabaseSecretRef) + } + cond := conditions.GetCondition(app, appsv1.ConditionTypeDatabaseReady) + if cond == nil || cond.Status != metav1.ConditionTrue || cond.Reason != appsv1.ReasonAvailable { + t.Errorf("expected DatabaseReady=True/Available, got %+v", cond) + } +} + +func TestReconcileDatabase_ReadyButSourceSecretMissing(t *testing.T) { + scheme := newScheme(t, true) + app := newApp(&appsv1.DatabaseConfig{Enabled: true}) + c := fake.NewClientBuilder().WithScheme(scheme). + WithObjects(readyCluster(app, scheme, t)). + Build() + + result, err := newReconciler(c, scheme).ReconcileDatabase(context.Background(), app) + if err != nil { + t.Fatalf("expected requeue, not error: %v", err) + } + if result == nil || result.RequeueAfter != 15*time.Second { + t.Fatalf("expected 15s requeue while CNPG secret is missing, got %+v", result) + } + cond := conditions.GetCondition(app, appsv1.ConditionTypeDatabaseReady) + if cond == nil || cond.Reason != appsv1.ReasonDatabaseProvisioning { + t.Errorf("expected DatabaseProvisioning, got %+v", cond) + } +} + +func TestReconcileDatabase_DisabledKeepsExistingCluster(t *testing.T) { + scheme := newScheme(t, true) + enabledApp := newApp(&appsv1.DatabaseConfig{Enabled: true}) + cluster := readyCluster(enabledApp, scheme, t) + c := fake.NewClientBuilder().WithScheme(scheme). + WithObjects(cluster, cnpgAppSecret(enabledApp)). + Build() + + app := newApp(&appsv1.DatabaseConfig{Enabled: false}) + // Status evidence of the previously provisioned database, as a real app + // that was enabled and then disabled would carry. + conditions.SetCondition(app, appsv1.ConditionTypeDatabaseReady, metav1.ConditionTrue, + appsv1.ReasonAvailable, "Database is ready and credentials are available") + app.Status.DatabaseSecretRef = &appsv1.ResourceReference{Name: "myapp-db-credentials", Namespace: "team-a"} + + result, err := newReconciler(c, scheme).ReconcileDatabase(context.Background(), app) + if err != nil { + t.Fatalf("ReconcileDatabase: %v", err) + } + if result != nil { + t.Fatalf("disable must not requeue, got %+v", result) + } + + // Nothing deleted + got := &cnpgv1.Cluster{} + if err := c.Get(context.Background(), types.NamespacedName{Name: "myapp-db", Namespace: "team-a"}, got); err != nil { + t.Fatalf("cluster must survive disable: %v", err) + } + + cond := conditions.GetCondition(app, appsv1.ConditionTypeDatabaseReady) + if cond == nil || cond.Status != metav1.ConditionFalse || cond.Reason != appsv1.ReasonDatabaseDisabled { + t.Errorf("expected DatabaseReady=False/DatabaseDisabled, got %+v", cond) + } +} + +func TestReconcileDatabase_AbsentBlockNoCondition(t *testing.T) { + scheme := newScheme(t, true) + c := fake.NewClientBuilder().WithScheme(scheme).Build() + app := newApp(nil) + + result, err := newReconciler(c, scheme).ReconcileDatabase(context.Background(), app) + if err != nil || result != nil { + t.Fatalf("expected clean no-op, got result=%+v err=%v", result, err) + } + if cond := conditions.GetCondition(app, appsv1.ConditionTypeDatabaseReady); cond != nil { + t.Errorf("expected no DatabaseReady condition for absent block, got %+v", cond) + } +} + +func TestReconcileDatabase_CNPGNotInstalled(t *testing.T) { + // Scheme WITHOUT cnpg types: the fake client returns a not-registered + // error, standing in for the real cluster's no-CRD NoKindMatchError. + scheme := newScheme(t, false) + c := fake.NewClientBuilder().WithScheme(scheme).Build() + app := newApp(&appsv1.DatabaseConfig{Enabled: true}) + + result, err := newReconciler(c, scheme).ReconcileDatabase(context.Background(), app) + if err != nil { + t.Fatalf("missing CRD must degrade to a condition, not an error: %v", err) + } + if result == nil || result.RequeueAfter != 5*time.Minute { + t.Fatalf("expected 5m requeue, got %+v", result) + } + cond := conditions.GetCondition(app, appsv1.ConditionTypeDatabaseReady) + if cond == nil || cond.Reason != appsv1.ReasonCNPGNotInstalled { + t.Fatalf("expected CNPGNotInstalled, got %+v", cond) + } + if !strings.Contains(cond.Message, "database.enabled") { + t.Errorf("message should point at NIC's database.enabled toggle, got %q", cond.Message) + } +} + +func TestReconcileDatabase_NameTooLong(t *testing.T) { + scheme := newScheme(t, true) + c := fake.NewClientBuilder().WithScheme(scheme).Build() + app := newApp(&appsv1.DatabaseConfig{Enabled: true}) + app.Name = strings.Repeat("a", 48) // cluster name becomes 51 chars, over CNPG's 50 cap + + result, err := newReconciler(c, scheme).ReconcileDatabase(context.Background(), app) + if err != nil { + t.Fatalf("invalid name must degrade to a condition, not an error: %v", err) + } + if result == nil || result.RequeueAfter != 5*time.Minute { + t.Fatalf("expected 5m requeue, got %+v", result) + } + cond := conditions.GetCondition(app, appsv1.ConditionTypeDatabaseReady) + if cond == nil || cond.Status != metav1.ConditionFalse || cond.Reason != appsv1.ReasonFailed { + t.Fatalf("expected DatabaseReady=False/Failed, got %+v", cond) + } + if !strings.Contains(cond.Message, "50") { + t.Errorf("message should state the 50-character limit, got %q", cond.Message) + } + clusters := &cnpgv1.ClusterList{} + if err := c.List(context.Background(), clusters); err != nil { + t.Fatalf("list clusters: %v", err) + } + if len(clusters.Items) != 0 { + t.Errorf("no Cluster may be created for an invalid name, found %d", len(clusters.Items)) + } +} + +func TestReconcileDatabase_DisabledForeignClusterClearsStatus(t *testing.T) { + scheme := newScheme(t, true) + foreign := &cnpgv1.Cluster{ + ObjectMeta: metav1.ObjectMeta{Name: "myapp-db", Namespace: "team-a"}, + } + c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(foreign).Build() + + app := newApp(&appsv1.DatabaseConfig{Enabled: false}) + conditions.SetCondition(app, appsv1.ConditionTypeDatabaseReady, metav1.ConditionFalse, + appsv1.ReasonFailed, "leftover from a failed enable attempt") + app.Status.DatabaseSecretRef = &appsv1.ResourceReference{Name: "stale"} + + result, err := newReconciler(c, scheme).ReconcileDatabase(context.Background(), app) + if err != nil || result != nil { + t.Fatalf("expected clean no-op, got result=%+v err=%v", result, err) + } + if cond := conditions.GetCondition(app, appsv1.ConditionTypeDatabaseReady); cond != nil { + t.Errorf("stale condition must be cleared for a foreign cluster, got %+v", cond) + } + if app.Status.DatabaseSecretRef != nil { + t.Errorf("stale DatabaseSecretRef must be cleared, got %+v", app.Status.DatabaseSecretRef) + } +} + +func TestReconcileDatabase_ReadySteadyStateNoDuplicateEvent(t *testing.T) { + scheme := newScheme(t, true) + app := newApp(&appsv1.DatabaseConfig{Enabled: true}) + c := fake.NewClientBuilder().WithScheme(scheme). + WithObjects(readyCluster(app, scheme, t), cnpgAppSecret(app)). + Build() + recorder := record.NewFakeRecorder(16) + r := &DatabaseReconciler{Client: c, Scheme: scheme, Recorder: recorder} + + for i := 0; i < 2; i++ { + result, err := r.ReconcileDatabase(context.Background(), app) + if err != nil || result != nil { + t.Fatalf("reconcile %d: result=%+v err=%v", i, result, err) + } + } + + events := 0 + for { + select { + case e := <-recorder.Events: + if strings.Contains(e, appsv1.EventReasonDatabaseProvisioned) { + events++ + } + continue + default: + } + break + } + if events != 1 { + t.Errorf("DatabaseProvisioned events = %d, want exactly 1 (first transition only)", events) + } +} + +func TestReconcileDatabase_CustomServiceAccount(t *testing.T) { + scheme := newScheme(t, true) + app := newApp(&appsv1.DatabaseConfig{Enabled: true}) + app.Spec.ServiceAccountName = "custom-sa" + c := fake.NewClientBuilder().WithScheme(scheme). + WithObjects(readyCluster(app, scheme, t), cnpgAppSecret(app)). + Build() + + if _, err := newReconciler(c, scheme).ReconcileDatabase(context.Background(), app); err != nil { + t.Fatalf("ReconcileDatabase: %v", err) + } + rb := &rbacv1.RoleBinding{} + if err := c.Get(context.Background(), types.NamespacedName{Name: "myapp-db-secret-reader", Namespace: "team-a"}, rb); err != nil { + t.Fatalf("expected RoleBinding: %v", err) + } + if rb.Subjects[0].Name != "custom-sa" { + t.Errorf("RoleBinding subject = %q, want custom-sa", rb.Subjects[0].Name) + } +} + +func TestNormalizeCredentials_MissingKey(t *testing.T) { + src := map[string][]byte{"host": []byte("h"), "port": []byte("5432")} + if _, err := normalizeCredentials(src); err == nil { + t.Fatal("expected error for missing source keys") + } +} diff --git a/internal/controller/reconcilers/database/secret.go b/internal/controller/reconcilers/database/secret.go new file mode 100644 index 0000000..fe71a1d --- /dev/null +++ b/internal/controller/reconcilers/database/secret.go @@ -0,0 +1,92 @@ +/* +Copyright 2026, OpenTeams. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package database + +import ( + "context" + "fmt" + + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + + appsv1 "github.com/nebari-dev/nebari-operator/api/v1" + "github.com/nebari-dev/nebari-operator/internal/controller/utils/naming" +) + +// credentialKeys maps the normalized key exposed to packs to the key in the +// Secret CloudNativePG generates for the application user. +var credentialKeys = map[string]string{ + "host": "host", + "port": "port", + "username": "username", + "password": "password", + "database": "dbname", + "uri": "uri", +} + +// reconcileCredentialsSecret copies CNPG's generated app-user Secret into the +// documented "-db-credentials" contract. Returns requeue=true when the +// source Secret does not exist yet (CNPG creates it shortly after the +// Cluster), which is a provisioning state rather than an error. +func (r *DatabaseReconciler) reconcileCredentialsSecret(ctx context.Context, nebariApp *appsv1.NebariApp) (requeue bool, err error) { + source := &corev1.Secret{} + if err := r.Client.Get(ctx, types.NamespacedName{ + Name: naming.DatabaseAppSecretName(nebariApp), + Namespace: nebariApp.Namespace, + }, source); err != nil { + if apierrors.IsNotFound(err) { + return true, nil + } + return false, fmt.Errorf("failed to read CNPG connection secret: %w", err) + } + + data, err := normalizeCredentials(source.Data) + if err != nil { + return false, err + } + + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: naming.DatabaseSecretName(nebariApp), + Namespace: nebariApp.Namespace, + }, + } + if _, err := controllerutil.CreateOrUpdate(ctx, r.Client, secret, func() error { + secret.Type = corev1.SecretTypeOpaque + secret.Data = data + return controllerReference(nebariApp, secret, r.Scheme) + }); err != nil { + return false, fmt.Errorf("failed to reconcile database credentials secret: %w", err) + } + return false, nil +} + +// normalizeCredentials maps CNPG source keys to the documented contract keys. +func normalizeCredentials(source map[string][]byte) (map[string][]byte, error) { + data := make(map[string][]byte, len(credentialKeys)) + for target, from := range credentialKeys { + v, ok := source[from] + if !ok { + return nil, fmt.Errorf("CNPG connection secret is missing key %q", from) + } + data[target] = v + } + return data, nil +} From 693fad0881858787c7f9e6e877608bfb0f39d825 Mon Sep 17 00:00:00 2001 From: Tyler Potts <49161327+tylerpotts@users.noreply.github.com> Date: Fri, 10 Jul 2026 17:06:38 -0500 Subject: [PATCH 4/7] feat(controller): wire the database reconciler Invoked after auth with the TLS-style poll-and-return pattern; scheme registration for CNPG types (client-side, safe when the CRD is absent); a Secrets watch mapping CNPG app secrets back to their NebariApp so password rotations refresh the normalized credentials copy; operator RBAC for postgresql.cnpg.io clusters without the delete verb. Part of https://github.com/nebari-dev/nebari-infrastructure-core/issues/303 --- cmd/operator/main.go | 26 ++++-- config/rbac/role.yaml | 11 +++ internal/controller/nebariapp_controller.go | 89 +++++++++++++++++++-- 3 files changed, 113 insertions(+), 13 deletions(-) diff --git a/cmd/operator/main.go b/cmd/operator/main.go index dddb97f..e55e72e 100644 --- a/cmd/operator/main.go +++ b/cmd/operator/main.go @@ -26,6 +26,7 @@ import ( _ "k8s.io/client-go/plugin/pkg/client/auth" certmanagerv1 "github.com/cert-manager/cert-manager/pkg/apis/certmanager/v1" + cnpgv1 "github.com/cloudnative-pg/api/pkg/api/v1" egv1alpha1 "github.com/envoyproxy/gateway/api/v1alpha1" "k8s.io/apimachinery/pkg/runtime" utilruntime "k8s.io/apimachinery/pkg/util/runtime" @@ -44,6 +45,7 @@ import ( "github.com/nebari-dev/nebari-operator/internal/controller/reconcilers/auth" "github.com/nebari-dev/nebari-operator/internal/controller/reconcilers/auth/providers" "github.com/nebari-dev/nebari-operator/internal/controller/reconcilers/core" + "github.com/nebari-dev/nebari-operator/internal/controller/reconcilers/database" "github.com/nebari-dev/nebari-operator/internal/controller/reconcilers/routing" tlsreconciler "github.com/nebari-dev/nebari-operator/internal/controller/reconcilers/tls" "github.com/nebari-dev/nebari-operator/internal/controller/utils/constants" @@ -62,6 +64,10 @@ func init() { utilruntime.Must(gatewayapiv1.Install(scheme)) utilruntime.Must(egv1alpha1.AddToScheme(scheme)) utilruntime.Must(certmanagerv1.AddToScheme(scheme)) + // CNPG types are registered unconditionally: scheme registration is + // client-side and harmless when the CRD is absent; the database + // reconciler degrades to a condition at request time in that case. + utilruntime.Must(cnpgv1.AddToScheme(scheme)) // +kubebuilder:scaffold:scheme } @@ -279,15 +285,21 @@ func main() { Scheme: mgr.GetScheme(), Recorder: mgr.GetEventRecorderFor("nebariapp-routing"), } + databaseReconciler := &database.DatabaseReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Recorder: mgr.GetEventRecorderFor("nebariapp-database"), + } if err := (&controller.NebariAppReconciler{ - Client: mgr.GetClient(), - Scheme: mgr.GetScheme(), - Recorder: mgr.GetEventRecorderFor("nebariapp-controller"), - CoreReconciler: coreReconciler, - TLSReconciler: tlsReconciler, - RoutingReconciler: routingReconciler, - AuthReconciler: authReconciler, + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Recorder: mgr.GetEventRecorderFor("nebariapp-controller"), + CoreReconciler: coreReconciler, + TLSReconciler: tlsReconciler, + RoutingReconciler: routingReconciler, + AuthReconciler: authReconciler, + DatabaseReconciler: databaseReconciler, }).SetupWithManager(mgr); err != nil { setupLog.Error(err, "unable to create controller", "controller", "NebariApp") os.Exit(1) diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index 6496149..7d681ce 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -77,6 +77,17 @@ rules: - patch - update - watch +- apiGroups: + - postgresql.cnpg.io + resources: + - clusters + verbs: + - create + - get + - list + - patch + - update + - watch - apiGroups: - rbac.authorization.k8s.io resources: diff --git a/internal/controller/nebariapp_controller.go b/internal/controller/nebariapp_controller.go index b701f09..97a075e 100644 --- a/internal/controller/nebariapp_controller.go +++ b/internal/controller/nebariapp_controller.go @@ -19,6 +19,7 @@ package controller import ( "context" "fmt" + "strings" "time" certmanagerv1 "github.com/cert-manager/cert-manager/pkg/apis/certmanager/v1" @@ -40,6 +41,7 @@ import ( "github.com/nebari-dev/nebari-operator/internal/controller/reconcilers/auth" "github.com/nebari-dev/nebari-operator/internal/controller/reconcilers/core" + "github.com/nebari-dev/nebari-operator/internal/controller/reconcilers/database" "github.com/nebari-dev/nebari-operator/internal/controller/reconcilers/routing" "github.com/nebari-dev/nebari-operator/internal/controller/reconcilers/tls" "github.com/nebari-dev/nebari-operator/internal/controller/utils/conditions" @@ -49,12 +51,13 @@ import ( // NebariAppReconciler reconciles a NebariApp object type NebariAppReconciler struct { client.Client - Scheme *runtime.Scheme - Recorder record.EventRecorder - CoreReconciler *core.CoreReconciler - TLSReconciler *tls.TLSReconciler - RoutingReconciler *routing.RoutingReconciler - AuthReconciler *auth.AuthReconciler + Scheme *runtime.Scheme + Recorder record.EventRecorder + CoreReconciler *core.CoreReconciler + TLSReconciler *tls.TLSReconciler + RoutingReconciler *routing.RoutingReconciler + AuthReconciler *auth.AuthReconciler + DatabaseReconciler *database.DatabaseReconciler } // +kubebuilder:rbac:groups=reconcilers.nebari.dev,resources=nebariapps,verbs=get;list;watch;create;update;patch;delete @@ -70,9 +73,12 @@ type NebariAppReconciler struct { // +kubebuilder:rbac:groups=gateway.envoyproxy.io,resources=securitypolicies,verbs=get;list;watch;create;update;patch;delete // +kubebuilder:rbac:groups=rbac.authorization.k8s.io,resources=roles,verbs=get;list;watch;create;update;patch;delete // +kubebuilder:rbac:groups=rbac.authorization.k8s.io,resources=rolebindings,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=postgresql.cnpg.io,resources=clusters,verbs=get;list;watch;create;update;patch // Reconcile is part of the main kubernetes reconciliation loop which aims to // move the current state of the cluster closer to the desired state. +// +// nolint:gocyclo func (r *NebariAppReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { logger := logf.FromContext(ctx) @@ -227,6 +233,17 @@ func (r *NebariAppReconciler) Reconcile(ctx context.Context, req ctrl.Request) ( } logger.Info("Auth reconciled successfully", "nebariapp", nebariApp.Name) + // Reconcile the managed database if configured. Extracted into its own + // method (like reconcilePublicRoutes) to keep Reconcile's cyclomatic + // complexity manageable. A non-nil result or error means the caller + // should return early. + if dbResult, err := r.reconcileDatabase(ctx, nebariApp); err != nil || dbResult != nil { + if dbResult != nil { + return *dbResult, err + } + return ctrl.Result{}, err + } + // Validation succeeded, set Ready condition to True conditions.SetCondition(nebariApp, appsv1.ConditionTypeReady, metav1.ConditionTrue, appsv1.ReasonReconcileSuccess, "NebariApp reconciled successfully") @@ -334,6 +351,38 @@ func (r *NebariAppReconciler) reconcilePublicRoutes(ctx context.Context, nebariA return nil, nil } +// reconcileDatabase reconciles the managed database if configured. Nil guard +// mirrors TLSReconciler so tests can opt out. A non-nil result means the +// database is still provisioning (or CNPG is missing / the name is invalid): +// the caller should persist status and return it (polling or backoff). +func (r *NebariAppReconciler) reconcileDatabase(ctx context.Context, nebariApp *appsv1.NebariApp) (*ctrl.Result, error) { + logger := logf.FromContext(ctx) + + if r.DatabaseReconciler == nil { + return nil, nil + } + + dbResult, err := r.DatabaseReconciler.ReconcileDatabase(ctx, nebariApp) + if err != nil { + logger.Error(err, "Database reconciliation failed") + conditions.SetCondition(nebariApp, appsv1.ConditionTypeReady, metav1.ConditionFalse, + appsv1.ReasonFailed, fmt.Sprintf("Database reconciliation failed: %v", err)) + if err := r.Status().Update(ctx, nebariApp); err != nil { + return &ctrl.Result{}, err + } + return &ctrl.Result{RequeueAfter: time.Minute}, nil + } + if dbResult != nil { + nebariApp.Status.ObservedGeneration = nebariApp.Generation + if err := r.Status().Update(ctx, nebariApp); err != nil { + return &ctrl.Result{}, err + } + return dbResult, nil + } + logger.Info("Database reconciled successfully", "nebariapp", nebariApp.Name) + return nil, nil +} + // cleanup removes resources created by this NebariApp func (r *NebariAppReconciler) cleanup(ctx context.Context, nebariApp *appsv1.NebariApp) error { logger := logf.FromContext(ctx) @@ -397,6 +446,16 @@ func (r *NebariAppReconciler) SetupWithManager(mgr ctrl.Manager) error { ) } + // Watch Secrets so CNPG password rotations propagate into the normalized + // credentials copy without waiting for the periodic requeue. Only + // CNPG-generated app secrets ("-db-app") map back to a NebariApp. + if r.DatabaseReconciler != nil { + builder = builder.Watches( + &corev1.Secret{}, + handler.EnqueueRequestsFromMapFunc(r.databaseSecretToNebariApp), + ) + } + return builder.Complete(r) } @@ -412,3 +471,21 @@ func (r *NebariAppReconciler) certificateToNebariApp(_ context.Context, obj clie {NamespacedName: types.NamespacedName{Name: name, Namespace: namespace}}, } } + +// databaseSecretToNebariApp maps a CNPG-generated app-user Secret +// ("-db-app") to the NebariApp whose database it belongs to. A false +// positive (an unrelated secret matching the suffix) only triggers a +// reconcile of a NebariApp that may not exist, which Reconcile ignores. +func (r *NebariAppReconciler) databaseSecretToNebariApp(_ context.Context, obj client.Object) []reconcile.Request { + const suffix = "-db-app" + name := obj.GetName() + if !strings.HasSuffix(name, suffix) { + return nil + } + return []reconcile.Request{ + {NamespacedName: types.NamespacedName{ + Name: strings.TrimSuffix(name, suffix), + Namespace: obj.GetNamespace(), + }}, + } +} From b7894ac2b10d2ee48c7eeabfde6a9f21f9bc305f Mon Sep 17 00:00:00 2001 From: Tyler Potts <49161327+tylerpotts@users.noreply.github.com> Date: Fri, 10 Jul 2026 17:20:16 -0500 Subject: [PATCH 5/7] docs(database): sample, reconciler doc, API reference Part of https://github.com/nebari-dev/nebari-infrastructure-core/issues/303 --- config/samples/reconcilers_v1_nebariapp.yaml | 9 ++ docs/api-reference.md | 24 ++++- docs/reconcilers/README.md | 51 ++++++++++- docs/reconcilers/authentication.md | 2 +- docs/reconcilers/database.md | 94 ++++++++++++++++++++ docs/reconcilers/routing.md | 2 +- docs/reconcilers/validation.md | 2 +- 7 files changed, 176 insertions(+), 8 deletions(-) create mode 100644 docs/reconcilers/database.md diff --git a/config/samples/reconcilers_v1_nebariapp.yaml b/config/samples/reconcilers_v1_nebariapp.yaml index c10683e..228716e 100644 --- a/config/samples/reconcilers_v1_nebariapp.yaml +++ b/config/samples/reconcilers_v1_nebariapp.yaml @@ -45,6 +45,15 @@ spec: # id.token.claim: "true" # access.token.claim: "true" # userinfo.token.claim: "true" + # Optional: request a managed PostgreSQL database (CloudNativePG). + # Credentials land in Secret "-db-credentials" with keys: + # host, port, username, password, database, uri. + # Disabling later never deletes the database; deleting this NebariApp does. + # database: + # enabled: true + # provider: cloudnativepg # default and only supported value + # instances: 1 # default + # size: 1Gi # default; cannot be decreased --- # Example: Cross-namespace service reference # Useful for centralized service architectures where services live in different namespaces diff --git a/docs/api-reference.md b/docs/api-reference.md index c8ca244..d6eea9c 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -51,6 +51,24 @@ _Appears in:_ | `tokenExchange` _[TokenExchangeConfig](#tokenexchangeconfig)_ | TokenExchange configures OAuth 2.0 Token Exchange (RFC 8693) for this client.
When enabled, other NebariApp OIDC clients in the same Keycloak realm can
exchange their access tokens for tokens with this client's audience.
Requires KC_FEATURES=token-exchange on the Keycloak server.
Only supported for provider="keycloak". | | Optional: \{\}
| +--- + +#### DatabaseConfig + +DatabaseConfig specifies a managed database request backed by a database +operator (CloudNativePG). + +_Appears in:_ +- [NebariAppSpec](#nebariappspec) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `enabled` _boolean_ | Enabled determines whether a managed database is provisioned. | false | Optional: \{\}
| +| `provider` _string_ | Provider selects the database operator backing this request. | cloudnativepg | Enum: [cloudnativepg]
Optional: \{\}
| +| `instances` _integer_ | Instances is the number of PostgreSQL instances (1 primary plus N-1 replicas, maximum 9). | 1 | Maximum: 9
Minimum: 1
Optional: \{\}
| +| `size` _string_ | Size is the storage request for each instance, as a Kubernetes quantity.
Size cannot be decreased once set (CloudNativePG restriction). | 1Gi | Pattern: `^[0-9]+(\.[0-9]+)?(Ki\|Mi\|Gi\|Ti\|Pi\|Ei\|k\|M\|G\|T\|P\|E)?$`
Optional: \{\}
| + + --- #### DenyRedirectHeader @@ -239,8 +257,9 @@ _Appears in:_ | `routing` _[RoutingConfig](#routingconfig)_ | Routing configures routing behavior including path-based rules and TLS. | | Optional: \{\}
| | `auth` _[AuthConfig](#authconfig)_ | Auth configures authentication/authorization for the application.
When enabled, the application will require OIDC authentication via supporting OIDC Provider. | | Optional: \{\}
| | `gateway` _string_ | Gateway specifies which shared Gateway to use for routing.
Valid values are "public" (default) or "internal". | public | Enum: [public internal]
Optional: \{\}
| -| `serviceAccountName` _string_ | ServiceAccountName is the name of the Kubernetes ServiceAccount used by the
app's pods. Used for RBAC scoping of OIDC secrets so only the app's pods
can read its credentials. Defaults to the NebariApp's name if omitted. | | MinLength: 1
Optional: \{\}
| +| `serviceAccountName` _string_ | ServiceAccountName is the name of the Kubernetes ServiceAccount used by the
app's pods. Used for RBAC scoping of OIDC and database credentials secrets
so only the app's pods can read them. Defaults to the NebariApp's name if omitted. | | MinLength: 1
Optional: \{\}
| | `landingPage` _[LandingPageConfig](#landingpageconfig)_ | LandingPage configures how this service appears on the Nebari landing page.
When enabled, the service will be discoverable through the landing page portal. | | Optional: \{\}
| +| `database` _[DatabaseConfig](#databaseconfig)_ | Database requests a managed PostgreSQL database for this application.
When enabled, the operator provisions a CloudNativePG Cluster named
"-db" and writes connection credentials to the Secret
"-db-credentials" with keys: host, port, username, password,
database, uri. Read access is scoped to the app's ServiceAccount.
Requires the CloudNativePG operator on the cluster (in Nebari, NIC's
top-level database.enabled toggle installs it).
Disabling later stops management but never deletes the database.
Deleting the NebariApp deletes the database and its data via owner
references. | | Optional: \{\}
| --- @@ -254,11 +273,12 @@ _Appears in:_ | Field | Description | Default | Validation | | --- | --- | --- | --- | -| `conditions` _[Condition](https://kubernetes.io/docs/reference/generated/kubernetes-api/v/#condition-v1-meta) array_ | Conditions represent the current state of the NebariApp resource.
Standard condition types:
- "RoutingReady": HTTPRoute has been created and is functioning
- "TLSReady": TLS certificate is available and configured
- "AuthReady": Authentication policy is configured (if auth is enabled)
- "Ready": All components are ready (aggregate condition) | | Optional: \{\}
| +| `conditions` _[Condition](https://kubernetes.io/docs/reference/generated/kubernetes-api/v/#condition-v1-meta) array_ | Conditions represent the current state of the NebariApp resource.
Standard condition types:
- "RoutingReady": HTTPRoute has been created and is functioning
- "TLSReady": TLS certificate is available and configured
- "AuthReady": Authentication policy is configured (if auth is enabled)
- "DatabaseReady": Managed database is provisioned and credentials are available (if database is enabled)
- "Ready": All components are ready (aggregate condition) | | Optional: \{\}
| | `observedGeneration` _integer_ | ObservedGeneration is the most recent generation observed for this NebariApp.
It corresponds to the NebariApp's generation, which is updated on mutation by the API Server. | | Optional: \{\}
| | `hostname` _string_ | Hostname is the actual hostname where the application is accessible.
This mirrors the spec.hostname for easy reference. | | Optional: \{\}
| | `gatewayRef` _[GatewayReference](#gatewayreference)_ | GatewayRef identifies the Gateway resource that routes traffic to this application. | | Optional: \{\}
| | `clientSecretRef` _[ResourceReference](#resourcereference)_ | ClientSecretRef identifies the Secret containing OIDC client credentials. | | Optional: \{\}
| +| `databaseSecretRef` _[ResourceReference](#resourcereference)_ | DatabaseSecretRef identifies the Secret containing database connection
credentials (keys: host, port, username, password, database, uri). | | Optional: \{\}
| | `authConfigHash` _string_ | AuthConfigHash stores a SHA-256 hash of the last successfully provisioned OIDC
client configuration. When this matches the hash of the current spec, and the
AuthReady condition is True, ProvisionClient is skipped to avoid unnecessary
external API calls on every reconcile cycle.
To force re-provisioning, set the nebari.dev/force-reprovision annotation on
the NebariApp. The annotation is automatically removed after the forced
re-provisioning completes. | | Optional: \{\}
| | `serviceDiscovery` _[ServiceDiscoveryStatus](#servicediscoverystatus)_ | ServiceDiscovery is the computed service discovery descriptor.
The controller populates this after reconciling spec.landingPage so the
webapi watcher can consume a pre-validated, URL-resolved view via
status.serviceDiscovery.* without re-deriving it from spec. | | Optional: \{\}
| diff --git a/docs/reconcilers/README.md b/docs/reconcilers/README.md index 75f6cd5..7af79bd 100644 --- a/docs/reconcilers/README.md +++ b/docs/reconcilers/README.md @@ -10,9 +10,10 @@ aspect of application onboarding: - **Validation Reconciler** - Ensures prerequisites are met (namespace opt-in, service exists) - **Routing Reconciler** - Creates and manages Gateway API HTTPRoutes for traffic routing - **Authentication Reconciler** - Provisions OIDC clients and configures SecurityPolicy for authentication +- **Database Reconciler** - Provisions managed PostgreSQL databases through CloudNativePG These reconcilers work together in a coordinated pipeline to transform a simple `NebariApp` custom resource into a fully -configured application with routing, TLS, and optional authentication. +configured application with routing, TLS, optional authentication, and optional managed databases. ## Platform Context @@ -32,6 +33,7 @@ flowchart TB Core[Core Validator] Routing[Routing Reconciler] Auth[Auth Reconciler] + Database[Database Reconciler] end AppCR --> Core @@ -42,6 +44,9 @@ flowchart TB Auth --> KC[Keycloak Client Optional] KC --> Secret[K8s Secret] Secret --> SecPol + + Core --> Database --> PG[CloudNativePG Cluster Optional] + PG --> DBSecret[K8s Secret] ``` ## Reconciliation Flow @@ -64,7 +69,7 @@ flowchart TD Routing -->|No| Fail3[Set RoutingReady=False
Emit Event] Routing -->|Yes| AuthCheck{Auth
Enabled?} - AuthCheck -->|No| Success[Set Ready=True
Complete] + AuthCheck -->|No| DBCheck{Database
Enabled?} AuthCheck -->|Yes| Auth[Authentication Reconciler] Auth --> Provider[Get OIDC Provider] @@ -78,16 +83,27 @@ flowchart TD SecPol --> AuthReady{Auth
Ready?} AuthReady -->|No| Fail4[Set AuthReady=False
Emit Event] - AuthReady -->|Yes| Success + AuthReady -->|Yes| DBCheck + + DBCheck -->|No| Success[Set Ready=True
Complete] + DBCheck -->|Yes| DB[Database Reconciler] + + DB --> Cluster[Create/Update CNPG Cluster
Publish Credentials Secret] + Cluster --> DBReady{Database
Ready?} + + DBReady -->|No| Fail5[Set DatabaseReady=False
Requeue] + DBReady -->|Yes| Success style Core fill:#e1f5ff style Route fill:#fff4e6 style Auth fill:#f3e5f5 + style DB fill:#e0f2f1 style Success fill:#e8f5e9 style Fail1 fill:#ffebee style Fail2 fill:#ffebee style Fail3 fill:#ffebee style Fail4 fill:#ffebee + style Fail5 fill:#ffebee ``` ## Reconciler Pipeline @@ -141,6 +157,23 @@ flowchart TD **Details:** [authentication.md](authentication.md) +### 4. Database Reconciler + +**Purpose:** Provision managed PostgreSQL databases via CloudNativePG + +**Responsibilities:** +- Create or update CloudNativePG Cluster resources +- Wait for database readiness +- Normalize generated secrets into well-known Secret format +- Scope read access to the app's ServiceAccount +- Handle cleanup with data safety guarantees + +**Outcomes:** +- ✅ `DatabaseReady=True` - Database ready, credentials published +- ❌ `DatabaseReady=False` - Database not ready, or CNPG not installed + +**Details:** [database.md](database.md) + ## Condition Types The operator manages these conditions on each `NebariApp`: @@ -151,6 +184,7 @@ The operator manages these conditions on each `NebariApp`: | `RoutingReady` | HTTPRoute created and Gateway is routing traffic | Routing reconciler | | `TLSReady` | TLS certificate ready and listener attached (when TLS is enabled) | TLS reconciler | | `AuthReady` | Authentication configured (if enabled) | Authentication reconciler | +| `DatabaseReady` | Managed database provisioned and credentials available (if enabled) | Database reconciler | ### Condition Reasons @@ -178,6 +212,11 @@ Common reasons you'll see in conditions: - `UserProvidedSecretInvalidType` - The named secret exists but is not type `kubernetes.io/tls` - `UserProvidedSecretCheckFailed` - The operator could not determine the secret's state (transient API error, RBAC failure). Distinct from `UserProvidedSecretNotFound`. +**DatabaseReady-specific:** +- `DatabaseProvisioning` - CNPG Cluster or credentials not ready yet (the reconciler polls until ready) +- `DatabaseDisabled` - `database.enabled` was turned off while the database still exists (data is retained) +- `CNPGNotInstalled` - CloudNativePG CRDs are missing on this cluster (re-checked every 5 minutes) + ## Event Recording Reconcilers emit Kubernetes events to provide visibility into operations: @@ -214,6 +253,11 @@ Reconcilers emit Kubernetes events to provide visibility into operations: - `AuthConfigured` (Normal) - Authentication configured - `AuthFailed` (Warning) - Authentication configuration failed +**Database Events:** +- `DatabaseProvisioned` (Normal) - Managed database ready and credentials published +- `DatabaseProvisionFailed` (Warning) - Database provisioning failed +- `DatabaseOrphaned` (Warning) - Database disabled; the CNPG Cluster and its data are retained + View events for a NebariApp: ```bash kubectl describe nebariapp -n @@ -340,6 +384,7 @@ Dive deeper into each reconciler: - **[Validation Reconciler](validation.md)** - Detailed validation logic and error scenarios - **[Routing Reconciler](routing.md)** - HTTPRoute management and Gateway integration - **[Authentication Reconciler](authentication.md)** - OIDC provider integration and SecurityPolicy configuration +- **[Database Reconciler](database.md)** - Managed PostgreSQL provisioning through CloudNativePG ## Debugging Reconcilers diff --git a/docs/reconcilers/authentication.md b/docs/reconcilers/authentication.md index 6e92fd6..2665bc0 100644 --- a/docs/reconcilers/authentication.md +++ b/docs/reconcilers/authentication.md @@ -1,6 +1,6 @@ # Authentication Reconciler -> **Part of:** [Reconciler Architecture](README.md) **Phase:** 3 of 3 (Validation → Routing → Authentication) +> **Part of:** [Reconciler Architecture](README.md) **Phase:** 3 of 4 (Validation → Routing → Authentication → Database) > **Purpose:** Configure OIDC authentication and authorization ## Overview diff --git a/docs/reconcilers/database.md b/docs/reconcilers/database.md new file mode 100644 index 0000000..2b1d562 --- /dev/null +++ b/docs/reconcilers/database.md @@ -0,0 +1,94 @@ +# Database Reconciler + +> **Part of:** [Reconciler Architecture](README.md) **Phase:** 4 of 4 (Validation → Routing → Authentication → Database) +> **Purpose:** Provision managed PostgreSQL databases through CloudNativePG + +## Overview + +The database reconciler provisions a managed PostgreSQL database for a +NebariApp that sets `spec.database.enabled: true`. It creates a +[CloudNativePG](https://cloudnative-pg.io) `Cluster`, waits for it to become +ready, publishes connection credentials in a well-known Secret, and scopes +read access to the app's ServiceAccount. It is the NebariApp-side contract of +the platform database infrastructure discussed in +[https://github.com/nebari-dev/nebari-infrastructure-core/issues/303](https://github.com/nebari-dev/nebari-infrastructure-core/issues/303); +the CloudNativePG operator itself is installed by NIC's top-level +`database.enabled` config toggle (added in +[nebari-infrastructure-core#455](https://github.com/nebari-dev/nebari-infrastructure-core/pull/455)). + +The logic is encapsulated in the `DatabaseReconciler` located at +`internal/controller/reconcilers/database/`. + +## Architecture + +``` +NebariAppReconciler + └-> DatabaseReconciler.ReconcileDatabase() + ├-> ValidateDatabaseClusterName() - Enforce CNPG's 50-char name cap + ├-> reconcileCluster() - Create/update the CNPG Cluster "-db" + ├-> readiness poll - Requeue until the Cluster's Ready condition is True + ├-> reconcileCredentialsSecret() - Normalize CNPG's "-db-app" + │ Secret into "-db-credentials" + └-> reconcileSecretRBAC() - Role/RoleBinding scoping the Secret to the + app's ServiceAccount +``` + +A Secrets watch maps CloudNativePG's generated `-db-app` Secret back to +its NebariApp, so password rotations refresh the normalized copy without +waiting for the periodic requeue. +The watch is cluster-wide over Secrets (filtered by name in the handler); +the operator's cached client already maintains a Secrets informer for the +auth flow, so this adds no new cache class. + +## Spec + +```yaml +spec: + database: + enabled: true + provider: cloudnativepg # default and only supported value + instances: 1 # default 1, maximum 9 + size: 1Gi # default 1Gi; cannot be decreased +``` + +## Credentials contract + +The operator writes Secret `-db-credentials` in the NebariApp's +namespace: + +| Key | Meaning | +| ---------- | ----------------------------------------------- | +| `host` | In-namespace hostname of the read-write service | +| `port` | PostgreSQL port | +| `username` | Application user | +| `password` | Application user password | +| `database` | Database name | +| `uri` | Full PostgreSQL connection URI | + +Reference it from pod env via `secretKeyRef`/`envFrom`. Read access is +restricted to the app's ServiceAccount (`spec.serviceAccountName`, defaulting +to the NebariApp's name). + +## Conditions + +| Condition | Reason | Meaning | +| --------------- | ---------------------- | ------------------------------------------------- | +| `DatabaseReady` | `DatabaseProvisioning` | Cluster or credentials not ready yet (polling) | +| `DatabaseReady` | `Available` | Database ready, credentials published | +| `DatabaseReady` | `CNPGNotInstalled` | CloudNativePG CRDs missing on this cluster | +| `DatabaseReady` | `DatabaseDisabled` | Toggle turned off while the database still exists | +| `DatabaseReady` | `Failed` | Provisioning error (see message and events) | + +## Lifecycle and data safety + +- **Disabling never deletes.** Setting `enabled: false` stops management and + sets `DatabaseReady=False/DatabaseDisabled`; the Cluster, its data, and the + credentials Secret remain. Delete the `Cluster` resource yourself to remove + the database and its data. Orphan reporting relies on the NebariApp's + status carrying evidence of the earlier provisioning; a manually wiped + status skips the warning (the data is still retained). +- **Deleting the NebariApp deletes the database.** All database resources are + owner-referenced to the NebariApp and are garbage collected with it. +- **No CNPG, no failure.** On clusters without CloudNativePG the rest of the + NebariApp reconciles normally; the database subsystem reports + `CNPGNotInstalled` and re-checks every 5 minutes. diff --git a/docs/reconcilers/routing.md b/docs/reconcilers/routing.md index 5e83b0a..ebfa753 100644 --- a/docs/reconcilers/routing.md +++ b/docs/reconcilers/routing.md @@ -1,6 +1,6 @@ # Routing Reconciler -> **Part of:** [Reconciler Architecture](README.md) **Phase:** 2 of 3 (Validation → Routing → Authentication) +> **Part of:** [Reconciler Architecture](README.md) **Phase:** 2 of 4 (Validation → Routing → Authentication → Database) > **Purpose:** Configure HTTP/HTTPS routing via Gateway API ## Overview diff --git a/docs/reconcilers/validation.md b/docs/reconcilers/validation.md index ee369a5..ff8d859 100644 --- a/docs/reconcilers/validation.md +++ b/docs/reconcilers/validation.md @@ -1,6 +1,6 @@ # Validation Reconciler -> **Part of:** [Reconciler Architecture](README.md) **Phase:** 1 of 3 (Validation → Routing → Authentication) +> **Part of:** [Reconciler Architecture](README.md) **Phase:** 1 of 4 (Validation → Routing → Authentication → Database) > **Purpose:** Ensure all prerequisites are met before resource provisioning ## Overview From ca5264b9296ec09a4f01d811caee264c36cd7392 Mon Sep 17 00:00:00 2001 From: Tyler Potts <49161327+tylerpotts@users.noreply.github.com> Date: Mon, 13 Jul 2026 09:11:08 -0500 Subject: [PATCH 6/7] fix(database): harden credentials sourcing and watch mapping Verify the CNPG connection secret is owned by our Cluster before republishing it as app credentials; extract the db-app secret name mapping behind naming.NebariAppNameForDatabaseSecret with tests (the Secrets watch depends on it); document the CEL quantity() Kubernetes 1.29 floor and the instances guardrail; correct the immutable-name guidance; normalize the nolint directive. Part of https://github.com/nebari-dev/nebari-infrastructure-core/issues/303 --- api/v1/nebariapp_types.go | 1 + .../reconcilers.nebari.dev_nebariapps.yaml | 5 ++- docs/api-reference.md | 2 +- docs/reconcilers/database.md | 3 ++ internal/controller/nebariapp_controller.go | 14 +++---- .../reconcilers/database/reconciler.go | 2 +- .../reconcilers/database/reconciler_test.go | 42 +++++++++++++++---- .../controller/reconcilers/database/secret.go | 21 +++++++++- internal/controller/utils/naming/naming.go | 14 ++++++- .../controller/utils/naming/naming_test.go | 24 +++++++++++ 10 files changed, 106 insertions(+), 22 deletions(-) diff --git a/api/v1/nebariapp_types.go b/api/v1/nebariapp_types.go index 72715f7..a1a7a08 100644 --- a/api/v1/nebariapp_types.go +++ b/api/v1/nebariapp_types.go @@ -508,6 +508,7 @@ type DatabaseConfig struct { Provider string `json:"provider,omitempty"` // Instances is the number of PostgreSQL instances (1 primary plus N-1 replicas, maximum 9). + // The cap is a platform guardrail, not a CloudNativePG limit. // +kubebuilder:default=1 // +kubebuilder:validation:Minimum=1 // +kubebuilder:validation:Maximum=9 diff --git a/config/crd/bases/reconcilers.nebari.dev_nebariapps.yaml b/config/crd/bases/reconcilers.nebari.dev_nebariapps.yaml index 971335d..33f6cd4 100644 --- a/config/crd/bases/reconcilers.nebari.dev_nebariapps.yaml +++ b/config/crd/bases/reconcilers.nebari.dev_nebariapps.yaml @@ -333,8 +333,9 @@ spec: type: boolean instances: default: 1 - description: Instances is the number of PostgreSQL instances (1 - primary plus N-1 replicas, maximum 9). + description: |- + Instances is the number of PostgreSQL instances (1 primary plus N-1 replicas, maximum 9). + The cap is a platform guardrail, not a CloudNativePG limit. format: int32 maximum: 9 minimum: 1 diff --git a/docs/api-reference.md b/docs/api-reference.md index d6eea9c..0b3e6e9 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -65,7 +65,7 @@ _Appears in:_ | --- | --- | --- | --- | | `enabled` _boolean_ | Enabled determines whether a managed database is provisioned. | false | Optional: \{\}
| | `provider` _string_ | Provider selects the database operator backing this request. | cloudnativepg | Enum: [cloudnativepg]
Optional: \{\}
| -| `instances` _integer_ | Instances is the number of PostgreSQL instances (1 primary plus N-1 replicas, maximum 9). | 1 | Maximum: 9
Minimum: 1
Optional: \{\}
| +| `instances` _integer_ | Instances is the number of PostgreSQL instances (1 primary plus N-1 replicas, maximum 9).
The cap is a platform guardrail, not a CloudNativePG limit. | 1 | Maximum: 9
Minimum: 1
Optional: \{\}
| | `size` _string_ | Size is the storage request for each instance, as a Kubernetes quantity.
Size cannot be decreased once set (CloudNativePG restriction). | 1Gi | Pattern: `^[0-9]+(\.[0-9]+)?(Ki\|Mi\|Gi\|Ti\|Pi\|Ei\|k\|M\|G\|T\|P\|E)?$`
Optional: \{\}
| diff --git a/docs/reconcilers/database.md b/docs/reconcilers/database.md index 2b1d562..2b6275f 100644 --- a/docs/reconcilers/database.md +++ b/docs/reconcilers/database.md @@ -51,6 +51,9 @@ spec: size: 1Gi # default 1Gi; cannot be decreased ``` +The `size` field's positivity check uses CEL's `quantity()` library, which +requires Kubernetes 1.29 or newer to install the CRD. + ## Credentials contract The operator writes Secret `-db-credentials` in the NebariApp's diff --git a/internal/controller/nebariapp_controller.go b/internal/controller/nebariapp_controller.go index 97a075e..4b6b949 100644 --- a/internal/controller/nebariapp_controller.go +++ b/internal/controller/nebariapp_controller.go @@ -19,7 +19,6 @@ package controller import ( "context" "fmt" - "strings" "time" certmanagerv1 "github.com/cert-manager/cert-manager/pkg/apis/certmanager/v1" @@ -46,6 +45,7 @@ import ( "github.com/nebari-dev/nebari-operator/internal/controller/reconcilers/tls" "github.com/nebari-dev/nebari-operator/internal/controller/utils/conditions" "github.com/nebari-dev/nebari-operator/internal/controller/utils/constants" + "github.com/nebari-dev/nebari-operator/internal/controller/utils/naming" ) // NebariAppReconciler reconciles a NebariApp object @@ -78,7 +78,7 @@ type NebariAppReconciler struct { // Reconcile is part of the main kubernetes reconciliation loop which aims to // move the current state of the cluster closer to the desired state. // -// nolint:gocyclo +//nolint:gocyclo func (r *NebariAppReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { logger := logf.FromContext(ctx) @@ -477,15 +477,11 @@ func (r *NebariAppReconciler) certificateToNebariApp(_ context.Context, obj clie // positive (an unrelated secret matching the suffix) only triggers a // reconcile of a NebariApp that may not exist, which Reconcile ignores. func (r *NebariAppReconciler) databaseSecretToNebariApp(_ context.Context, obj client.Object) []reconcile.Request { - const suffix = "-db-app" - name := obj.GetName() - if !strings.HasSuffix(name, suffix) { + appName, ok := naming.NebariAppNameForDatabaseSecret(obj.GetName()) + if !ok { return nil } return []reconcile.Request{ - {NamespacedName: types.NamespacedName{ - Name: strings.TrimSuffix(name, suffix), - Namespace: obj.GetNamespace(), - }}, + {NamespacedName: types.NamespacedName{Name: appName, Namespace: obj.GetNamespace()}}, } } diff --git a/internal/controller/reconcilers/database/reconciler.go b/internal/controller/reconcilers/database/reconciler.go index 2ad887c..a8e2ea7 100644 --- a/internal/controller/reconcilers/database/reconciler.go +++ b/internal/controller/reconcilers/database/reconciler.go @@ -106,7 +106,7 @@ func (r *DatabaseReconciler) ReconcileDatabase(ctx context.Context, nebariApp *a return &ctrl.Result{RequeueAfter: provisioningRequeue}, nil } - if requeue, err := r.reconcileCredentialsSecret(ctx, nebariApp); requeue || err != nil { + if requeue, err := r.reconcileCredentialsSecret(ctx, nebariApp, cluster); requeue || err != nil { if err != nil { r.recordFailure(nebariApp, err) return nil, err diff --git a/internal/controller/reconcilers/database/reconciler_test.go b/internal/controller/reconcilers/database/reconciler_test.go index 4fda81e..f4bb128 100644 --- a/internal/controller/reconcilers/database/reconciler_test.go +++ b/internal/controller/reconcilers/database/reconciler_test.go @@ -78,7 +78,7 @@ func newReconciler(c client.Client, scheme *runtime.Scheme) *DatabaseReconciler func readyCluster(app *appsv1.NebariApp, scheme *runtime.Scheme, t *testing.T) *cnpgv1.Cluster { t.Helper() cluster := &cnpgv1.Cluster{ - ObjectMeta: metav1.ObjectMeta{Name: app.Name + "-db", Namespace: app.Namespace}, + ObjectMeta: metav1.ObjectMeta{Name: app.Name + "-db", Namespace: app.Namespace, UID: "cluster-uid-1"}, Spec: cnpgv1.ClusterSpec{ Instances: 1, StorageConfiguration: cnpgv1.StorageConfiguration{Size: "1Gi"}, @@ -98,8 +98,9 @@ func readyCluster(app *appsv1.NebariApp, scheme *runtime.Scheme, t *testing.T) * return cluster } -func cnpgAppSecret(app *appsv1.NebariApp) *corev1.Secret { - return &corev1.Secret{ +func cnpgAppSecret(t *testing.T, app *appsv1.NebariApp, cluster *cnpgv1.Cluster, scheme *runtime.Scheme) *corev1.Secret { + t.Helper() + secret := &corev1.Secret{ ObjectMeta: metav1.ObjectMeta{Name: app.Name + "-db-app", Namespace: app.Namespace}, Data: map[string][]byte{ "host": []byte("myapp-db-rw"), @@ -110,6 +111,10 @@ func cnpgAppSecret(app *appsv1.NebariApp) *corev1.Secret { "uri": []byte("postgresql://app:hunter2@myapp-db-rw.team-a:5432/app"), }, } + if err := controllerReference(cluster, secret, scheme); err != nil { + t.Fatalf("set owner ref: %v", err) + } + return secret } func TestReconcileDatabase_CreatesCluster(t *testing.T) { @@ -170,8 +175,9 @@ func TestReconcileDatabase_Defaults(t *testing.T) { func TestReconcileDatabase_ReadyWritesSecretAndRBAC(t *testing.T) { scheme := newScheme(t, true) app := newApp(&appsv1.DatabaseConfig{Enabled: true}) + cluster := readyCluster(app, scheme, t) c := fake.NewClientBuilder().WithScheme(scheme). - WithObjects(readyCluster(app, scheme, t), cnpgAppSecret(app)). + WithObjects(cluster, cnpgAppSecret(t, app, cluster, scheme)). Build() result, err := newReconciler(c, scheme).ReconcileDatabase(context.Background(), app) @@ -252,7 +258,7 @@ func TestReconcileDatabase_DisabledKeepsExistingCluster(t *testing.T) { enabledApp := newApp(&appsv1.DatabaseConfig{Enabled: true}) cluster := readyCluster(enabledApp, scheme, t) c := fake.NewClientBuilder().WithScheme(scheme). - WithObjects(cluster, cnpgAppSecret(enabledApp)). + WithObjects(cluster, cnpgAppSecret(t, enabledApp, cluster, scheme)). Build() app := newApp(&appsv1.DatabaseConfig{Enabled: false}) @@ -375,8 +381,9 @@ func TestReconcileDatabase_DisabledForeignClusterClearsStatus(t *testing.T) { func TestReconcileDatabase_ReadySteadyStateNoDuplicateEvent(t *testing.T) { scheme := newScheme(t, true) app := newApp(&appsv1.DatabaseConfig{Enabled: true}) + cluster := readyCluster(app, scheme, t) c := fake.NewClientBuilder().WithScheme(scheme). - WithObjects(readyCluster(app, scheme, t), cnpgAppSecret(app)). + WithObjects(cluster, cnpgAppSecret(t, app, cluster, scheme)). Build() recorder := record.NewFakeRecorder(16) r := &DatabaseReconciler{Client: c, Scheme: scheme, Recorder: recorder} @@ -409,8 +416,9 @@ func TestReconcileDatabase_CustomServiceAccount(t *testing.T) { scheme := newScheme(t, true) app := newApp(&appsv1.DatabaseConfig{Enabled: true}) app.Spec.ServiceAccountName = "custom-sa" + cluster := readyCluster(app, scheme, t) c := fake.NewClientBuilder().WithScheme(scheme). - WithObjects(readyCluster(app, scheme, t), cnpgAppSecret(app)). + WithObjects(cluster, cnpgAppSecret(t, app, cluster, scheme)). Build() if _, err := newReconciler(c, scheme).ReconcileDatabase(context.Background(), app); err != nil { @@ -431,3 +439,23 @@ func TestNormalizeCredentials_MissingKey(t *testing.T) { t.Fatal("expected error for missing source keys") } } + +func TestReconcileDatabase_ForeignSourceSecretRejected(t *testing.T) { + scheme := newScheme(t, true) + app := newApp(&appsv1.DatabaseConfig{Enabled: true}) + cluster := readyCluster(app, scheme, t) + unowned := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "myapp-db-app", Namespace: "team-a"}, + Data: map[string][]byte{"host": []byte("h"), "port": []byte("5432"), "username": []byte("u"), "password": []byte("p"), "dbname": []byte("d"), "uri": []byte("postgresql://u:p@h:5432/d")}, + } + c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(cluster, unowned).Build() + + _, err := newReconciler(c, scheme).ReconcileDatabase(context.Background(), app) + if err == nil { + t.Fatal("expected error for a source secret not owned by our Cluster") + } + cond := conditions.GetCondition(app, appsv1.ConditionTypeDatabaseReady) + if cond == nil || cond.Reason != appsv1.ReasonFailed { + t.Errorf("expected DatabaseReady=False/Failed, got %+v", cond) + } +} diff --git a/internal/controller/reconcilers/database/secret.go b/internal/controller/reconcilers/database/secret.go index fe71a1d..bd73686 100644 --- a/internal/controller/reconcilers/database/secret.go +++ b/internal/controller/reconcilers/database/secret.go @@ -20,6 +20,7 @@ import ( "context" "fmt" + cnpgv1 "github.com/cloudnative-pg/api/pkg/api/v1" corev1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -45,7 +46,7 @@ var credentialKeys = map[string]string{ // documented "-db-credentials" contract. Returns requeue=true when the // source Secret does not exist yet (CNPG creates it shortly after the // Cluster), which is a provisioning state rather than an error. -func (r *DatabaseReconciler) reconcileCredentialsSecret(ctx context.Context, nebariApp *appsv1.NebariApp) (requeue bool, err error) { +func (r *DatabaseReconciler) reconcileCredentialsSecret(ctx context.Context, nebariApp *appsv1.NebariApp, cluster *cnpgv1.Cluster) (requeue bool, err error) { source := &corev1.Secret{} if err := r.Client.Get(ctx, types.NamespacedName{ Name: naming.DatabaseAppSecretName(nebariApp), @@ -57,6 +58,13 @@ func (r *DatabaseReconciler) reconcileCredentialsSecret(ctx context.Context, neb return false, fmt.Errorf("failed to read CNPG connection secret: %w", err) } + // Only republish credentials from the Secret CNPG generated for our + // Cluster. A pre-created same-named Secret must not become the app's + // official credentials. + if !isOwnedBy(source, cluster) { + return false, fmt.Errorf("refusing to publish credentials: secret %s is not owned by Cluster %s", source.Name, cluster.Name) + } + data, err := normalizeCredentials(source.Data) if err != nil { return false, err @@ -78,6 +86,17 @@ func (r *DatabaseReconciler) reconcileCredentialsSecret(ctx context.Context, neb return false, nil } +// isOwnedBy reports whether obj carries an owner reference to owner's UID +// (controller or not; CNPG sets ownership on its generated secrets). +func isOwnedBy(obj metav1.Object, owner metav1.Object) bool { + for _, ref := range obj.GetOwnerReferences() { + if ref.UID == owner.GetUID() { + return true + } + } + return false +} + // normalizeCredentials maps CNPG source keys to the documented contract keys. func normalizeCredentials(source map[string][]byte) (map[string][]byte, error) { data := make(map[string][]byte, len(credentialKeys)) diff --git a/internal/controller/utils/naming/naming.go b/internal/controller/utils/naming/naming.go index cfc2c61..8dfb4c3 100644 --- a/internal/controller/utils/naming/naming.go +++ b/internal/controller/utils/naming/naming.go @@ -2,6 +2,7 @@ package naming import ( "fmt" + "strings" appsv1 "github.com/nebari-dev/nebari-operator/api/v1" "github.com/nebari-dev/nebari-operator/internal/controller/utils/constants" @@ -148,7 +149,18 @@ const maxCNPGClusterNameLength = 50 func ValidateDatabaseClusterName(nebariApp *appsv1.NebariApp) error { name := DatabaseClusterName(nebariApp) if len(name) > maxCNPGClusterNameLength { - return fmt.Errorf("database cluster name %q is %d characters; CloudNativePG requires at most %d (shorten the NebariApp name)", name, len(name), maxCNPGClusterNameLength) + return fmt.Errorf("database cluster name %q is %d characters; CloudNativePG requires at most %d (NebariApp names are immutable; recreate the app with a shorter name)", name, len(name), maxCNPGClusterNameLength) } return nil } + +// NebariAppNameForDatabaseSecret maps the name of a CloudNativePG-generated +// app-user Secret ("-db-app") back to its NebariApp name. ok is false +// for names that are not database app secrets. +func NebariAppNameForDatabaseSecret(secretName string) (appName string, ok bool) { + const suffix = "-" + constants.DatabaseSuffix + "-app" + if !strings.HasSuffix(secretName, suffix) || len(secretName) == len(suffix) { + return "", false + } + return strings.TrimSuffix(secretName, suffix), true +} diff --git a/internal/controller/utils/naming/naming_test.go b/internal/controller/utils/naming/naming_test.go index d012141..b7454c0 100644 --- a/internal/controller/utils/naming/naming_test.go +++ b/internal/controller/utils/naming/naming_test.go @@ -345,3 +345,27 @@ func TestValidateDatabaseClusterName(t *testing.T) { t.Errorf("error should state the 50-character limit, got: %v", err) } } + +func TestNebariAppNameForDatabaseSecret(t *testing.T) { + tests := []struct { + secretName string + wantName string + wantOK bool + }{ + {"foo-db-app", "foo", true}, + {"foo-db-credentials", "", false}, + {"-db-app", "", false}, + {"foo-db-app-db-app", "foo-db-app", true}, + {"unrelated", "", false}, + } + + for _, tt := range tests { + t.Run(tt.secretName, func(t *testing.T) { + gotName, gotOK := NebariAppNameForDatabaseSecret(tt.secretName) + if gotName != tt.wantName || gotOK != tt.wantOK { + t.Errorf("NebariAppNameForDatabaseSecret(%q) = (%q, %v), want (%q, %v)", + tt.secretName, gotName, gotOK, tt.wantName, tt.wantOK) + } + }) + } +} From fe8243c3f858a62e8a566fdd5f69ebb8f158963b Mon Sep 17 00:00:00 2001 From: Tyler Potts <49161327+tylerpotts@users.noreply.github.com> Date: Mon, 13 Jul 2026 15:58:32 -0500 Subject: [PATCH 7/7] docs: CNPG operator is installed unconditionally by NIC, not via a toggle NIC PR 455 was reworked per review to install the CloudNativePG operator as unconditional foundational infrastructure; the database.enabled config toggle no longer exists. Update the field doc, the CNPGNotInstalled condition message, its test, and the reconciler docs page accordingly. Regenerated CRD bases and api-reference. --- api/v1/nebariapp_types.go | 4 ++-- config/crd/bases/reconcilers.nebari.dev_nebariapps.yaml | 4 ++-- docs/api-reference.md | 2 +- docs/reconcilers/database.md | 6 +++--- internal/controller/reconcilers/database/reconciler.go | 2 +- internal/controller/reconcilers/database/reconciler_test.go | 4 ++-- 6 files changed, 11 insertions(+), 11 deletions(-) diff --git a/api/v1/nebariapp_types.go b/api/v1/nebariapp_types.go index a1a7a08..3fc6f23 100644 --- a/api/v1/nebariapp_types.go +++ b/api/v1/nebariapp_types.go @@ -68,8 +68,8 @@ type NebariAppSpec struct { // "-db-credentials" with keys: host, port, username, password, // database, uri. Read access is scoped to the app's ServiceAccount. // - // Requires the CloudNativePG operator on the cluster (in Nebari, NIC's - // top-level database.enabled toggle installs it). + // Requires the CloudNativePG operator on the cluster (in Nebari, NIC + // installs it on every GitOps bootstrap as foundational infrastructure). // // Disabling later stops management but never deletes the database. // Deleting the NebariApp deletes the database and its data via owner diff --git a/config/crd/bases/reconcilers.nebari.dev_nebariapps.yaml b/config/crd/bases/reconcilers.nebari.dev_nebariapps.yaml index 33f6cd4..a130c35 100644 --- a/config/crd/bases/reconcilers.nebari.dev_nebariapps.yaml +++ b/config/crd/bases/reconcilers.nebari.dev_nebariapps.yaml @@ -319,8 +319,8 @@ spec: "-db-credentials" with keys: host, port, username, password, database, uri. Read access is scoped to the app's ServiceAccount. - Requires the CloudNativePG operator on the cluster (in Nebari, NIC's - top-level database.enabled toggle installs it). + Requires the CloudNativePG operator on the cluster (in Nebari, NIC + installs it on every GitOps bootstrap as foundational infrastructure). Disabling later stops management but never deletes the database. Deleting the NebariApp deletes the database and its data via owner diff --git a/docs/api-reference.md b/docs/api-reference.md index 0b3e6e9..9c4e75a 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -259,7 +259,7 @@ _Appears in:_ | `gateway` _string_ | Gateway specifies which shared Gateway to use for routing.
Valid values are "public" (default) or "internal". | public | Enum: [public internal]
Optional: \{\}
| | `serviceAccountName` _string_ | ServiceAccountName is the name of the Kubernetes ServiceAccount used by the
app's pods. Used for RBAC scoping of OIDC and database credentials secrets
so only the app's pods can read them. Defaults to the NebariApp's name if omitted. | | MinLength: 1
Optional: \{\}
| | `landingPage` _[LandingPageConfig](#landingpageconfig)_ | LandingPage configures how this service appears on the Nebari landing page.
When enabled, the service will be discoverable through the landing page portal. | | Optional: \{\}
| -| `database` _[DatabaseConfig](#databaseconfig)_ | Database requests a managed PostgreSQL database for this application.
When enabled, the operator provisions a CloudNativePG Cluster named
"-db" and writes connection credentials to the Secret
"-db-credentials" with keys: host, port, username, password,
database, uri. Read access is scoped to the app's ServiceAccount.
Requires the CloudNativePG operator on the cluster (in Nebari, NIC's
top-level database.enabled toggle installs it).
Disabling later stops management but never deletes the database.
Deleting the NebariApp deletes the database and its data via owner
references. | | Optional: \{\}
| +| `database` _[DatabaseConfig](#databaseconfig)_ | Database requests a managed PostgreSQL database for this application.
When enabled, the operator provisions a CloudNativePG Cluster named
"-db" and writes connection credentials to the Secret
"-db-credentials" with keys: host, port, username, password,
database, uri. Read access is scoped to the app's ServiceAccount.
Requires the CloudNativePG operator on the cluster (in Nebari, NIC
installs it on every GitOps bootstrap as foundational infrastructure).
Disabling later stops management but never deletes the database.
Deleting the NebariApp deletes the database and its data via owner
references. | | Optional: \{\}
| --- diff --git a/docs/reconcilers/database.md b/docs/reconcilers/database.md index 2b6275f..046ad72 100644 --- a/docs/reconcilers/database.md +++ b/docs/reconcilers/database.md @@ -12,9 +12,9 @@ ready, publishes connection credentials in a well-known Secret, and scopes read access to the app's ServiceAccount. It is the NebariApp-side contract of the platform database infrastructure discussed in [https://github.com/nebari-dev/nebari-infrastructure-core/issues/303](https://github.com/nebari-dev/nebari-infrastructure-core/issues/303); -the CloudNativePG operator itself is installed by NIC's top-level -`database.enabled` config toggle (added in -[nebari-infrastructure-core#455](https://github.com/nebari-dev/nebari-infrastructure-core/pull/455)). +the CloudNativePG operator itself is installed by NIC on every GitOps +bootstrap as foundational infrastructure (added in +https://github.com/nebari-dev/nebari-infrastructure-core/pull/455). The logic is encapsulated in the `DatabaseReconciler` located at `internal/controller/reconcilers/database/`. diff --git a/internal/controller/reconcilers/database/reconciler.go b/internal/controller/reconcilers/database/reconciler.go index a8e2ea7..74dd8da 100644 --- a/internal/controller/reconcilers/database/reconciler.go +++ b/internal/controller/reconcilers/database/reconciler.go @@ -172,7 +172,7 @@ func (r *DatabaseReconciler) reconcileCluster(ctx context.Context, nebariApp *ap if apimeta.IsNoMatchError(err) || runtime.IsNotRegisteredError(err) { conditions.SetCondition(nebariApp, appsv1.ConditionTypeDatabaseReady, metav1.ConditionFalse, appsv1.ReasonCNPGNotInstalled, - "CloudNativePG is not installed on this cluster; in Nebari, set the top-level database.enabled toggle in the NIC config to install it") + "CloudNativePG is not installed on this cluster; install the CloudNativePG operator (in Nebari, NIC installs it on every GitOps bootstrap)") return nil, &ctrl.Result{RequeueAfter: settledRequeue}, nil } r.recordFailure(nebariApp, err) diff --git a/internal/controller/reconcilers/database/reconciler_test.go b/internal/controller/reconcilers/database/reconciler_test.go index f4bb128..7dda791 100644 --- a/internal/controller/reconcilers/database/reconciler_test.go +++ b/internal/controller/reconcilers/database/reconciler_test.go @@ -320,8 +320,8 @@ func TestReconcileDatabase_CNPGNotInstalled(t *testing.T) { if cond == nil || cond.Reason != appsv1.ReasonCNPGNotInstalled { t.Fatalf("expected CNPGNotInstalled, got %+v", cond) } - if !strings.Contains(cond.Message, "database.enabled") { - t.Errorf("message should point at NIC's database.enabled toggle, got %q", cond.Message) + if !strings.Contains(cond.Message, "CloudNativePG") { + t.Errorf("message should tell the user the CloudNativePG operator is missing, got %q", cond.Message) } }