diff --git a/api/v4/postgrescluster_types.go b/api/v4/postgrescluster_types.go index f6ae81ea7..de4e1b240 100644 --- a/api/v4/postgrescluster_types.go +++ b/api/v4/postgrescluster_types.go @@ -45,7 +45,7 @@ type ManagedRole struct { // Validation rules ensure immutability of Class, and that Storage and PostgresVersion can only be set once and cannot be removed or downgraded. // +kubebuilder:validation:XValidation:rule="!has(oldSelf.postgresVersion) || (has(self.postgresVersion) && int(self.postgresVersion.split('.')[0]) >= int(oldSelf.postgresVersion.split('.')[0]))",messageExpression="!has(self.postgresVersion) ? 'postgresVersion cannot be removed once set (was: ' + oldSelf.postgresVersion + ')' : 'postgresVersion major version cannot be downgraded (from: ' + oldSelf.postgresVersion + ', to: ' + self.postgresVersion + ')'" // +kubebuilder:validation:XValidation:rule="!has(oldSelf.storage) || (has(self.storage) && quantity(self.storage).compareTo(quantity(oldSelf.storage)) >= 0)",messageExpression="!has(self.storage) ? 'storage cannot be removed once set (was: ' + string(oldSelf.storage) + ')' : 'storage size cannot be decreased (from: ' + string(oldSelf.storage) + ', to: ' + string(self.storage) + ')'" -// +kubebuilder:validation:XValidation:rule="!self.connectionPoolerEnabled || self.connectionPoolerConfig != null || (self.cnpg != null && self.cnpg.connectionPooler != null)",message="connectionPoolerConfig must be set in cluster spec or class when connectionPoolerEnabled is true" +// +kubebuilder:validation:XValidation:rule="!self.connectionPoolerEnabled || self.connectionPoolerConfig != null",message="connectionPoolerConfig must be set when connectionPoolerEnabled is true" type PostgresClusterSpec struct { // This field is IMMUTABLE after creation. // +kubebuilder:validation:Required @@ -111,6 +111,19 @@ type PostgresClusterSpec struct { ManagedRoles []ManagedRole `json:"managedRoles,omitempty"` } +// PostgresClusterResources defines references to Kubernetes resources related to the PostgresCluster, such as ConfigMaps and Secrets. +type PostgresClusterResources struct { + // ConfigMapRef references the ConfigMap with connection endpoints. + // Contains: CLUSTER_ENDPOINTS, POOLER_ENDPOINTS (if connection pooler enabled) + // +optional + ConfigMapRef *corev1.LocalObjectReference `json:"configMapRef,omitempty"` + + // SecretRef references the Secret with superuser credentials. + // Contains: passwords for superuser + // +optional + SecretRef *corev1.LocalObjectReference `json:"secretRef,omitempty"` +} + // PostgresClusterStatus defines the observed state of PostgresCluster. type PostgresClusterStatus struct { // Phase represents the current phase of the PostgresCluster. @@ -135,6 +148,10 @@ type PostgresClusterStatus struct { // ManagedRolesStatus tracks the reconciliation status of managed roles. // +optional ManagedRolesStatus *ManagedRolesStatus `json:"managedRolesStatus,omitempty"` + + // Resources contains references to related Kubernetes resources like ConfigMaps and Secrets. + // +optional + Resources *PostgresClusterResources `json:"resources,omitempty"` } // ManagedRolesStatus tracks the state of managed PostgreSQL roles. diff --git a/api/v4/postgresclusterclass_types.go b/api/v4/postgresclusterclass_types.go index 9c4fcef44..430e3409e 100644 --- a/api/v4/postgresclusterclass_types.go +++ b/api/v4/postgresclusterclass_types.go @@ -114,7 +114,6 @@ const ( // ConnectionPoolerConfig defines PgBouncer connection pooler configuration. // When enabled, creates RW and RO pooler deployments for clusters using this class. -// +kubebuilder:validation:XValidation:rule="!self.connectionPoolerEnabled || self.connectionPoolerConfig != null || (self.cnpg != null && self.cnpg.connectionPooler != null)",message="connectionPoolerConfig must be set in cluster spec or class when connectionPoolerEnabled is true" type ConnectionPoolerConfig struct { // Instances is the number of PgBouncer pod replicas. // Higher values provide better availability and load distribution. diff --git a/config/crd/bases/enterprise.splunk.com_postgresclusters.yaml b/config/crd/bases/enterprise.splunk.com_postgresclusters.yaml index 205ce8438..45d6f6c5d 100644 --- a/config/crd/bases/enterprise.splunk.com_postgresclusters.yaml +++ b/config/crd/bases/enterprise.splunk.com_postgresclusters.yaml @@ -58,6 +58,37 @@ spec: x-kubernetes-validations: - message: class is immutable rule: self == oldSelf + connectionPoolerConfig: + description: |- + ConnectionPoolerConfig overrides the connection pooler configuration from the class. + Only takes effect when connection pooling is enabled. + properties: + config: + additionalProperties: + type: string + description: |- + Config contains PgBouncer configuration parameters. + Passed directly to CNPG Pooler spec.pgbouncer.parameters. + See: https://cloudnative-pg.io/docs/1.28/connection_pooling/#pgbouncer-configuration-options + type: object + instances: + default: 3 + description: |- + Instances is the number of PgBouncer pod replicas. + Higher values provide better availability and load distribution. + format: int32 + maximum: 10 + minimum: 1 + type: integer + mode: + default: transaction + description: Mode defines the connection pooling strategy. + enum: + - session + - transaction + - statement + type: string + type: object connectionPoolerEnabled: default: false description: |- @@ -128,7 +159,6 @@ spec: type: string type: array postgresVersion: - default: "18" description: |- PostgresVersion is the PostgreSQL version (major or major.minor). Examples: "18" (latest 18.x), "18.1" (specific minor), "17", "16" @@ -216,9 +246,22 @@ spec: - class type: object x-kubernetes-validations: - - message: Storage size cannot be removed and can only be increased + - messageExpression: '!has(self.postgresVersion) ? ''postgresVersion cannot + be removed once set (was: '' + oldSelf.postgresVersion + '')'' : ''postgresVersion + major version cannot be downgraded (from: '' + oldSelf.postgresVersion + + '', to: '' + self.postgresVersion + '')''' + rule: '!has(oldSelf.postgresVersion) || (has(self.postgresVersion) && + int(self.postgresVersion.split(''.'')[0]) >= int(oldSelf.postgresVersion.split(''.'')[0]))' + - messageExpression: '!has(self.storage) ? ''storage cannot be removed + once set (was: '' + string(oldSelf.storage) + '')'' : ''storage size + cannot be decreased (from: '' + string(oldSelf.storage) + '', to: + '' + string(self.storage) + '')''' rule: '!has(oldSelf.storage) || (has(self.storage) && quantity(self.storage).compareTo(quantity(oldSelf.storage)) >= 0)' + - message: connectionPoolerConfig must be set when connectionPoolerEnabled + is true + rule: '!self.connectionPoolerEnabled || self.connectionPoolerConfig + != null' status: description: PostgresClusterStatus defines the observed state of PostgresCluster. properties: @@ -363,6 +406,43 @@ spec: type: string type: object x-kubernetes-map-type: atomic + resources: + description: Resources contains references to related Kubernetes resources + like ConfigMaps and Secrets. + properties: + configMapRef: + description: |- + ConfigMapRef references the ConfigMap with connection endpoints. + Contains: CLUSTER_ENDPOINTS, POOLER_ENDPOINTS (if connection pooler enabled) + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + type: object + x-kubernetes-map-type: atomic + secretRef: + description: |- + SecretRef references the Secret with superuser credentials. + Contains: passwords for superuser + properties: + name: + default: "" + description: |- + Name of the referent. + This field is effectively required, but due to backwards compatibility is + allowed to be empty. Instances of this type with an empty value here are + almost certainly wrong. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + type: object + x-kubernetes-map-type: atomic + type: object type: object type: object served: true diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index f1506faf9..e7f4b73e1 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -110,6 +110,7 @@ rules: resources: - clusters - databases + - poolers verbs: - create - delete @@ -122,5 +123,6 @@ rules: - postgresql.cnpg.io resources: - clusters/status + - poolers/status verbs: - get diff --git a/go.mod b/go.mod index e5917beaa..3e39b2ebb 100644 --- a/go.mod +++ b/go.mod @@ -22,6 +22,7 @@ require ( github.com/onsi/gomega v1.39.1 github.com/pkg/errors v0.9.1 github.com/prometheus/client_golang v1.23.2 + github.com/sethvargo/go-password v0.3.1 github.com/stretchr/testify v1.11.1 github.com/wk8/go-ordered-map/v2 v2.1.7 go.uber.org/zap v1.27.1 @@ -31,6 +32,7 @@ require ( k8s.io/apimachinery v0.34.2 k8s.io/client-go v0.34.2 k8s.io/kubectl v0.26.2 + k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 sigs.k8s.io/controller-runtime v0.22.4 ) @@ -183,7 +185,6 @@ require ( k8s.io/component-base v0.34.2 // indirect k8s.io/klog/v2 v2.130.1 // indirect k8s.io/kube-openapi v0.0.0-20250905212525-66792eed8611 // indirect - k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 // indirect sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.2 // 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 d908caadc..bfada64f1 100644 --- a/go.sum +++ b/go.sum @@ -305,6 +305,8 @@ github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7 github.com/rs/xid v1.2.1 h1:mhH9Nq+C1fY2l1XIpgxIiUOfNpRBYH1kKcr+qfKgjRc= github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sethvargo/go-password v0.3.1 h1:WqrLTjo7X6AcVYfC6R7GtSyuUQR9hGyAj/f1PYQZCJU= +github.com/sethvargo/go-password v0.3.1/go.mod h1:rXofC1zT54N7R8K/h1WDUdkf9BOx5OptoxrMBcrXzvs= github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= diff --git a/internal/controller/postgrescluster_controller.go b/internal/controller/postgrescluster_controller.go index c6ef54f28..b97ff0c7a 100644 --- a/internal/controller/postgrescluster_controller.go +++ b/internal/controller/postgrescluster_controller.go @@ -19,7 +19,10 @@ package controller import ( "context" "fmt" + "strings" + cnpgv1 "github.com/cloudnative-pg/cloudnative-pg/api/v1" + "github.com/sethvargo/go-password/password" enterprisev4 "github.com/splunk/splunk-operator/api/v4" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/equality" @@ -28,6 +31,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" + "k8s.io/utils/ptr" ctrl "sigs.k8s.io/controller-runtime" client "sigs.k8s.io/controller-runtime/pkg/client" logs "sigs.k8s.io/controller-runtime/pkg/log" @@ -92,17 +96,57 @@ func (r *PostgresClusterReconciler) Reconcile(ctx context.Context, req ctrl.Requ return ctrl.Result{}, mergeErr } - // 4. Build the desired CNPG Cluster spec based on the merged configuration. - desiredSpec := r.buildCNPGClusterSpec(mergedConfig) + // 4. Ensure PostgresCluster secret exists before creating CNPG cluster. + + var postgresSecretName string + // Check if we already have a secret referenced in the status + + if postgresCluster.Status.Resources != nil && postgresCluster.Status.Resources.SecretRef != nil { + postgresSecretName = postgresCluster.Status.Resources.SecretRef.Name + } else { + // If not, check if we have an orphaned secret (created, but status update failed previously) + secretList := &corev1.SecretList{} + if err := r.List(ctx, secretList, client.InNamespace(postgresCluster.Namespace)); err == nil { + for _, s := range secretList.Items { + if metav1.IsControlledBy(&s, postgresCluster) && s.Type == corev1.SecretTypeOpaque { + postgresSecretName = s.Name + break + } + } + } + // Generate secret using the cluster name and a random suffix to avoid collisions. + if postgresSecretName == "" { + suffix, err := generateRandomSuffix() + if err != nil { + logger.Error(err, "Failed to generate random suffix for PostgresCluster secret") + if statusErr := updateStatus(clusterReady, metav1.ConditionFalse, reasonUserSecretFailed, fmt.Sprintf("Failed to generate random suffix for PostgresCluster secret: %v", err), failedClusterPhase); statusErr != nil { + logger.Error(statusErr, "Failed to update status") + } + return ctrl.Result{}, err + } + postgresSecretName = fmt.Sprintf("%s%s%s", postgresCluster.Name, defaultSecretSuffix, suffix) + } + } + logger.Info("Creating PostgresCluster secret", "name", postgresSecretName) + if err := r.generateSecret(ctx, postgresCluster, postgresSecretName); err != nil { + logger.Error(err, "Failed to ensure PostgresCluster secret", "name", postgresSecretName) + if statusErr := updateStatus(clusterReady, metav1.ConditionFalse, reasonUserSecretFailed, fmt.Sprintf("Failed to generate PostgresCluster secret: %v", err), failedClusterPhase); statusErr != nil { + logger.Error(statusErr, "Failed to update status") + } + return ctrl.Result{}, err + } + + // 5. Build the desired CNPG Cluster spec based on the merged configuration. + desiredSpec := r.buildCNPGClusterSpec(mergedConfig, postgresSecretName) - // 5. Fetch existing CNPG Cluster or create it if it doesn't exist yet. + // 6. Fetch existing CNPG Cluster or create it if it doesn't exist yet. existingCNPG := &cnpgv1.Cluster{} err := r.Get(ctx, types.NamespacedName{Name: postgresCluster.Name, Namespace: postgresCluster.Namespace}, existingCNPG) switch { case apierrors.IsNotFound(err): // CNPG Cluster doesn't exist, create it and requeue for status update. logger.Info("CNPG Cluster not found, creating", "name", postgresCluster.Name) - newCluster := r.buildCNPGCluster(postgresCluster, mergedConfig) + newCluster := r.buildCNPGCluster(postgresCluster, mergedConfig, postgresSecretName) if err = r.Create(ctx, newCluster); err != nil { logger.Error(err, "Failed to create CNPG Cluster") if statusErr := updateStatus(clusterReady, metav1.ConditionFalse, reasonClusterBuildFailed, fmt.Sprintf("Failed to create CNPG Cluster: %v", err), failedClusterPhase); statusErr != nil { @@ -122,11 +166,11 @@ func (r *PostgresClusterReconciler) Reconcile(ctx context.Context, req ctrl.Requ } return ctrl.Result{}, err } - cnpgCluster = existingCNPG - // 6. If CNPG Cluster exists, compare the current spec with the desired spec and update if necessary. - currentNormalizedSpec := normalizeCNPGClusterSpec(cnpgCluster.Spec, mergedConfig.PostgreSQLConfig) - desiredNormalizedSpec := normalizeCNPGClusterSpec(desiredSpec, mergedConfig.PostgreSQLConfig) + // 7. If CNPG Cluster exists, compare the current spec with the desired spec and update if necessary. + cnpgCluster = existingCNPG + currentNormalizedSpec := normalizeCNPGClusterSpec(cnpgCluster.Spec, mergedConfig.PostgreSQLConfig, postgresSecretName) + desiredNormalizedSpec := normalizeCNPGClusterSpec(desiredSpec, mergedConfig.PostgreSQLConfig, postgresSecretName) if !equality.Semantic.DeepEqual(currentNormalizedSpec, desiredNormalizedSpec) { logger.Info("Detected drift in CNPG Cluster spec, patching", "name", cnpgCluster.Name) @@ -238,9 +282,25 @@ func (r *PostgresClusterReconciler) Reconcile(ctx context.Context, req ctrl.Requ } } - // 8. Report progress back to the user and manage the reconciliation lifecycle. + // 8. If CNPG is ready, generate ConfigMap + if cnpgCluster.Status.Phase == cnpgv1.PhaseHealthy { + logger.Info("CNPG Cluster is ready, generating ConfigMap for connection details") + if err := r.reconcileConfigMap(ctx, postgresCluster, cnpgCluster, postgresSecretName); err != nil { + logger.Error(err, "Failed to reconcile ConfigMap") + if statusErr := updateStatus(clusterReady, metav1.ConditionFalse, reasonConfigMapFailed, fmt.Sprintf("Failed to reconcile ConfigMap: %v", err), failedClusterPhase); statusErr != nil { + logger.Error(statusErr, "Failed to update status") + } + return ctrl.Result{}, err + } + logger.Info("ConfigMap created successfully") + } + + // 9. Report progress back to the user and manage the reconciliation lifecycle. if err := r.syncStatus(ctx, postgresCluster, cnpgCluster); err != nil { logger.Error(err, "Failed to sync final status") + if statusErr := updateStatus(clusterReady, metav1.ConditionFalse, reasonStatusSyncFailed, fmt.Sprintf("Failed to sync final status: %v", err), failedClusterPhase); statusErr != nil { + logger.Error(statusErr, "Failed to update status") + } return ctrl.Result{}, err } return ctrl.Result{}, nil @@ -306,7 +366,7 @@ func (r *PostgresClusterReconciler) getMergedConfig(clusterClass *enterprisev4.P // buildCNPGClusterSpec builds the desired CNPG ClusterSpec. // IMPORTANT: any field added here must also be added to normalizedCNPGClusterSpec and normalizeCNPGClusterSpec, // otherwise it will not be included in drift detection and changes will be silently ignored. -func (r *PostgresClusterReconciler) buildCNPGClusterSpec(mergedConfig *enterprisev4.PostgresClusterSpec) cnpgv1.ClusterSpec { +func (r *PostgresClusterReconciler) buildCNPGClusterSpec(mergedConfig *enterprisev4.PostgresClusterSpec, secretName string) cnpgv1.ClusterSpec { // 3. Build the Spec spec := cnpgv1.ClusterSpec{ @@ -316,10 +376,15 @@ func (r *PostgresClusterReconciler) buildCNPGClusterSpec(mergedConfig *enterpris Parameters: mergedConfig.PostgreSQLConfig, PgHBA: mergedConfig.PgHBA, }, + SuperuserSecret: &cnpgv1.LocalObjectReference{ + Name: secretName, + }, + EnableSuperuserAccess: ptr.To(true), + Bootstrap: &cnpgv1.BootstrapConfiguration{ InitDB: &cnpgv1.BootstrapInitDB{ Database: defaultDatabaseName, - Owner: defaultUsername, + Owner: superUsername, }, }, StorageConfiguration: cnpgv1.StorageConfiguration{ @@ -335,13 +400,14 @@ func (r *PostgresClusterReconciler) buildCNPGClusterSpec(mergedConfig *enterpris func (r *PostgresClusterReconciler) buildCNPGCluster( postgresCluster *enterprisev4.PostgresCluster, mergedConfig *enterprisev4.PostgresClusterSpec, + secretName string, ) *cnpgv1.Cluster { cnpgCluster := &cnpgv1.Cluster{ ObjectMeta: metav1.ObjectMeta{ Name: postgresCluster.Name, Namespace: postgresCluster.Namespace, }, - Spec: r.buildCNPGClusterSpec(mergedConfig), + Spec: r.buildCNPGClusterSpec(mergedConfig, secretName), } ctrl.SetControllerReference(postgresCluster, cnpgCluster, r.Scheme) return cnpgCluster @@ -349,7 +415,7 @@ func (r *PostgresClusterReconciler) buildCNPGCluster( // poolerResourceName returns the CNPG Pooler resource name for a given cluster and type (rw/ro). func poolerResourceName(clusterName, poolerType string) string { - return fmt.Sprintf("%s-pooler-%s", clusterName, poolerType) + return fmt.Sprintf("%s%s%s", clusterName, defaultPoolerSuffix, poolerType) } // createOrUpdateConnectionPooler creates or updates CNPG Pooler resources. @@ -620,7 +686,6 @@ func (r *PostgresClusterReconciler) updateStatus( Message: message, ObservedGeneration: postgresCluster.Generation, }) - if err := r.Status().Update(ctx, postgresCluster); err != nil { return fmt.Errorf("failed to update PostgresCluster status: %w", err) } @@ -653,14 +718,16 @@ func (r *PostgresClusterReconciler) syncPoolerStatus(ctx context.Context, postgr rwDesired, rwScheduled := r.getPoolerInstanceCount(rwPooler) roDesired, roScheduled := r.getPoolerInstanceCount(roPooler) - r.updateStatus( + if err := r.updateStatus( ctx, postgresCluster, poolerReady, metav1.ConditionTrue, reasonAllInstancesReady, fmt.Sprintf("%s: %d/%d, %s: %d/%d", readWriteEndpoint, rwScheduled, rwDesired, readOnlyEndpoint, roScheduled, roDesired), - readyClusterPhase) // Not sure if we should use this phase here + readyClusterPhase); err != nil { + return err + } return nil } @@ -765,13 +832,17 @@ func (r *PostgresClusterReconciler) reconcileManagedRoles(ctx context.Context, p return nil } -func normalizeCNPGClusterSpec(spec cnpgv1.ClusterSpec, customDefinedParameters map[string]string) normalizedCNPGClusterSpec { +func normalizeCNPGClusterSpec(spec cnpgv1.ClusterSpec, customDefinedParameters map[string]string, secretName string) normalizedCNPGClusterSpec { normalizedConf := normalizedCNPGClusterSpec{ ImageName: spec.ImageName, Instances: spec.Instances, // Parameters intentionally excluded — CNPG injects defaults that we don't change StorageSize: spec.StorageConfiguration.Size, Resources: spec.Resources, + SuperuserSecret: cnpgv1.LocalObjectReference{ + Name: secretName, + }, + EnableSuperuserAccess: ptr.To(true), } if len(customDefinedParameters) > 0 { @@ -791,12 +862,165 @@ func normalizeCNPGClusterSpec(spec cnpgv1.ClusterSpec, customDefinedParameters m return normalizedConf } +func (r *PostgresClusterReconciler) reconcileConfigMap(ctx context.Context, postgresCluster *enterprisev4.PostgresCluster, cnpgCluster *cnpgv1.Cluster, secretName string) error { + logger := logs.FromContext(ctx) + + configMap := r.generateConfigMap(ctx, postgresCluster, cnpgCluster, secretName) + existingConfigMap := &corev1.ConfigMap{} + err := r.Get(ctx, types.NamespacedName{Namespace: configMap.Namespace, Name: configMap.Name}, existingConfigMap) + if err != nil && apierrors.IsNotFound(err) { + logger.Info("ConfigMap resource not found, creating one") + if err := r.Create(ctx, configMap); err != nil { + return err + } + if err := r.Get(ctx, types.NamespacedName{ + Name: postgresCluster.Name, + Namespace: postgresCluster.Namespace, + }, postgresCluster); err != nil { + logger.Error(err, "Failed to re-fetch PostgresCluster after creating ConfigMap") + return err + } + if postgresCluster.Status.Resources == nil { + postgresCluster.Status.Resources = &enterprisev4.PostgresClusterResources{} + } + postgresCluster.Status.Resources.ConfigMapRef = &corev1.LocalObjectReference{Name: configMap.Name} + return nil + } else if err != nil { + logger.Error(err, "Failed to fetch existing ConfigMap") + return err + } + existingConfigMap.Data = configMap.Data + if err := r.Update(ctx, existingConfigMap); err != nil { + logger.Error(err, "Failed to update ConfigMap") + return err + } + return nil +} + +// generateConfigMap generates a ConfigMap with connection details for the PostgresCluster. +func (r *PostgresClusterReconciler) generateConfigMap(ctx context.Context, postgresCluster *enterprisev4.PostgresCluster, cnpgCluster *cnpgv1.Cluster, secretName string) *corev1.ConfigMap { + logger := logs.FromContext(ctx) + var configMapName string + + if postgresCluster.Status.Resources != nil && postgresCluster.Status.Resources.ConfigMapRef != nil { + configMapName = postgresCluster.Status.Resources.ConfigMapRef.Name + } else { + // Attempt to find existing orphaned ConfigMap + cmList := &corev1.ConfigMapList{} + if err := r.List(ctx, cmList, client.InNamespace(postgresCluster.Namespace)); err == nil { + for _, cm := range cmList.Items { + if metav1.IsControlledBy(&cm, postgresCluster) && strings.HasPrefix(cm.Name, postgresCluster.Name+defaultConfigSuffix) { + configMapName = cm.Name + break + } + } + } + if configMapName == "" { + suffix, err := generateRandomSuffix() + if err != nil { + logger.Error(err, "Failed to generate random suffix for ConfigMap") + return nil + } + configMapName = fmt.Sprintf("%s%s%s", postgresCluster.Name, defaultConfigSuffix, suffix) + } + } + if postgresCluster.Status.Resources != nil && postgresCluster.Status.Resources.ConfigMapRef != nil { + configMapName = postgresCluster.Status.Resources.ConfigMapRef.Name + } + + data := map[string]string{ + "CLUSTER_RW_ENDPOINT": fmt.Sprintf("%s.%s.svc", cnpgCluster.Status.WriteService, postgresCluster.Namespace), + "CLUSTER_R_ENDPOINT": fmt.Sprintf("%s.%s.svc", cnpgCluster.Status.ReadService, postgresCluster.Namespace), + "CLUSTER_RO_ENDPOINT": fmt.Sprintf("%so.%s.svc", cnpgCluster.Status.ReadService, postgresCluster.Namespace), + "DEFAULT_CLUSTER_PORT": defaultPort, + "SUPER_USER_NAME": superUsername, + "DEFAULT_USER_SECRET_REF": secretName, + } + if r.poolerExists(ctx, postgresCluster, readWriteEndpoint) && r.poolerExists(ctx, postgresCluster, readOnlyEndpoint) { + data["CLUSTER_POOLER_RW_ENDPOINT"] = fmt.Sprintf("%s.%s.svc", poolerResourceName(postgresCluster.Name, readWriteEndpoint), postgresCluster.Namespace) + data["CLUSTER_POOLER_RO_ENDPOINT"] = fmt.Sprintf("%s.%s.svc", poolerResourceName(postgresCluster.Name, readOnlyEndpoint), postgresCluster.Namespace) + } + + configMap := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: configMapName, + Namespace: postgresCluster.Namespace, + Labels: map[string]string{"app.kubernetes.io/managed-by": "postgrescluster-controller"}, + }, + Data: data, + } + ctrl.SetControllerReference(postgresCluster, configMap, r.Scheme) + return configMap +} + +// generateSecret creates a Kubernetes Secret with credentials for the default postgres user if it doesn't already exist. +func (r *PostgresClusterReconciler) generateSecret(ctx context.Context, postgresCluster *enterprisev4.PostgresCluster, secretName string) error { + existing := &corev1.Secret{} + err := r.Get(ctx, types.NamespacedName{Name: secretName, Namespace: postgresCluster.Namespace}, existing) + + // If secret does not exist, create it + if apierrors.IsNotFound(err) { + password, err := generatePassword() + if err != nil { + return err + } + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: secretName, + Namespace: postgresCluster.Namespace, + }, + StringData: map[string]string{ + "username": superUsername, + "password": password, + }, + Type: corev1.SecretTypeOpaque, + } + // Set owner reference so we can find it later if status update fails + if err := ctrl.SetControllerReference(postgresCluster, secret, r.Scheme); err != nil { + return err + } + if err := r.Create(ctx, secret); err != nil { + return err + } + } else if err != nil { + return err + } + if postgresCluster.Status.Resources == nil { + postgresCluster.Status.Resources = &enterprisev4.PostgresClusterResources{} + } + postgresCluster.Status.Resources.SecretRef = &corev1.LocalObjectReference{Name: secretName} + + return nil +} + +// generateRandomSuffix returns a short random alphanumeric suffix. +func generateRandomSuffix() (string, error) { + + suff, err := password.Generate(5, 2, 0, false, false) + if err != nil { + fmt.Printf("Error generating random suffix: %v", err) + return "", err + } + return suff, nil +} + +// generatePassword creates a random 16-character alphanumeric password for the default postgres user. +func generatePassword() (string, error) { + pass, err := password.Generate(16, 4, 0, false, false) + if err != nil { + return "", err + } + return pass, nil +} + // SetupWithManager sets up the controller with the Manager. func (r *PostgresClusterReconciler) SetupWithManager(mgr ctrl.Manager) error { return ctrl.NewControllerManagedBy(mgr). For(&enterprisev4.PostgresCluster{}). Owns(&cnpgv1.Cluster{}). Owns(&cnpgv1.Pooler{}). + Owns(&corev1.ConfigMap{}). + Owns(&corev1.Secret{}). Named("postgresCluster"). Complete(r) } diff --git a/internal/controller/postgresoperator_common_types.go b/internal/controller/postgresoperator_common_types.go index 771824c6f..1baa1cee6 100644 --- a/internal/controller/postgresoperator_common_types.go +++ b/internal/controller/postgresoperator_common_types.go @@ -1,6 +1,7 @@ package controller import ( + cnpgv1 "github.com/cloudnative-pg/cloudnative-pg/api/v1" corev1 "k8s.io/api/core/v1" "time" ) @@ -18,6 +19,8 @@ type normalizedCNPGClusterSpec struct { Owner string StorageSize string Resources corev1.ResourceRequirements + SuperuserSecret cnpgv1.LocalObjectReference + EnableSuperuserAccess *bool } type reconcilePhases string @@ -27,14 +30,16 @@ type conditionReasons string type clusterReadyStatus string const ( - // default requeue delay - retryDelay = time.Second * 15 - // cluster endpoint suffixes - readOnlyEndpoint string = "ro" - readWriteEndpoint string = "rw" - // default database name + // defaults + retryDelay = time.Second * 15 + defaultPort = "5432" + readOnlyEndpoint string = "ro" + readWriteEndpoint string = "rw" defaultDatabaseName string = "postgres" - defaultUsername string = "postgres" + superUsername string = "postgres" + defaultSecretSuffix string = "-secret-" + defaultConfigSuffix string = "-config-" + defaultPoolerSuffix string = "-pooler-" // phases ready reconcilePhases = "Ready" @@ -76,6 +81,9 @@ const ( reasonClusterGetFailed conditionReasons = "ClusterGetFailed" reasonClusterPatchFailed conditionReasons = "ClusterPatchFailed" reasonInvalidConfiguration conditionReasons = "InvalidConfiguration" + reasonConfigMapFailed conditionReasons = "ConfigMapReconciliationFailed" + reasonStatusSyncFailed conditionReasons = "StatusSyncFailed" + reasonUserSecretFailed conditionReasons = "UserSecretReconciliationFailed" // Additional condition reasons for poolerReady conditionType reasonPoolerReconciliationFailed conditionReasons = "PoolerReconciliationFailed"