From 6e1c2c09e826681e84b455faf023c0feefb9b19d Mon Sep 17 00:00:00 2001 From: Andrii Dema Date: Mon, 13 Jul 2026 11:51:12 +0300 Subject: [PATCH 01/10] K8SPG-1045: add `.spec.tls.certManagementPolicy` field https://perconadev.atlassian.net/browse/K8SPG-1045 --- .../pgv2.percona.com_perconapgclusters.yaml | 6 + .../pgv2.percona.com_perconapgclusters.yaml | 6 + ...eam.pgv2.percona.com_postgresclusters.yaml | 6 + deploy/bundle.yaml | 12 ++ deploy/cr.yaml | 1 + deploy/crd.yaml | 12 ++ deploy/cw-bundle.yaml | 12 ++ e2e-tests/run-pr.csv | 1 + e2e-tests/run-release.csv | 1 + .../cert-management-policy/00-assert.yaml | 24 ++++ .../00-deploy-operator.yaml | 13 ++ .../cert-management-policy/01-assert.yaml | 79 ++++++++++++ .../01-create-initial-cluster.yaml | 15 +++ .../02-save-secrets.yaml | 33 +++++ .../03-delete-cluster-and-secrets.yaml | 26 ++++ .../cert-management-policy/04-assert.yaml | 19 +++ .../04-create-user-provided-cluster.yaml | 17 +++ .../cert-management-policy/05-assert.yaml | 11 ++ .../05-restore-secrets.yaml | 10 ++ .../cert-management-policy/06-assert.yaml | 60 +++++++++ .../06-restore-instance-secret.yaml | 26 ++++ .../99-remove-cluster-gracefully.yaml | 31 +++++ .../controller/postgrescluster/controller.go | 25 ++++ .../controller/postgrescluster/instance.go | 8 ++ .../controller/postgrescluster/patroni.go | 8 ++ .../controller/postgrescluster/pgbackrest.go | 10 +- .../controller/postgrescluster/pgbouncer.go | 12 +- internal/controller/postgrescluster/pki.go | 119 ++++++++++++++++++ .../controller/postgrescluster/watches.go | 17 +++ .../v1beta1/postgrescluster_types.go | 14 +++ 30 files changed, 631 insertions(+), 3 deletions(-) create mode 100644 e2e-tests/tests/cert-management-policy/00-assert.yaml create mode 100644 e2e-tests/tests/cert-management-policy/00-deploy-operator.yaml create mode 100644 e2e-tests/tests/cert-management-policy/01-assert.yaml create mode 100644 e2e-tests/tests/cert-management-policy/01-create-initial-cluster.yaml create mode 100644 e2e-tests/tests/cert-management-policy/02-save-secrets.yaml create mode 100644 e2e-tests/tests/cert-management-policy/03-delete-cluster-and-secrets.yaml create mode 100644 e2e-tests/tests/cert-management-policy/04-assert.yaml create mode 100644 e2e-tests/tests/cert-management-policy/04-create-user-provided-cluster.yaml create mode 100644 e2e-tests/tests/cert-management-policy/05-assert.yaml create mode 100644 e2e-tests/tests/cert-management-policy/05-restore-secrets.yaml create mode 100644 e2e-tests/tests/cert-management-policy/06-assert.yaml create mode 100644 e2e-tests/tests/cert-management-policy/06-restore-instance-secret.yaml create mode 100644 e2e-tests/tests/cert-management-policy/99-remove-cluster-gracefully.yaml diff --git a/build/crd/percona/generated/pgv2.percona.com_perconapgclusters.yaml b/build/crd/percona/generated/pgv2.percona.com_perconapgclusters.yaml index 594b73af5..46ddf38d3 100644 --- a/build/crd/percona/generated/pgv2.percona.com_perconapgclusters.yaml +++ b/build/crd/percona/generated/pgv2.percona.com_perconapgclusters.yaml @@ -28684,6 +28684,12 @@ spec: properties: caValidityDuration: type: string + certManagementPolicy: + default: auto + enum: + - auto + - userProvidedOnly + type: string certValidityDuration: type: string pgBackRestCertValidityDuration: diff --git a/config/crd/bases/pgv2.percona.com_perconapgclusters.yaml b/config/crd/bases/pgv2.percona.com_perconapgclusters.yaml index 195fd6a63..d54c42c71 100644 --- a/config/crd/bases/pgv2.percona.com_perconapgclusters.yaml +++ b/config/crd/bases/pgv2.percona.com_perconapgclusters.yaml @@ -29379,6 +29379,12 @@ spec: properties: caValidityDuration: type: string + certManagementPolicy: + default: auto + enum: + - auto + - userProvidedOnly + type: string certValidityDuration: type: string pgBackRestCertValidityDuration: diff --git a/config/crd/bases/upstream.pgv2.percona.com_postgresclusters.yaml b/config/crd/bases/upstream.pgv2.percona.com_postgresclusters.yaml index 37b17f1ad..7c03cba8c 100644 --- a/config/crd/bases/upstream.pgv2.percona.com_postgresclusters.yaml +++ b/config/crd/bases/upstream.pgv2.percona.com_postgresclusters.yaml @@ -28665,6 +28665,12 @@ spec: properties: caValidityDuration: type: string + certManagementPolicy: + default: auto + enum: + - auto + - userProvidedOnly + type: string certValidityDuration: type: string pgBackRestCertValidityDuration: diff --git a/deploy/bundle.yaml b/deploy/bundle.yaml index aa9ffe309..38175c3b6 100644 --- a/deploy/bundle.yaml +++ b/deploy/bundle.yaml @@ -29676,6 +29676,12 @@ spec: properties: caValidityDuration: type: string + certManagementPolicy: + default: auto + enum: + - auto + - userProvidedOnly + type: string certValidityDuration: type: string pgBackRestCertValidityDuration: @@ -66729,6 +66735,12 @@ spec: properties: caValidityDuration: type: string + certManagementPolicy: + default: auto + enum: + - auto + - userProvidedOnly + type: string certValidityDuration: type: string pgBackRestCertValidityDuration: diff --git a/deploy/cr.yaml b/deploy/cr.yaml index d814b480f..838e427a1 100644 --- a/deploy/cr.yaml +++ b/deploy/cr.yaml @@ -59,6 +59,7 @@ spec: # name: replication1-cert # tlsOnly: false # tls: +# certManagementPolicy: auto # certValidityDuration: 2160h # caValidityDuration: 26280h # pgBackRestCertValidityDuration: 2160h diff --git a/deploy/crd.yaml b/deploy/crd.yaml index 505329580..23d01e170 100644 --- a/deploy/crd.yaml +++ b/deploy/crd.yaml @@ -29676,6 +29676,12 @@ spec: properties: caValidityDuration: type: string + certManagementPolicy: + default: auto + enum: + - auto + - userProvidedOnly + type: string certValidityDuration: type: string pgBackRestCertValidityDuration: @@ -66729,6 +66735,12 @@ spec: properties: caValidityDuration: type: string + certManagementPolicy: + default: auto + enum: + - auto + - userProvidedOnly + type: string certValidityDuration: type: string pgBackRestCertValidityDuration: diff --git a/deploy/cw-bundle.yaml b/deploy/cw-bundle.yaml index 0216f2dda..dfad9fb5c 100644 --- a/deploy/cw-bundle.yaml +++ b/deploy/cw-bundle.yaml @@ -29676,6 +29676,12 @@ spec: properties: caValidityDuration: type: string + certManagementPolicy: + default: auto + enum: + - auto + - userProvidedOnly + type: string certValidityDuration: type: string pgBackRestCertValidityDuration: @@ -66729,6 +66735,12 @@ spec: properties: caValidityDuration: type: string + certManagementPolicy: + default: auto + enum: + - auto + - userProvidedOnly + type: string certValidityDuration: type: string pgBackRestCertValidityDuration: diff --git a/e2e-tests/run-pr.csv b/e2e-tests/run-pr.csv index 110dff48e..b05888a68 100644 --- a/e2e-tests/run-pr.csv +++ b/e2e-tests/run-pr.csv @@ -1,6 +1,7 @@ backup-enable-disable builtin-extensions cert-manager-tls +cert-management-policy custom-envs custom-tls database-init-sql diff --git a/e2e-tests/run-release.csv b/e2e-tests/run-release.csv index 4a6fb3d52..1ac47406d 100644 --- a/e2e-tests/run-release.csv +++ b/e2e-tests/run-release.csv @@ -1,6 +1,7 @@ backup-enable-disable builtin-extensions cert-manager-tls +cert-management-policy custom-envs custom-tls database-init-sql diff --git a/e2e-tests/tests/cert-management-policy/00-assert.yaml b/e2e-tests/tests/cert-management-policy/00-assert.yaml new file mode 100644 index 000000000..ae5a062d8 --- /dev/null +++ b/e2e-tests/tests/cert-management-policy/00-assert.yaml @@ -0,0 +1,24 @@ +apiVersion: kuttl.dev/v1beta1 +kind: TestAssert +timeout: 120 +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: perconapgclusters.pgv2.percona.com +spec: + group: pgv2.percona.com + names: + kind: PerconaPGCluster + listKind: PerconaPGClusterList + plural: perconapgclusters + singular: perconapgcluster + scope: Namespaced +--- +apiVersion: kuttl.dev/v1beta1 +kind: TestAssert +metadata: + name: check-operator-deploy-status +timeout: 120 +commands: + - script: kubectl assert exist-enhanced deployment percona-postgresql-operator -n ${OPERATOR_NS:-$NAMESPACE} --field-selector status.readyReplicas=1 diff --git a/e2e-tests/tests/cert-management-policy/00-deploy-operator.yaml b/e2e-tests/tests/cert-management-policy/00-deploy-operator.yaml new file mode 100644 index 000000000..4dc6d5cbe --- /dev/null +++ b/e2e-tests/tests/cert-management-policy/00-deploy-operator.yaml @@ -0,0 +1,13 @@ +apiVersion: kuttl.dev/v1beta1 +kind: TestStep +timeout: 120 +commands: + - script: |- + set -o errexit + set -o xtrace + + source ../../functions + init_temp_dir + + destroy_cert_manager + deploy_operator diff --git a/e2e-tests/tests/cert-management-policy/01-assert.yaml b/e2e-tests/tests/cert-management-policy/01-assert.yaml new file mode 100644 index 000000000..1f6cfdb0c --- /dev/null +++ b/e2e-tests/tests/cert-management-policy/01-assert.yaml @@ -0,0 +1,79 @@ +apiVersion: kuttl.dev/v1beta1 +kind: TestAssert +timeout: 300 +--- +apiVersion: v1 +kind: Secret +metadata: + name: cert-management-policy-cluster-ca-cert +--- +apiVersion: v1 +kind: Secret +metadata: + name: cert-management-policy-cluster-cert +--- +apiVersion: v1 +kind: Secret +metadata: + name: cert-management-policy-replication-cert +--- +kind: StatefulSet +apiVersion: apps/v1 +metadata: + labels: + postgres-operator.crunchydata.com/cluster: cert-management-policy + postgres-operator.crunchydata.com/data: postgres + postgres-operator.crunchydata.com/instance-set: instance1 +status: + observedGeneration: 1 + replicas: 1 + readyReplicas: 1 + currentReplicas: 1 + updatedReplicas: 1 + collisionCount: 0 +--- +kind: Deployment +apiVersion: apps/v1 +metadata: + name: cert-management-policy-pgbouncer + labels: + postgres-operator.crunchydata.com/cluster: cert-management-policy + postgres-operator.crunchydata.com/role: pgbouncer +status: + observedGeneration: 1 + replicas: 1 + updatedReplicas: 1 + readyReplicas: 1 +--- +apiVersion: upstream.pgv2.percona.com/v1beta1 +kind: PostgresCluster +metadata: + name: cert-management-policy +status: + instances: + - name: instance1 + readyReplicas: 1 + replicas: 1 + updatedReplicas: 1 + observedGeneration: 1 + proxy: + pgBouncer: + readyReplicas: 1 + replicas: 1 +--- +apiVersion: pgv2.percona.com/v2 +kind: PerconaPGCluster +metadata: + name: cert-management-policy +status: + pgbouncer: + ready: 1 + size: 1 + postgres: + instances: + - name: instance1 + ready: 1 + size: 1 + ready: 1 + size: 1 + state: ready diff --git a/e2e-tests/tests/cert-management-policy/01-create-initial-cluster.yaml b/e2e-tests/tests/cert-management-policy/01-create-initial-cluster.yaml new file mode 100644 index 000000000..dc15a63f4 --- /dev/null +++ b/e2e-tests/tests/cert-management-policy/01-create-initial-cluster.yaml @@ -0,0 +1,15 @@ +apiVersion: kuttl.dev/v1beta1 +kind: TestStep +timeout: 10 +commands: + - script: |- + set -o errexit + set -o xtrace + + source ../../functions + + get_cr "cert-management-policy" \ + | yq '.spec.tls.certManagementPolicy = "auto"' \ + | yq '.spec.proxy.pgBouncer.replicas = 1' \ + | yq '.spec.instances[].replicas = 1' \ + | kubectl -n "$NAMESPACE" apply -f - diff --git a/e2e-tests/tests/cert-management-policy/02-save-secrets.yaml b/e2e-tests/tests/cert-management-policy/02-save-secrets.yaml new file mode 100644 index 000000000..d66854c5c --- /dev/null +++ b/e2e-tests/tests/cert-management-policy/02-save-secrets.yaml @@ -0,0 +1,33 @@ +apiVersion: kuttl.dev/v1beta1 +kind: TestStep +timeout: 180 +commands: + - script: |- + set -o errexit + set -o pipefail + set -o xtrace + + source ../../functions + + cluster="cert-management-policy" + instance=$(kubectl -n "$NAMESPACE" get statefulset \ + -l postgres-operator.crunchydata.com/cluster="$cluster",postgres-operator.crunchydata.com/data=postgres \ + -o jsonpath='{.items[0].metadata.name}') + + kubectl -n "$NAMESPACE" get secret \ + "${cluster}-cluster-ca-cert" \ + "${cluster}-cluster-cert" \ + "${cluster}-replication-cert" \ + "${cluster}-pgbackrest" \ + "${cluster}-pgbouncer" \ + "${instance}-certs" \ + -o yaml \ + | yq eval 'del( + .metadata, + .items[].metadata.creationTimestamp, + .items[].metadata.managedFields, + .items[].metadata.ownerReferences, + .items[].metadata.resourceVersion, + .items[].metadata.uid + )' - \ + >"$TEMP_DIR/tls-secrets.yaml" diff --git a/e2e-tests/tests/cert-management-policy/03-delete-cluster-and-secrets.yaml b/e2e-tests/tests/cert-management-policy/03-delete-cluster-and-secrets.yaml new file mode 100644 index 000000000..79d8212b2 --- /dev/null +++ b/e2e-tests/tests/cert-management-policy/03-delete-cluster-and-secrets.yaml @@ -0,0 +1,26 @@ +apiVersion: kuttl.dev/v1beta1 +kind: TestStep +timeout: 180 +commands: + - script: |- + set -o errexit + set -o pipefail + set -o xtrace + + source ../../functions + + cluster="cert-management-policy" + + kubectl -n "$NAMESPACE" delete perconapgcluster "$cluster" --wait=false + remove_all_finalizers + kubectl -n "$NAMESPACE" wait --for=delete "perconapgcluster/$cluster" --timeout=120s + kubectl -n "$NAMESPACE" wait --for=delete "postgrescluster/$cluster" --timeout=120s + + kubectl -n "$NAMESPACE" delete secret \ + -l postgres-operator.crunchydata.com/cluster="$cluster" \ + --ignore-not-found + kubectl -n "$NAMESPACE" delete secret \ + "${cluster}-cluster-ca-cert" \ + "${cluster}-cluster-cert" \ + "${cluster}-replication-cert" \ + --ignore-not-found diff --git a/e2e-tests/tests/cert-management-policy/04-assert.yaml b/e2e-tests/tests/cert-management-policy/04-assert.yaml new file mode 100644 index 000000000..d8632c239 --- /dev/null +++ b/e2e-tests/tests/cert-management-policy/04-assert.yaml @@ -0,0 +1,19 @@ +apiVersion: kuttl.dev/v1beta1 +kind: TestAssert +timeout: 60 +--- +apiVersion: pgv2.percona.com/v2 +kind: PerconaPGCluster +metadata: + name: cert-management-policy +status: + conditions: + - reason: PGBackRestRepoHostReady + status: "False" + type: ReadyForBackup + - reason: TLSSecretsMissing + status: "False" + type: TLSSecretsReady + - reason: Paused + status: "False" + type: Progressing diff --git a/e2e-tests/tests/cert-management-policy/04-create-user-provided-cluster.yaml b/e2e-tests/tests/cert-management-policy/04-create-user-provided-cluster.yaml new file mode 100644 index 000000000..c9620a0ba --- /dev/null +++ b/e2e-tests/tests/cert-management-policy/04-create-user-provided-cluster.yaml @@ -0,0 +1,17 @@ +apiVersion: kuttl.dev/v1beta1 +kind: TestStep +commands: + - script: |- + set -o errexit + set -o pipefail + set -o xtrace + + source ../../functions + + cluster="cert-management-policy" + + get_cr "$cluster" \ + | yq '.spec.tls.certManagementPolicy = "userProvidedOnly"' \ + | yq '.spec.proxy.pgBouncer.replicas = 1' \ + | yq '.spec.instances[].replicas = 1' \ + | kubectl -n "$NAMESPACE" apply -f - diff --git a/e2e-tests/tests/cert-management-policy/05-assert.yaml b/e2e-tests/tests/cert-management-policy/05-assert.yaml new file mode 100644 index 000000000..e88e4eb4a --- /dev/null +++ b/e2e-tests/tests/cert-management-policy/05-assert.yaml @@ -0,0 +1,11 @@ +apiVersion: kuttl.dev/v1beta1 +kind: TestAssert +timeout: 180 +--- +kind: StatefulSet +apiVersion: apps/v1 +metadata: + labels: + postgres-operator.crunchydata.com/cluster: cert-management-policy + postgres-operator.crunchydata.com/data: postgres + postgres-operator.crunchydata.com/instance-set: instance1 diff --git a/e2e-tests/tests/cert-management-policy/05-restore-secrets.yaml b/e2e-tests/tests/cert-management-policy/05-restore-secrets.yaml new file mode 100644 index 000000000..1d520adf0 --- /dev/null +++ b/e2e-tests/tests/cert-management-policy/05-restore-secrets.yaml @@ -0,0 +1,10 @@ +apiVersion: kuttl.dev/v1beta1 +kind: TestStep +commands: + - script: |- + set -o errexit + set -o xtrace + + source ../../functions + + kubectl -n "$NAMESPACE" apply -f "$TEMP_DIR/tls-secrets.yaml" diff --git a/e2e-tests/tests/cert-management-policy/06-assert.yaml b/e2e-tests/tests/cert-management-policy/06-assert.yaml new file mode 100644 index 000000000..ff107dfb3 --- /dev/null +++ b/e2e-tests/tests/cert-management-policy/06-assert.yaml @@ -0,0 +1,60 @@ +apiVersion: kuttl.dev/v1beta1 +kind: TestAssert +timeout: 600 +--- +apiVersion: v1 +kind: Secret +metadata: + name: cert-management-policy-cluster-ca-cert +--- +apiVersion: v1 +kind: Secret +metadata: + name: cert-management-policy-cluster-cert +--- +apiVersion: v1 +kind: Secret +metadata: + name: cert-management-policy-replication-cert +--- +apiVersion: v1 +kind: Secret +metadata: + name: cert-management-policy-pgbackrest +--- +apiVersion: v1 +kind: Secret +metadata: + name: cert-management-policy-pgbouncer +--- +kind: StatefulSet +apiVersion: apps/v1 +metadata: + labels: + postgres-operator.crunchydata.com/cluster: cert-management-policy + postgres-operator.crunchydata.com/data: postgres + postgres-operator.crunchydata.com/instance-set: instance1 +status: + replicas: 1 + readyReplicas: 1 +--- +kind: Deployment +apiVersion: apps/v1 +metadata: + name: cert-management-policy-pgbouncer +status: + replicas: 1 + readyReplicas: 1 +--- +apiVersion: pgv2.percona.com/v2 +kind: PerconaPGCluster +metadata: + name: cert-management-policy +status: + pgbouncer: + ready: 1 + size: 1 + postgres: + ready: 1 + size: 1 + state: ready diff --git a/e2e-tests/tests/cert-management-policy/06-restore-instance-secret.yaml b/e2e-tests/tests/cert-management-policy/06-restore-instance-secret.yaml new file mode 100644 index 000000000..852519963 --- /dev/null +++ b/e2e-tests/tests/cert-management-policy/06-restore-instance-secret.yaml @@ -0,0 +1,26 @@ +apiVersion: kuttl.dev/v1beta1 +kind: TestStep +commands: + - script: |- + set -o errexit + set -o pipefail + set -o xtrace + + source ../../functions + + cluster="cert-management-policy" + instance=$(kubectl -n "$NAMESPACE" get statefulset \ + -l postgres-operator.crunchydata.com/cluster="$cluster",postgres-operator.crunchydata.com/data=postgres \ + -o jsonpath='{.items[0].metadata.name}') + + old_instance_secret=$(yq eval -r \ + '[.items[].metadata.name | select(test("-certs$"))][0] // ""' \ + "$TEMP_DIR/tls-secrets.yaml") + new_instance_secret="${instance}-certs" + + if [[ -n "$old_instance_secret" && "$old_instance_secret" != "$new_instance_secret" ]]; then + old_instance_secret="$old_instance_secret" new_instance_secret="$new_instance_secret" yq eval \ + '.items[] | select(.metadata.name == strenv(old_instance_secret)) | .metadata.name = strenv(new_instance_secret)' \ + "$TEMP_DIR/tls-secrets.yaml" \ + | kubectl -n "$NAMESPACE" apply -f - + fi diff --git a/e2e-tests/tests/cert-management-policy/99-remove-cluster-gracefully.yaml b/e2e-tests/tests/cert-management-policy/99-remove-cluster-gracefully.yaml new file mode 100644 index 000000000..f7840e50f --- /dev/null +++ b/e2e-tests/tests/cert-management-policy/99-remove-cluster-gracefully.yaml @@ -0,0 +1,31 @@ +apiVersion: kuttl.dev/v1beta1 +kind: TestStep +delete: +- apiVersion: pgv2.percona.com/v2 + kind: PerconaPGCluster + metadata: + name: cert-management-policy +- apiVersion: upstream.pgv2.percona.com/v1beta1 + kind: PostgresCluster + metadata: + name: cert-management-policy +commands: + - script: |- + set -o errexit + set -o xtrace + + source ../../functions + + cluster="cert-management-policy" + + remove_all_finalizers + kubectl -n "$NAMESPACE" delete pvc -l postgres-operator.crunchydata.com/cluster="$cluster" || true + kubectl -n "$NAMESPACE" delete secret -l postgres-operator.crunchydata.com/cluster="$cluster" || true + kubectl -n "$NAMESPACE" delete secret \ + "${cluster}-cluster-ca-cert" \ + "${cluster}-cluster-cert" \ + "${cluster}-replication-cert" \ + --ignore-not-found || true + check_operator_panic + destroy_operator + timeout: 60 diff --git a/internal/controller/postgrescluster/controller.go b/internal/controller/postgrescluster/controller.go index e0518ce57..f34d3c48c 100644 --- a/internal/controller/postgrescluster/controller.go +++ b/internal/controller/postgrescluster/controller.go @@ -254,6 +254,27 @@ func (r *Reconciler) Reconcile( } } + // K8SPG-1045 + if err == nil { + if err = r.reconcileTLSCertManagementPolicy(ctx, cluster); err != nil { + return runtime.ErrorWithBackoff(err) + } + + if cond := meta.FindStatusCondition(cluster.Status.Conditions, v1beta1.ConditionTypeTLSSecretsReady); cond != nil { + if cond.Status == metav1.ConditionFalse { + meta.SetStatusCondition(&cluster.Status.Conditions, metav1.Condition{ + Type: v1beta1.PostgresClusterProgressing, + Status: metav1.ConditionFalse, + Reason: "Paused", + Message: "Reconciliation is paused. Check `TLSSecretsReady` condition", + ObservedGeneration: cluster.GetGeneration(), + }) + return runtime.ErrorWithBackoff(patchClusterStatus()) + } + meta.RemoveStatusCondition(&cluster.Status.Conditions, v1beta1.PostgresClusterProgressing) + } + } + pgHBAs := postgres.NewHBAs() pmm.PostgreSQLHBAs(cluster, &pgHBAs) pgmonitor.PostgreSQLHBAs(cluster, &pgHBAs) @@ -591,6 +612,10 @@ func (r *Reconciler) SetupWithManager(mgr manager.Manager) error { Owns(&rbacv1.RoleBinding{}). Owns(&batchv1.CronJob{}). Owns(&policyv1.PodDisruptionBudget{}). + Watches(&corev1.Secret{}, r.watchClusterSecrets(), builder.WithPredicates(predicate.NewPredicateFuncs(func(obj client.Object) bool { + _, hasCluster := obj.GetLabels()[naming.LabelCluster] + return hasCluster + }))). Watches(&corev1.Pod{}, r.watchPods()). Watches(&appsv1.StatefulSet{}, r.controllerRefHandlerFuncs()) // watch all StatefulSets diff --git a/internal/controller/postgrescluster/instance.go b/internal/controller/postgrescluster/instance.go index 44913866c..77e04cdde 100644 --- a/internal/controller/postgrescluster/instance.go +++ b/internal/controller/postgrescluster/instance.go @@ -1489,6 +1489,14 @@ func (r *Reconciler) reconcileInstanceCertificates( rootCertificateAuth *pki.RootCertificateAuthority, ) (*corev1.Secret, error) { if cluster.Spec.CustomTLSSecret == nil { + if cluster.Spec.TLS.CertManagementPolicy == v1beta1.CertManagementUserProvidedOnly { + existing := &corev1.Secret{ObjectMeta: naming.InstanceCertificates(instance)} + if err := r.Client.Get(ctx, client.ObjectKeyFromObject(existing), existing); err != nil { + return nil, errors.Wrapf(err, "get user-provided instance TLS secret %s", existing.Name) + } + return existing, nil + } + certManagerManaged, err := r.isRootCACertManagerManaged(ctx, cluster) if err != nil { return nil, errors.Wrap(err, "failed to check if cert-manager manages root CA") diff --git a/internal/controller/postgrescluster/patroni.go b/internal/controller/postgrescluster/patroni.go index 858b6cafd..bdfbedbcc 100644 --- a/internal/controller/postgrescluster/patroni.go +++ b/internal/controller/postgrescluster/patroni.go @@ -380,6 +380,14 @@ func (r *Reconciler) reconcileReplicationSecret( return custom, err } + if cluster.Spec.TLS.CertManagementPolicy == v1beta1.CertManagementUserProvidedOnly { + secret := &corev1.Secret{ObjectMeta: naming.ReplicationClientCertSecret(cluster)} + if err := r.Client.Get(ctx, client.ObjectKeyFromObject(secret), secret); err != nil { + return nil, errors.Wrapf(err, "get user-provided replication TLS secret %s", secret.Name) + } + return secret, nil + } + certManagerManaged, err := r.isRootCACertManagerManaged(ctx, cluster) if err != nil { return nil, errors.Wrap(err, "failed to check if cert-manager manages root CA") diff --git a/internal/controller/postgrescluster/pgbackrest.go b/internal/controller/postgrescluster/pgbackrest.go index a83804d62..c4e5497c5 100644 --- a/internal/controller/postgrescluster/pgbackrest.go +++ b/internal/controller/postgrescluster/pgbackrest.go @@ -2219,7 +2219,15 @@ func (r *Reconciler) reconcilePGBackRestSecret(ctx context.Context, existing := &corev1.Secret{} err := errors.WithStack(client.IgnoreNotFound( - r.Client.Get(ctx, client.ObjectKeyFromObject(intent), existing))) + r.Client.Get(ctx, client.ObjectKeyFromObject(intent), existing), + )) + + if err == nil && cluster.Spec.TLS.CertManagementPolicy == v1beta1.CertManagementUserProvidedOnly { + if repoHost != nil && len(existing.Name) == 0 { + return errors.Errorf("user-provided pgBackRest secret %q is missing", intent.Name) + } + return nil + } // K8SPG-330: Keep this commented in case of conflicts. // We don't want to delete TLS secrets on cluster deletion. diff --git a/internal/controller/postgrescluster/pgbouncer.go b/internal/controller/postgrescluster/pgbouncer.go index 1b63db2de..a08d7f88f 100644 --- a/internal/controller/postgrescluster/pgbouncer.go +++ b/internal/controller/postgrescluster/pgbouncer.go @@ -247,13 +247,14 @@ func (r *Reconciler) reconcilePGBouncerSecret( ctx context.Context, cluster *v1beta1.PostgresCluster, root *pki.RootCertificateAuthority, service *corev1.Service, ) (*corev1.Secret, error) { - existing := &corev1.Secret{ObjectMeta: naming.ClusterPGBouncer(cluster)} err := errors.WithStack( - r.Client.Get(ctx, client.ObjectKeyFromObject(existing), existing)) + r.Client.Get(ctx, client.ObjectKeyFromObject(existing), existing), + ) if client.IgnoreNotFound(err) != nil { return nil, err } + secretFound := err == nil if cluster.Spec.Proxy == nil || cluster.Spec.Proxy.PGBouncer == nil { // PgBouncer is disabled; delete the Secret if it exists. @@ -265,6 +266,13 @@ func (r *Reconciler) reconcilePGBouncerSecret( err = client.IgnoreNotFound(err) + if cluster.Spec.TLS.CertManagementPolicy == v1beta1.CertManagementUserProvidedOnly { + if !secretFound { + return nil, errors.Errorf("user-provided PgBouncer secret %q is missing", naming.ClusterPGBouncer(cluster).Name) + } + return existing, nil + } + var frontendCertManagerSecret *corev1.Secret if cluster.Spec.Proxy.PGBouncer.CustomTLSSecret == nil { certManagerManaged, certErr := r.isRootCACertManagerManaged(ctx, cluster) diff --git a/internal/controller/postgrescluster/pki.go b/internal/controller/postgrescluster/pki.go index 7ee3d61f7..7051a753a 100644 --- a/internal/controller/postgrescluster/pki.go +++ b/internal/controller/postgrescluster/pki.go @@ -6,12 +6,14 @@ package postgrescluster import ( "context" + "strings" gover "github.com/hashicorp/go-version" "github.com/pkg/errors" appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" k8serrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "sigs.k8s.io/controller-runtime/pkg/client" @@ -29,6 +31,91 @@ const ( rootCertFile = "ca.crt" ) +// K8SPG-1045 +func (r *Reconciler) reconcileTLSCertManagementPolicy(ctx context.Context, cluster *v1beta1.PostgresCluster) error { + cond := metav1.Condition{ + Type: v1beta1.ConditionTypeTLSSecretsReady, + Status: metav1.ConditionTrue, + Reason: "TLSSecretsFound", + ObservedGeneration: cluster.GetGeneration(), + } + if cluster.Spec.TLS.CertManagementPolicy != v1beta1.CertManagementUserProvidedOnly { + cond.Message = "certManagementPolicy is " + string(cluster.Spec.TLS.CertManagementPolicy) + meta.SetStatusCondition(&cluster.Status.Conditions, cond) + return nil + } + + var missing []string + + checkSecret := func(projection *corev1.SecretProjection, secretName string) error { + if projection != nil { + secretName = projection.Name + } + secret := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{ + Namespace: cluster.Namespace, + Name: secretName, + }} + err := r.Client.Get(ctx, client.ObjectKeyFromObject(secret), secret) + if client.IgnoreNotFound(err) != nil { + return errors.Wrapf(err, "get TLS secret %s", secret.Name) + } + if k8serrors.IsNotFound(err) { + missing = append(missing, secret.Name) + } + + return nil + } + + if err := checkSecret(cluster.Spec.CustomRootCATLSSecret, naming.PostgresRootCASecret(cluster).Name); err != nil { + return errors.Wrap(err, "check root ca secret") + } + if err := checkSecret(cluster.Spec.CustomTLSSecret, naming.PostgresTLSSecret(cluster).Name); err != nil { + return errors.Wrap(err, "check custom tls secret") + } + if err := checkSecret(cluster.Spec.CustomReplicationClientTLSSecret, naming.ReplicationClientCertSecret(cluster).Name); err != nil { + return errors.Wrap(err, "check replication client cert secret") + } + if err := checkSecret(nil, naming.PGBackRestSecret(cluster).Name); err != nil { + return errors.Wrap(err, "check pgBackRest TLS secret") + } + + if cluster.Spec.Proxy != nil && cluster.Spec.Proxy.PGBouncer != nil { + if err := checkSecret(nil, naming.ClusterPGBouncer(cluster).Name); err != nil { + return errors.Wrap(err, "check PgBouncer TLS secret") + } + } + + if cluster.Spec.CustomTLSSecret == nil { + instances := &appsv1.StatefulSetList{} + if err := r.Client.List( + ctx, instances, + client.InNamespace(cluster.Namespace), + client.MatchingLabels{ + naming.LabelCluster: cluster.Name, + naming.LabelData: naming.DataPostgres, + }, + ); err != nil { + return errors.Wrap(err, "list instances to check TLS secrets") + } + + for i := range instances.Items { + if err := checkSecret(nil, naming.InstanceCertificates(&instances.Items[i]).Name); err != nil { + return errors.Wrap(err, "check instance TLS secret") + } + } + } + + if len(missing) > 0 { + cond.Message = "Missing user-provided TLS secrets: " + strings.Join(missing, ", ") + ". certManagementPolicy is userProvidedOnly" + cond.Reason = "TLSSecretsMissing" + cond.Status = metav1.ConditionFalse + meta.SetStatusCondition(&cluster.Status.Conditions, cond) + return nil + } + + meta.SetStatusCondition(&cluster.Status.Conditions, cond) + return nil +} // +kubebuilder:rbac:groups="",resources="secrets",verbs={get} // +kubebuilder:rbac:groups="",resources="secrets",verbs={create,patch} @@ -89,6 +176,19 @@ func (r *Reconciler) reconcileRootCertificate( } } + if cluster.Spec.TLS.CertManagementPolicy == v1beta1.CertManagementUserProvidedOnly { + if err != nil { + return nil, errors.Wrap(err, "get user-provided root CA secret") + } + + root := &pki.RootCertificateAuthority{} + _ = root.Certificate.UnmarshalText(existing.Data[certificateKey]) + _ = root.PrivateKey.UnmarshalText(existing.Data[privateKey]) + if !pki.RootIsValid(root) { + return nil, errors.Errorf("user-provided root CA secret %q is invalid", existing.Name) + } + return root, nil + } // If the secret is managed by cert-manager, parse it using cert-manager key names // (tls.crt/tls.key) and return without overwriting the secret with internal PKI. if err == nil && existing.Annotations["cert-manager.io/certificate-name"] != "" { @@ -220,6 +320,9 @@ func (r *Reconciler) reconcileClusterCertificate( return cluster.Spec.CustomTLSSecret, nil } + if cluster.Spec.TLS.CertManagementPolicy == v1beta1.CertManagementUserProvidedOnly { + return r.reconcileUserProvidedClusterCertificate(ctx, cluster) + } certManagerManaged, err := r.isRootCACertManagerManaged(ctx, cluster) if err != nil { return nil, errors.Wrap(err, "failed to check if cert-manager manages root CA") @@ -242,6 +345,22 @@ func (r *Reconciler) reconcileClusterCertificate( return r.reconcileInternalClusterCertificate(ctx, root, cluster, primaryService, replicaService) } +func (r *Reconciler) reconcileUserProvidedClusterCertificate( + ctx context.Context, cluster *v1beta1.PostgresCluster, +) (*corev1.SecretProjection, error) { + secret := &corev1.Secret{ObjectMeta: naming.PostgresTLSSecret(cluster)} + if err := r.Client.Get(ctx, client.ObjectKeyFromObject(secret), secret); err != nil { + return nil, errors.Wrapf(err, "get user-provided TLS secret %s", secret.Name) + } + + for _, key := range []string{clusterCertFile, clusterKeyFile, rootCertFile} { + if len(secret.Data[key]) == 0 { + return nil, errors.Errorf("user-provided TLS secret %q is missing key %q", secret.Name, key) + } + } + + return clusterCertSecretProjection(secret), nil +} // reconcileInternalClusterCertificate creates a cluster certificate using internal PKI. func (r *Reconciler) reconcileInternalClusterCertificate( ctx context.Context, root *pki.RootCertificateAuthority, diff --git a/internal/controller/postgrescluster/watches.go b/internal/controller/postgrescluster/watches.go index 2175bef86..7c458598f 100644 --- a/internal/controller/postgrescluster/watches.go +++ b/internal/controller/postgrescluster/watches.go @@ -17,6 +17,23 @@ import ( "github.com/percona/percona-postgresql-operator/v2/internal/patroni" ) +// watchClusterSecrets returns a handler.EventHandler for Secrets that are +// labeled with a PostgresCluster name but intentionally have no owner reference. +func (*Reconciler) watchClusterSecrets() handler.EventHandler { + return handler.EnqueueRequestsFromMapFunc(func(ctx context.Context, obj client.Object) []reconcile.Request { + cluster := obj.GetLabels()[naming.LabelCluster] + if len(cluster) > 0 { + return []reconcile.Request{ + {NamespacedName: client.ObjectKey{ + Namespace: obj.GetNamespace(), + Name: cluster, + }}, + } + } + return nil + }) +} + // watchCertManagerSecrets returns a handler.EventHandler for cert-manager-issued // Secrets. These Secrets are owned by Certificate resources (not PostgresCluster), // so they are not covered by Owns(&corev1.Secret{}). diff --git a/pkg/apis/upstream.pgv2.percona.com/v1beta1/postgrescluster_types.go b/pkg/apis/upstream.pgv2.percona.com/v1beta1/postgrescluster_types.go index 0f7d466d9..570ecaf6a 100644 --- a/pkg/apis/upstream.pgv2.percona.com/v1beta1/postgrescluster_types.go +++ b/pkg/apis/upstream.pgv2.percona.com/v1beta1/postgrescluster_types.go @@ -228,8 +228,22 @@ type TLSSpec struct { CAValidityDuration *metav1.Duration `json:"caValidityDuration,omitempty"` // +optional PGBackRestCertValidityDuration *metav1.Duration `json:"pgBackRestCertValidityDuration,omitempty"` + // +kubebuilder:default=auto + // +kubebuilder:validation:Enum={auto,userProvidedOnly} + CertManagementPolicy CertManagementPolicy `json:"certManagementPolicy,omitempty"` } +type CertManagementPolicy string + +const ( + CertManagementAuto CertManagementPolicy = "auto" + CertManagementUserProvidedOnly CertManagementPolicy = "userProvidedOnly" +) + +const ( + ConditionTypeTLSSecretsReady = "TLSSecretsReady" +) + // DataSource defines data sources for a new PostgresCluster. type DataSource struct { // Defines a pgBackRest cloud-based data source that can be used to pre-populate the From 306f8462eede596709183e0f47a9cc3d5c146f5b Mon Sep 17 00:00:00 2001 From: Andrii Dema Date: Tue, 14 Jul 2026 13:17:34 +0300 Subject: [PATCH 02/10] fix --- internal/controller/postgrescluster/pki.go | 23 ++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/internal/controller/postgrescluster/pki.go b/internal/controller/postgrescluster/pki.go index 7051a753a..9f77d19ec 100644 --- a/internal/controller/postgrescluster/pki.go +++ b/internal/controller/postgrescluster/pki.go @@ -39,8 +39,13 @@ func (r *Reconciler) reconcileTLSCertManagementPolicy(ctx context.Context, clust Reason: "TLSSecretsFound", ObservedGeneration: cluster.GetGeneration(), } - if cluster.Spec.TLS.CertManagementPolicy != v1beta1.CertManagementUserProvidedOnly { - cond.Message = "certManagementPolicy is " + string(cluster.Spec.TLS.CertManagementPolicy) + policy := v1beta1.CertManagementAuto + if cluster.Spec.TLS != nil && cluster.Spec.TLS.CertManagementPolicy != "" { + policy = cluster.Spec.TLS.CertManagementPolicy + } + + if policy != v1beta1.CertManagementUserProvidedOnly { + cond.Message = "certManagementPolicy is " + string(policy) meta.SetStatusCondition(&cluster.Status.Conditions, cond) return nil } @@ -116,6 +121,7 @@ func (r *Reconciler) reconcileTLSCertManagementPolicy(ctx context.Context, clust meta.SetStatusCondition(&cluster.Status.Conditions, cond) return nil } + // +kubebuilder:rbac:groups="",resources="secrets",verbs={get} // +kubebuilder:rbac:groups="",resources="secrets",verbs={create,patch} @@ -152,14 +158,16 @@ func (r *Reconciler) reconcileRootCertificate( } err := errors.WithStack( - r.Client.Get(ctx, client.ObjectKeyFromObject(existing), existing)) + r.Client.Get(ctx, client.ObjectKeyFromObject(existing), existing), + ) // K8SPG-555: we need to check ca certificate from old operator versions // TODO: remove when 2.4.0 will become unsupported if k8serrors.IsNotFound(err) { nn := client.ObjectKeyFromObject(existing) nn.Name = naming.RootCertSecret err = errors.WithStack( - r.Client.Get(ctx, nn, existing)) + r.Client.Get(ctx, nn, existing), + ) if err == nil { existing.Name = naming.RootCertSecret } @@ -361,6 +369,7 @@ func (r *Reconciler) reconcileUserProvidedClusterCertificate( return clusterCertSecretProjection(secret), nil } + // reconcileInternalClusterCertificate creates a cluster certificate using internal PKI. func (r *Reconciler) reconcileInternalClusterCertificate( ctx context.Context, root *pki.RootCertificateAuthority, @@ -373,7 +382,8 @@ func (r *Reconciler) reconcileInternalClusterCertificate( existing := &corev1.Secret{ObjectMeta: naming.PostgresTLSSecret(cluster)} err := errors.WithStack(client.IgnoreNotFound( - r.Client.Get(ctx, client.ObjectKeyFromObject(existing), existing))) + r.Client.Get(ctx, client.ObjectKeyFromObject(existing), existing), + )) leaf := &pki.LeafCertificate{} primaryServiceDNSNames, err := naming.ServiceDNSNames(ctx, primaryService, cluster.Spec.ClusterServiceDNSSuffix) @@ -411,7 +421,8 @@ func (r *Reconciler) reconcileInternalClusterCertificate( naming.WithPerconaLabels(map[string]string{ naming.LabelCluster: cluster.Name, naming.LabelClusterCertificate: "postgres-tls", - }, cluster.Name, "", cluster.Labels[naming.LabelVersion])) + }, cluster.Name, "", cluster.Labels[naming.LabelVersion]), + ) // K8SPG-330: Keep this commented in case of conflicts. // We don't want to delete TLS secrets on cluster deletion. From 11537d74d4953d0012182da9c30019ad5238b053 Mon Sep 17 00:00:00 2001 From: Andrii Dema Date: Tue, 14 Jul 2026 13:33:52 +0300 Subject: [PATCH 03/10] fix tests --- .../controller/postgrescluster/instance.go | 2 +- .../controller/postgrescluster/patroni.go | 38 ++++++++++++------- .../controller/postgrescluster/pgbackrest.go | 2 +- .../controller/postgrescluster/pgbouncer.go | 2 +- internal/controller/postgrescluster/pki.go | 10 ++--- .../v1beta1/postgrescluster_types.go | 7 ++++ 6 files changed, 38 insertions(+), 23 deletions(-) diff --git a/internal/controller/postgrescluster/instance.go b/internal/controller/postgrescluster/instance.go index 77e04cdde..a4509838e 100644 --- a/internal/controller/postgrescluster/instance.go +++ b/internal/controller/postgrescluster/instance.go @@ -1489,7 +1489,7 @@ func (r *Reconciler) reconcileInstanceCertificates( rootCertificateAuth *pki.RootCertificateAuthority, ) (*corev1.Secret, error) { if cluster.Spec.CustomTLSSecret == nil { - if cluster.Spec.TLS.CertManagementPolicy == v1beta1.CertManagementUserProvidedOnly { + if cluster.Spec.TLS.GetCertManagementPolicy() == v1beta1.CertManagementUserProvidedOnly { existing := &corev1.Secret{ObjectMeta: naming.InstanceCertificates(instance)} if err := r.Client.Get(ctx, client.ObjectKeyFromObject(existing), existing); err != nil { return nil, errors.Wrapf(err, "get user-provided instance TLS secret %s", existing.Name) diff --git a/internal/controller/postgrescluster/patroni.go b/internal/controller/postgrescluster/patroni.go index bdfbedbcc..ec61fece4 100644 --- a/internal/controller/postgrescluster/patroni.go +++ b/internal/controller/postgrescluster/patroni.go @@ -37,10 +37,12 @@ func (r *Reconciler) deletePatroniArtifacts( selector, err := naming.AsSelector(naming.ClusterPatronis(cluster)) if err == nil { err = errors.WithStack( - r.Client.DeleteAllOf(ctx, &corev1.Endpoints{}, + r.Client.DeleteAllOf( + ctx, &corev1.Endpoints{}, client.InNamespace(cluster.Namespace), client.MatchingLabelsSelector{Selector: selector}, - )) + ), + ) } return err @@ -158,13 +160,15 @@ func (r *Reconciler) reconcilePatroniDistributedConfiguration( err := errors.WithStack(r.setControllerReference(cluster, dcsService)) dcsService.Annotations = naming.Merge( - cluster.Spec.Metadata.GetAnnotationsOrNil()) + cluster.Spec.Metadata.GetAnnotationsOrNil(), + ) dcsService.Labels = naming.Merge( cluster.Spec.Metadata.GetLabelsOrNil(), naming.WithPerconaLabels(map[string]string{ // K8SPG-430 naming.LabelCluster: cluster.Name, naming.LabelPatroni: naming.PatroniScope(cluster), - }, cluster.Name, "", cluster.Labels[naming.LabelVersion])) + }, cluster.Name, "", cluster.Labels[naming.LabelVersion]), + ) // Allocate no IP address (headless) and create no Endpoints. // - https://docs.k8s.io/concepts/services-networking/service/#headless-services @@ -225,7 +229,8 @@ func (r *Reconciler) reconcilePatroniDynamicConfiguration( logging.FromContext(ctx).V(1).Info("Replacing patroni dynamic configuration") return errors.WithStack( - patroni.Executor(exec).ReplaceConfiguration(ctx, configuration)) + patroni.Executor(exec).ReplaceConfiguration(ctx, configuration), + ) } // generatePatroniLeaderLeaseService returns a v1.Service that exposes the @@ -237,9 +242,11 @@ func (r *Reconciler) generatePatroniLeaderLeaseService( service.SetGroupVersionKind(corev1.SchemeGroupVersion.WithKind("Service")) service.Annotations = naming.Merge( - cluster.Spec.Metadata.GetAnnotationsOrNil()) + cluster.Spec.Metadata.GetAnnotationsOrNil(), + ) service.Labels = naming.Merge( - cluster.Spec.Metadata.GetLabelsOrNil()) + cluster.Spec.Metadata.GetLabelsOrNil(), + ) if spec := cluster.Spec.Service; spec != nil { service.Annotations = naming.Merge(service.Annotations, @@ -338,7 +345,8 @@ func (r *Reconciler) reconcilePatroniStatus( dcs := &corev1.Endpoints{ObjectMeta: naming.PatroniDistributedConfiguration(cluster)} err := errors.WithStack(client.IgnoreNotFound( - r.Client.Get(ctx, client.ObjectKeyFromObject(dcs), dcs))) + r.Client.Get(ctx, client.ObjectKeyFromObject(dcs), dcs), + )) if err == nil { if dcs.Annotations["initialize"] != "" { @@ -380,7 +388,7 @@ func (r *Reconciler) reconcileReplicationSecret( return custom, err } - if cluster.Spec.TLS.CertManagementPolicy == v1beta1.CertManagementUserProvidedOnly { + if cluster.Spec.TLS.GetCertManagementPolicy() == v1beta1.CertManagementUserProvidedOnly { secret := &corev1.Secret{ObjectMeta: naming.ReplicationClientCertSecret(cluster)} if err := r.Client.Get(ctx, client.ObjectKeyFromObject(secret), secret); err != nil { return nil, errors.Wrapf(err, "get user-provided replication TLS secret %s", secret.Name) @@ -417,7 +425,8 @@ func (r *Reconciler) reconcileInternalReplicationSecret( ) (*corev1.Secret, error) { existing := &corev1.Secret{ObjectMeta: naming.ReplicationClientCertSecret(cluster)} err := errors.WithStack(client.IgnoreNotFound( - r.Client.Get(ctx, client.ObjectKeyFromObject(existing), existing))) + r.Client.Get(ctx, client.ObjectKeyFromObject(existing), existing), + )) leaf := &pki.LeafCertificate{} commonName := postgres.ReplicationUser @@ -440,14 +449,16 @@ func (r *Reconciler) reconcileInternalReplicationSecret( // set labels and annotations intent.Annotations = naming.Merge( - cluster.Spec.Metadata.GetAnnotationsOrNil()) + cluster.Spec.Metadata.GetAnnotationsOrNil(), + ) intent.Labels = naming.Merge( cluster.Spec.Metadata.GetLabelsOrNil(), naming.WithPerconaLabels(map[string]string{ naming.LabelCluster: cluster.Name, naming.LabelClusterCertificate: "replication-client-tls", - }, cluster.Name, "", cluster.Labels[naming.LabelVersion])) + }, cluster.Name, "", cluster.Labels[naming.LabelVersion]), + ) // K8SPG-330: Keep this commented in case of conflicts. // We don't want to delete TLS secrets on cluster deletion. @@ -572,7 +583,8 @@ func (r *Reconciler) reconcilePatroniSwitchover(ctx context.Context, if len(targetInstance.Pods) != 1 { // We expect that a target instance should have one associated pod. return errors.Errorf( - "TargetInstance should have one pod. Pods (%d)", len(targetInstance.Pods)) + "TargetInstance should have one pod. Pods (%d)", len(targetInstance.Pods), + ) } } else { log.V(1).Info("TargetInstance not provided") diff --git a/internal/controller/postgrescluster/pgbackrest.go b/internal/controller/postgrescluster/pgbackrest.go index c4e5497c5..93087fd3a 100644 --- a/internal/controller/postgrescluster/pgbackrest.go +++ b/internal/controller/postgrescluster/pgbackrest.go @@ -2222,7 +2222,7 @@ func (r *Reconciler) reconcilePGBackRestSecret(ctx context.Context, r.Client.Get(ctx, client.ObjectKeyFromObject(intent), existing), )) - if err == nil && cluster.Spec.TLS.CertManagementPolicy == v1beta1.CertManagementUserProvidedOnly { + if err == nil && cluster.Spec.TLS.GetCertManagementPolicy() == v1beta1.CertManagementUserProvidedOnly { if repoHost != nil && len(existing.Name) == 0 { return errors.Errorf("user-provided pgBackRest secret %q is missing", intent.Name) } diff --git a/internal/controller/postgrescluster/pgbouncer.go b/internal/controller/postgrescluster/pgbouncer.go index a08d7f88f..73b47be1b 100644 --- a/internal/controller/postgrescluster/pgbouncer.go +++ b/internal/controller/postgrescluster/pgbouncer.go @@ -266,7 +266,7 @@ func (r *Reconciler) reconcilePGBouncerSecret( err = client.IgnoreNotFound(err) - if cluster.Spec.TLS.CertManagementPolicy == v1beta1.CertManagementUserProvidedOnly { + if cluster.Spec.TLS.GetCertManagementPolicy() == v1beta1.CertManagementUserProvidedOnly { if !secretFound { return nil, errors.Errorf("user-provided PgBouncer secret %q is missing", naming.ClusterPGBouncer(cluster).Name) } diff --git a/internal/controller/postgrescluster/pki.go b/internal/controller/postgrescluster/pki.go index 9f77d19ec..ea1ab4ee3 100644 --- a/internal/controller/postgrescluster/pki.go +++ b/internal/controller/postgrescluster/pki.go @@ -39,13 +39,9 @@ func (r *Reconciler) reconcileTLSCertManagementPolicy(ctx context.Context, clust Reason: "TLSSecretsFound", ObservedGeneration: cluster.GetGeneration(), } - policy := v1beta1.CertManagementAuto - if cluster.Spec.TLS != nil && cluster.Spec.TLS.CertManagementPolicy != "" { - policy = cluster.Spec.TLS.CertManagementPolicy - } - if policy != v1beta1.CertManagementUserProvidedOnly { - cond.Message = "certManagementPolicy is " + string(policy) + if cluster.Spec.TLS.GetCertManagementPolicy() != v1beta1.CertManagementUserProvidedOnly { + cond.Message = "certManagementPolicy is " + string(cluster.Spec.TLS.GetCertManagementPolicy()) meta.SetStatusCondition(&cluster.Status.Conditions, cond) return nil } @@ -184,7 +180,7 @@ func (r *Reconciler) reconcileRootCertificate( } } - if cluster.Spec.TLS.CertManagementPolicy == v1beta1.CertManagementUserProvidedOnly { + if cluster.Spec.TLS.GetCertManagementPolicy() == v1beta1.CertManagementUserProvidedOnly { if err != nil { return nil, errors.Wrap(err, "get user-provided root CA secret") } diff --git a/pkg/apis/upstream.pgv2.percona.com/v1beta1/postgrescluster_types.go b/pkg/apis/upstream.pgv2.percona.com/v1beta1/postgrescluster_types.go index 570ecaf6a..ddf700382 100644 --- a/pkg/apis/upstream.pgv2.percona.com/v1beta1/postgrescluster_types.go +++ b/pkg/apis/upstream.pgv2.percona.com/v1beta1/postgrescluster_types.go @@ -233,6 +233,13 @@ type TLSSpec struct { CertManagementPolicy CertManagementPolicy `json:"certManagementPolicy,omitempty"` } +func (s *TLSSpec) GetCertManagementPolicy() CertManagementPolicy { + if s == nil || s.CertManagementPolicy == "" { + return CertManagementAuto + } + return s.CertManagementPolicy +} + type CertManagementPolicy string const ( From 445f022aa131c74e43f4c59bd6e895805e886071 Mon Sep 17 00:00:00 2001 From: Andrii Dema Date: Wed, 15 Jul 2026 14:31:49 +0300 Subject: [PATCH 04/10] fix --- .../03-delete-cluster-and-secrets.yaml | 4 +++ .../cert-management-policy/05-assert.yaml | 25 +++++++++++++++++++ .../05-restore-secrets.yaml | 5 +++- .../06-restore-instance-secret.yaml | 15 +++++------ .../controller/postgrescluster/instance.go | 7 +++++- internal/controller/postgrescluster/pki.go | 2 +- 6 files changed, 46 insertions(+), 12 deletions(-) diff --git a/e2e-tests/tests/cert-management-policy/03-delete-cluster-and-secrets.yaml b/e2e-tests/tests/cert-management-policy/03-delete-cluster-and-secrets.yaml index 79d8212b2..c89a5a6ec 100644 --- a/e2e-tests/tests/cert-management-policy/03-delete-cluster-and-secrets.yaml +++ b/e2e-tests/tests/cert-management-policy/03-delete-cluster-and-secrets.yaml @@ -16,6 +16,10 @@ commands: kubectl -n "$NAMESPACE" wait --for=delete "perconapgcluster/$cluster" --timeout=120s kubectl -n "$NAMESPACE" wait --for=delete "postgrescluster/$cluster" --timeout=120s + kubectl -n "$NAMESPACE" delete pvc \ + -l postgres-operator.crunchydata.com/cluster="$cluster" \ + --ignore-not-found + kubectl -n "$NAMESPACE" delete secret \ -l postgres-operator.crunchydata.com/cluster="$cluster" \ --ignore-not-found diff --git a/e2e-tests/tests/cert-management-policy/05-assert.yaml b/e2e-tests/tests/cert-management-policy/05-assert.yaml index e88e4eb4a..58d79931e 100644 --- a/e2e-tests/tests/cert-management-policy/05-assert.yaml +++ b/e2e-tests/tests/cert-management-policy/05-assert.yaml @@ -9,3 +9,28 @@ metadata: postgres-operator.crunchydata.com/cluster: cert-management-policy postgres-operator.crunchydata.com/data: postgres postgres-operator.crunchydata.com/instance-set: instance1 +--- +apiVersion: pgv2.percona.com/v2 +kind: PerconaPGCluster +metadata: + name: cert-management-policy +status: + conditions: + - reason: PGBackRestRepoHostReady + status: "False" + type: ReadyForBackup + - reason: TLSSecretsMissing + status: "False" + type: TLSSecretsReady + - reason: Paused + status: "False" + type: Progressing + - reason: RepoHostNotReady + status: "False" + type: PGBackRestRepoHostReady + - reason: StanzaNotCreated + status: "False" + type: PGBackRestReplicaRepoReady + - reason: RepoBackupNotComplete + status: "False" + type: PGBackRestReplicaCreate diff --git a/e2e-tests/tests/cert-management-policy/05-restore-secrets.yaml b/e2e-tests/tests/cert-management-policy/05-restore-secrets.yaml index 1d520adf0..8f0a7a5bb 100644 --- a/e2e-tests/tests/cert-management-policy/05-restore-secrets.yaml +++ b/e2e-tests/tests/cert-management-policy/05-restore-secrets.yaml @@ -7,4 +7,7 @@ commands: source ../../functions - kubectl -n "$NAMESPACE" apply -f "$TEMP_DIR/tls-secrets.yaml" + yq eval \ + 'del(.items[] | select(.metadata.name | test("-certs$")))' \ + "$TEMP_DIR/tls-secrets.yaml" \ + | kubectl -n "$NAMESPACE" apply -f - diff --git a/e2e-tests/tests/cert-management-policy/06-restore-instance-secret.yaml b/e2e-tests/tests/cert-management-policy/06-restore-instance-secret.yaml index 852519963..5b5aaeb80 100644 --- a/e2e-tests/tests/cert-management-policy/06-restore-instance-secret.yaml +++ b/e2e-tests/tests/cert-management-policy/06-restore-instance-secret.yaml @@ -13,14 +13,11 @@ commands: -l postgres-operator.crunchydata.com/cluster="$cluster",postgres-operator.crunchydata.com/data=postgres \ -o jsonpath='{.items[0].metadata.name}') - old_instance_secret=$(yq eval -r \ - '[.items[].metadata.name | select(test("-certs$"))][0] // ""' \ - "$TEMP_DIR/tls-secrets.yaml") new_instance_secret="${instance}-certs" - if [[ -n "$old_instance_secret" && "$old_instance_secret" != "$new_instance_secret" ]]; then - old_instance_secret="$old_instance_secret" new_instance_secret="$new_instance_secret" yq eval \ - '.items[] | select(.metadata.name == strenv(old_instance_secret)) | .metadata.name = strenv(new_instance_secret)' \ - "$TEMP_DIR/tls-secrets.yaml" \ - | kubectl -n "$NAMESPACE" apply -f - - fi + instance="$instance" new_instance_secret="$new_instance_secret" yq eval \ + '.items[] | select(.metadata.name | test("-certs$")) | + .metadata.name = strenv(new_instance_secret) | + .metadata.labels."postgres-operator.crunchydata.com/instance" = strenv(instance)' \ + "$TEMP_DIR/tls-secrets.yaml" \ + | kubectl -n "$NAMESPACE" apply -f - diff --git a/internal/controller/postgrescluster/instance.go b/internal/controller/postgrescluster/instance.go index 0edb67a01..4653aabe7 100644 --- a/internal/controller/postgrescluster/instance.go +++ b/internal/controller/postgrescluster/instance.go @@ -1491,7 +1491,12 @@ func (r *Reconciler) reconcileInstanceCertificates( if cluster.Spec.CustomTLSSecret == nil { if cluster.Spec.TLS.GetCertManagementPolicy() == v1beta1.CertManagementUserProvidedOnly { existing := &corev1.Secret{ObjectMeta: naming.InstanceCertificates(instance)} - if err := r.Client.Get(ctx, client.ObjectKeyFromObject(existing), existing); err != nil { + // Allow the StatefulSet to be created so its generated name is visible + // to the user. The next reconciliation checks its certificate Secret + // and pauses until the user provides it. + if err := client.IgnoreNotFound( + r.Client.Get(ctx, client.ObjectKeyFromObject(existing), existing), + ); err != nil { return nil, errors.Wrapf(err, "get user-provided instance TLS secret %s", existing.Name) } return existing, nil diff --git a/internal/controller/postgrescluster/pki.go b/internal/controller/postgrescluster/pki.go index ea1ab4ee3..82a504c32 100644 --- a/internal/controller/postgrescluster/pki.go +++ b/internal/controller/postgrescluster/pki.go @@ -324,7 +324,7 @@ func (r *Reconciler) reconcileClusterCertificate( return cluster.Spec.CustomTLSSecret, nil } - if cluster.Spec.TLS.CertManagementPolicy == v1beta1.CertManagementUserProvidedOnly { + if cluster.Spec.TLS.GetCertManagementPolicy() == v1beta1.CertManagementUserProvidedOnly { return r.reconcileUserProvidedClusterCertificate(ctx, cluster) } certManagerManaged, err := r.isRootCACertManagerManaged(ctx, cluster) From 3e8b00727d0f9d2c4eaa539d6e155839bf47efa0 Mon Sep 17 00:00:00 2001 From: Andrii Dema Date: Mon, 20 Jul 2026 21:33:25 +0300 Subject: [PATCH 05/10] watch secrets without label --- .../controller/postgrescluster/controller.go | 5 +-- .../controller/postgrescluster/watches.go | 24 +++++++++++--- .../postgrescluster/watches_test.go | 32 +++++++++++++++++++ 3 files changed, 53 insertions(+), 8 deletions(-) diff --git a/internal/controller/postgrescluster/controller.go b/internal/controller/postgrescluster/controller.go index a43e87057..0e1513d3a 100644 --- a/internal/controller/postgrescluster/controller.go +++ b/internal/controller/postgrescluster/controller.go @@ -620,10 +620,7 @@ func (r *Reconciler) SetupWithManager(mgr manager.Manager) error { Owns(&rbacv1.RoleBinding{}). Owns(&batchv1.CronJob{}). Owns(&policyv1.PodDisruptionBudget{}). - Watches(&corev1.Secret{}, r.watchClusterSecrets(), builder.WithPredicates(predicate.NewPredicateFuncs(func(obj client.Object) bool { - _, hasCluster := obj.GetLabels()[naming.LabelCluster] - return hasCluster - }))). + Watches(&corev1.Secret{}, r.watchClusterSecrets()). Watches(&corev1.Pod{}, r.watchPods()). Watches(&appsv1.StatefulSet{}, r.controllerRefHandlerFuncs()) // watch all StatefulSets diff --git a/internal/controller/postgrescluster/watches.go b/internal/controller/postgrescluster/watches.go index 7c458598f..fba300ec9 100644 --- a/internal/controller/postgrescluster/watches.go +++ b/internal/controller/postgrescluster/watches.go @@ -15,11 +15,15 @@ import ( "github.com/percona/percona-postgresql-operator/v2/internal/naming" "github.com/percona/percona-postgresql-operator/v2/internal/patroni" + "github.com/percona/percona-postgresql-operator/v2/pkg/apis/upstream.pgv2.percona.com/v1beta1" ) -// watchClusterSecrets returns a handler.EventHandler for Secrets that are -// labeled with a PostgresCluster name but intentionally have no owner reference. -func (*Reconciler) watchClusterSecrets() handler.EventHandler { +// watchClusterSecrets returns a handler.EventHandler for Secrets that have no owner reference. +// +// Secrets with a PostgresCluster label enqueue that cluster. +// +// Secrets without one enqueue every PostgresCluster in their namespace. +func (r *Reconciler) watchClusterSecrets() handler.EventHandler { return handler.EnqueueRequestsFromMapFunc(func(ctx context.Context, obj client.Object) []reconcile.Request { cluster := obj.GetLabels()[naming.LabelCluster] if len(cluster) > 0 { @@ -30,7 +34,19 @@ func (*Reconciler) watchClusterSecrets() handler.EventHandler { }}, } } - return nil + + clusters := &v1beta1.PostgresClusterList{} + if err := r.Client.List(ctx, clusters, client.InNamespace(obj.GetNamespace())); err != nil { + return nil + } + + requests := make([]reconcile.Request, 0, len(clusters.Items)) + for i := range clusters.Items { + requests = append(requests, reconcile.Request{ + NamespacedName: client.ObjectKeyFromObject(&clusters.Items[i]), + }) + } + return requests }) } diff --git a/internal/controller/postgrescluster/watches_test.go b/internal/controller/postgrescluster/watches_test.go index 6b9d106bb..95b1629d1 100644 --- a/internal/controller/postgrescluster/watches_test.go +++ b/internal/controller/postgrescluster/watches_test.go @@ -10,13 +10,45 @@ import ( "gotest.tools/v3/assert" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" "k8s.io/client-go/util/workqueue" "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" "sigs.k8s.io/controller-runtime/pkg/controller/controllertest" "sigs.k8s.io/controller-runtime/pkg/event" "sigs.k8s.io/controller-runtime/pkg/reconcile" + + "github.com/percona/percona-postgresql-operator/v2/pkg/apis/upstream.pgv2.percona.com/v1beta1" ) +func TestWatchClusterSecretsWithoutLabel(t *testing.T) { + ctx := t.Context() + scheme := runtime.NewScheme() + assert.NilError(t, v1beta1.AddToScheme(scheme)) + + reconciler := &Reconciler{Client: fake.NewClientBuilder().WithScheme(scheme).WithObjects( + &v1beta1.PostgresCluster{ObjectMeta: metav1.ObjectMeta{Name: "hippo", Namespace: "test-ns"}}, + &v1beta1.PostgresCluster{ObjectMeta: metav1.ObjectMeta{Name: "rhino", Namespace: "test-ns"}}, + &v1beta1.PostgresCluster{ObjectMeta: metav1.ObjectMeta{Name: "elephant", Namespace: "other-ns"}}, + ).Build()} + queue := &controllertest.Queue{TypedInterface: workqueue.NewTyped[reconcile.Request]()} + + reconciler.watchClusterSecrets().Generic(ctx, event.GenericEvent{ + Object: &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Namespace: "test-ns"}}, + }, queue) + + requests := make(map[reconcile.Request]bool, queue.Len()) + for queue.Len() > 0 { + item, _ := queue.Get() + requests[item] = true + queue.Done(item) + } + assert.DeepEqual(t, requests, map[reconcile.Request]bool{ + {NamespacedName: client.ObjectKey{Namespace: "test-ns", Name: "hippo"}}: true, + {NamespacedName: client.ObjectKey{Namespace: "test-ns", Name: "rhino"}}: true, + }) +} + func TestWatchCertManagerSecrets(t *testing.T) { ctx := t.Context() reconciler := &Reconciler{} From ba152c8a018f8f5d66ecf51aba867a64e2572a3d Mon Sep 17 00:00:00 2001 From: Andrii Dema Date: Mon, 20 Jul 2026 21:47:15 +0300 Subject: [PATCH 06/10] update comment --- internal/controller/postgrescluster/instance.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/internal/controller/postgrescluster/instance.go b/internal/controller/postgrescluster/instance.go index 4653aabe7..227e1f79d 100644 --- a/internal/controller/postgrescluster/instance.go +++ b/internal/controller/postgrescluster/instance.go @@ -1482,7 +1482,9 @@ func (r *Reconciler) reconcileInstanceConfigMap( // managed, it creates a Certificate CR and augments the resulting secret with // Patroni and pgBackRest keys. Otherwise, it uses internal PKI — first // reconciling any stale Certificate CR left by K8SPG-1017 to update its -// ownerRef (K8SPG-1007 recovery). +// ownerRef (K8SPG-1007 recovery). When userProvidedOnly is specified, it +// returns the existing user-provided Secret, or a placeholder containing its +// name and namespace if it does not exist. func (r *Reconciler) reconcileInstanceCertificates( ctx context.Context, cluster *v1beta1.PostgresCluster, spec *v1beta1.PostgresInstanceSetSpec, instance *appsv1.StatefulSet, From 1c0bbf6586f0102000b52df29629421cd4af1695 Mon Sep 17 00:00:00 2001 From: Andrii Dema Date: Tue, 21 Jul 2026 13:53:07 +0300 Subject: [PATCH 07/10] Revert "watch secrets without label" This reverts commit 3e8b00727d0f9d2c4eaa539d6e155839bf47efa0. --- .../controller/postgrescluster/controller.go | 5 ++- .../controller/postgrescluster/watches.go | 24 +++----------- .../postgrescluster/watches_test.go | 32 ------------------- 3 files changed, 8 insertions(+), 53 deletions(-) diff --git a/internal/controller/postgrescluster/controller.go b/internal/controller/postgrescluster/controller.go index a0e031004..bdab50648 100644 --- a/internal/controller/postgrescluster/controller.go +++ b/internal/controller/postgrescluster/controller.go @@ -629,7 +629,10 @@ func (r *Reconciler) SetupWithManager(mgr manager.Manager) error { Owns(&rbacv1.RoleBinding{}). Owns(&batchv1.CronJob{}). Owns(&policyv1.PodDisruptionBudget{}). - Watches(&corev1.Secret{}, r.watchClusterSecrets()). + Watches(&corev1.Secret{}, r.watchClusterSecrets(), builder.WithPredicates(predicate.NewPredicateFuncs(func(obj client.Object) bool { + _, hasCluster := obj.GetLabels()[naming.LabelCluster] + return hasCluster + }))). Watches(&corev1.Pod{}, r.watchPods()). Watches(&corev1.Secret{}, r.watchPGBouncerUserSecrets()). Watches(&appsv1.StatefulSet{}, diff --git a/internal/controller/postgrescluster/watches.go b/internal/controller/postgrescluster/watches.go index 3e8e16f68..40e3b6ea7 100644 --- a/internal/controller/postgrescluster/watches.go +++ b/internal/controller/postgrescluster/watches.go @@ -16,15 +16,11 @@ import ( "github.com/percona/percona-postgresql-operator/v2/internal/naming" "github.com/percona/percona-postgresql-operator/v2/internal/patroni" - "github.com/percona/percona-postgresql-operator/v2/pkg/apis/upstream.pgv2.percona.com/v1beta1" ) -// watchClusterSecrets returns a handler.EventHandler for Secrets that have no owner reference. -// -// Secrets with a PostgresCluster label enqueue that cluster. -// -// Secrets without one enqueue every PostgresCluster in their namespace. -func (r *Reconciler) watchClusterSecrets() handler.EventHandler { +// watchClusterSecrets returns a handler.EventHandler for Secrets that are +// labeled with a PostgresCluster name but intentionally have no owner reference. +func (*Reconciler) watchClusterSecrets() handler.EventHandler { return handler.EnqueueRequestsFromMapFunc(func(ctx context.Context, obj client.Object) []reconcile.Request { cluster := obj.GetLabels()[naming.LabelCluster] if len(cluster) > 0 { @@ -35,19 +31,7 @@ func (r *Reconciler) watchClusterSecrets() handler.EventHandler { }}, } } - - clusters := &v1beta1.PostgresClusterList{} - if err := r.Client.List(ctx, clusters, client.InNamespace(obj.GetNamespace())); err != nil { - return nil - } - - requests := make([]reconcile.Request, 0, len(clusters.Items)) - for i := range clusters.Items { - requests = append(requests, reconcile.Request{ - NamespacedName: client.ObjectKeyFromObject(&clusters.Items[i]), - }) - } - return requests + return nil }) } diff --git a/internal/controller/postgrescluster/watches_test.go b/internal/controller/postgrescluster/watches_test.go index 95b1629d1..6b9d106bb 100644 --- a/internal/controller/postgrescluster/watches_test.go +++ b/internal/controller/postgrescluster/watches_test.go @@ -10,45 +10,13 @@ import ( "gotest.tools/v3/assert" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime" "k8s.io/client-go/util/workqueue" "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/client/fake" "sigs.k8s.io/controller-runtime/pkg/controller/controllertest" "sigs.k8s.io/controller-runtime/pkg/event" "sigs.k8s.io/controller-runtime/pkg/reconcile" - - "github.com/percona/percona-postgresql-operator/v2/pkg/apis/upstream.pgv2.percona.com/v1beta1" ) -func TestWatchClusterSecretsWithoutLabel(t *testing.T) { - ctx := t.Context() - scheme := runtime.NewScheme() - assert.NilError(t, v1beta1.AddToScheme(scheme)) - - reconciler := &Reconciler{Client: fake.NewClientBuilder().WithScheme(scheme).WithObjects( - &v1beta1.PostgresCluster{ObjectMeta: metav1.ObjectMeta{Name: "hippo", Namespace: "test-ns"}}, - &v1beta1.PostgresCluster{ObjectMeta: metav1.ObjectMeta{Name: "rhino", Namespace: "test-ns"}}, - &v1beta1.PostgresCluster{ObjectMeta: metav1.ObjectMeta{Name: "elephant", Namespace: "other-ns"}}, - ).Build()} - queue := &controllertest.Queue{TypedInterface: workqueue.NewTyped[reconcile.Request]()} - - reconciler.watchClusterSecrets().Generic(ctx, event.GenericEvent{ - Object: &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Namespace: "test-ns"}}, - }, queue) - - requests := make(map[reconcile.Request]bool, queue.Len()) - for queue.Len() > 0 { - item, _ := queue.Get() - requests[item] = true - queue.Done(item) - } - assert.DeepEqual(t, requests, map[reconcile.Request]bool{ - {NamespacedName: client.ObjectKey{Namespace: "test-ns", Name: "hippo"}}: true, - {NamespacedName: client.ObjectKey{Namespace: "test-ns", Name: "rhino"}}: true, - }) -} - func TestWatchCertManagerSecrets(t *testing.T) { ctx := t.Context() reconciler := &Reconciler{} From 312ca390fc0cba2e9ab63d3996eafd4bf5f5dad2 Mon Sep 17 00:00:00 2001 From: Andrii Dema Date: Tue, 21 Jul 2026 14:00:59 +0300 Subject: [PATCH 08/10] fix merge --- internal/controller/postgrescluster/watches.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/controller/postgrescluster/watches.go b/internal/controller/postgrescluster/watches.go index 40e3b6ea7..f6f3a5953 100644 --- a/internal/controller/postgrescluster/watches.go +++ b/internal/controller/postgrescluster/watches.go @@ -16,6 +16,7 @@ import ( "github.com/percona/percona-postgresql-operator/v2/internal/naming" "github.com/percona/percona-postgresql-operator/v2/internal/patroni" + "github.com/percona/percona-postgresql-operator/v2/pkg/apis/upstream.pgv2.percona.com/v1beta1" ) // watchClusterSecrets returns a handler.EventHandler for Secrets that are From 8eb3a7ebf11a8ef5b487d6d2027ffdccda00bcc3 Mon Sep 17 00:00:00 2001 From: Andrii Dema Date: Fri, 24 Jul 2026 17:09:28 +0300 Subject: [PATCH 09/10] address comments --- .../controller/postgrescluster/controller.go | 24 ++-- internal/controller/postgrescluster/pki.go | 10 +- .../controller/postgrescluster/pki_test.go | 109 ++++++++++++++++++ 3 files changed, 127 insertions(+), 16 deletions(-) diff --git a/internal/controller/postgrescluster/controller.go b/internal/controller/postgrescluster/controller.go index bdab50648..834812bf5 100644 --- a/internal/controller/postgrescluster/controller.go +++ b/internal/controller/postgrescluster/controller.go @@ -258,23 +258,21 @@ func (r *Reconciler) Reconcile( // K8SPG-1045 if err == nil { - if err = r.reconcileTLSCertManagementPolicy(ctx, cluster); err != nil { + if err = r.reconcileTLSCondition(ctx, cluster); err != nil { return runtime.ErrorWithBackoff(err) } - if cond := meta.FindStatusCondition(cluster.Status.Conditions, v1beta1.ConditionTypeTLSSecretsReady); cond != nil { - if cond.Status == metav1.ConditionFalse { - meta.SetStatusCondition(&cluster.Status.Conditions, metav1.Condition{ - Type: v1beta1.PostgresClusterProgressing, - Status: metav1.ConditionFalse, - Reason: "Paused", - Message: "Reconciliation is paused. Check `TLSSecretsReady` condition", - ObservedGeneration: cluster.GetGeneration(), - }) - return runtime.ErrorWithBackoff(patchClusterStatus()) - } - meta.RemoveStatusCondition(&cluster.Status.Conditions, v1beta1.PostgresClusterProgressing) + if meta.IsStatusConditionPresentAndEqual(cluster.Status.Conditions, v1beta1.ConditionTypeTLSSecretsReady, metav1.ConditionFalse) { + meta.SetStatusCondition(&cluster.Status.Conditions, metav1.Condition{ + Type: v1beta1.PostgresClusterProgressing, + Status: metav1.ConditionFalse, + Reason: "Paused", + Message: "Reconciliation is paused. Check `TLSSecretsReady` condition", + ObservedGeneration: cluster.GetGeneration(), + }) + return runtime.ErrorWithBackoff(patchClusterStatus()) } + meta.RemoveStatusCondition(&cluster.Status.Conditions, v1beta1.PostgresClusterProgressing) } pgHBAs := postgres.NewHBAs() diff --git a/internal/controller/postgrescluster/pki.go b/internal/controller/postgrescluster/pki.go index 82a504c32..2189a7a7e 100644 --- a/internal/controller/postgrescluster/pki.go +++ b/internal/controller/postgrescluster/pki.go @@ -32,7 +32,7 @@ const ( ) // K8SPG-1045 -func (r *Reconciler) reconcileTLSCertManagementPolicy(ctx context.Context, cluster *v1beta1.PostgresCluster) error { +func (r *Reconciler) reconcileTLSCondition(ctx context.Context, cluster *v1beta1.PostgresCluster) error { cond := metav1.Condition{ Type: v1beta1.ConditionTypeTLSSecretsReady, Status: metav1.ConditionTrue, @@ -186,8 +186,12 @@ func (r *Reconciler) reconcileRootCertificate( } root := &pki.RootCertificateAuthority{} - _ = root.Certificate.UnmarshalText(existing.Data[certificateKey]) - _ = root.PrivateKey.UnmarshalText(existing.Data[privateKey]) + if err := root.Certificate.UnmarshalText(existing.Data[certificateKey]); err != nil { + return nil, errors.Wrapf(err, "parse certificate in user-provided root CA secret %q", existing.Name) + } + if err := root.PrivateKey.UnmarshalText(existing.Data[privateKey]); err != nil { + return nil, errors.Wrapf(err, "parse private key in user-provided root CA secret %q", existing.Name) + } if !pki.RootIsValid(root) { return nil, errors.Errorf("user-provided root CA secret %q is invalid", existing.Name) } diff --git a/internal/controller/postgrescluster/pki_test.go b/internal/controller/postgrescluster/pki_test.go index 2c61ad1d5..7d3a824fd 100644 --- a/internal/controller/postgrescluster/pki_test.go +++ b/internal/controller/postgrescluster/pki_test.go @@ -16,11 +16,13 @@ import ( "gotest.tools/v3/assert" appsv1 "k8s.io/api/apps/v1" corev1 "k8s.io/api/core/v1" + "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/rest" "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" "github.com/percona/percona-postgresql-operator/v2/internal/naming" "github.com/percona/percona-postgresql-operator/v2/internal/pki" @@ -29,6 +31,113 @@ import ( "github.com/percona/percona-postgresql-operator/v2/pkg/apis/upstream.pgv2.percona.com/v1beta1" ) +func TestReconcileTLSCondition(t *testing.T) { + condition := func(t *testing.T, cluster *v1beta1.PostgresCluster, status metav1.ConditionStatus) *metav1.Condition { + t.Helper() + assert.Assert(t, meta.IsStatusConditionPresentAndEqual( + cluster.Status.Conditions, v1beta1.ConditionTypeTLSSecretsReady, status, + )) + condition := meta.FindStatusCondition(cluster.Status.Conditions, v1beta1.ConditionTypeTLSSecretsReady) + assert.Assert(t, condition != nil) + return condition + } + + t.Run("automatic certificate management", func(t *testing.T) { + cluster := testCluster() + cluster.Generation = 7 + cluster.Spec.TLS = nil + cluster.Status.Conditions = []metav1.Condition{{ + Type: v1beta1.ConditionTypeTLSSecretsReady, + Status: metav1.ConditionFalse, + }} + + r := &Reconciler{} + assert.NilError(t, r.reconcileTLSCondition(t.Context(), cluster)) + + condition := condition(t, cluster, metav1.ConditionTrue) + assert.Equal(t, condition.Reason, "TLSSecretsFound") + assert.Equal(t, condition.Message, "certManagementPolicy is auto") + assert.Equal(t, condition.ObservedGeneration, int64(7)) + }) + + t.Run("missing user-provided secrets", func(t *testing.T) { + cluster := testCluster() + cluster.Namespace = "postgres-operator" + cluster.Generation = 11 + cluster.Spec.TLS = &v1beta1.TLSSpec{ + CertManagementPolicy: v1beta1.CertManagementUserProvidedOnly, + } + cluster.Spec.CustomRootCATLSSecret = &corev1.SecretProjection{ + LocalObjectReference: corev1.LocalObjectReference{Name: "custom-root-ca"}, + } + cluster.Spec.CustomReplicationClientTLSSecret = &corev1.SecretProjection{ + LocalObjectReference: corev1.LocalObjectReference{Name: "custom-replication"}, + } + + instance := &appsv1.StatefulSet{ObjectMeta: metav1.ObjectMeta{ + Name: "hippo-instance1-abcd", + Namespace: cluster.Namespace, + Labels: map[string]string{ + naming.LabelCluster: cluster.Name, + naming.LabelData: naming.DataPostgres, + }, + }} + instanceWithoutMatchingLabels := &appsv1.StatefulSet{ObjectMeta: metav1.ObjectMeta{ + Name: "unrelated-instance", + Namespace: cluster.Namespace, + }} + + r := &Reconciler{Client: fake.NewClientBuilder().WithObjects(instance, instanceWithoutMatchingLabels).Build()} + assert.NilError(t, r.reconcileTLSCondition(t.Context(), cluster)) + + condition := condition(t, cluster, metav1.ConditionFalse) + assert.Equal(t, condition.Reason, "TLSSecretsMissing") + assert.Equal(t, condition.ObservedGeneration, int64(11)) + assert.Equal(t, condition.Message, "Missing user-provided TLS secrets: "+strings.Join([]string{ + "custom-root-ca", + naming.PostgresTLSSecret(cluster).Name, + "custom-replication", + naming.PGBackRestSecret(cluster).Name, + naming.ClusterPGBouncer(cluster).Name, + naming.InstanceCertificates(instance).Name, + }, ", ")+". certManagementPolicy is userProvidedOnly") + }) + + t.Run("user-provided secrets found", func(t *testing.T) { + cluster := testCluster() + cluster.Namespace = "postgres-operator" + cluster.Generation = 13 + cluster.Spec.TLS = &v1beta1.TLSSpec{ + CertManagementPolicy: v1beta1.CertManagementUserProvidedOnly, + } + cluster.Spec.CustomRootCATLSSecret = &corev1.SecretProjection{ + LocalObjectReference: corev1.LocalObjectReference{Name: "custom-root-ca"}, + } + cluster.Spec.CustomTLSSecret = &corev1.SecretProjection{ + LocalObjectReference: corev1.LocalObjectReference{Name: "custom-postgres-tls"}, + } + cluster.Spec.CustomReplicationClientTLSSecret = &corev1.SecretProjection{ + LocalObjectReference: corev1.LocalObjectReference{Name: "custom-replication"}, + } + + objects := []client.Object{ + &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "custom-root-ca", Namespace: cluster.Namespace}}, + &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "custom-postgres-tls", Namespace: cluster.Namespace}}, + &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: "custom-replication", Namespace: cluster.Namespace}}, + &corev1.Secret{ObjectMeta: naming.PGBackRestSecret(cluster)}, + &corev1.Secret{ObjectMeta: naming.ClusterPGBouncer(cluster)}, + } + + r := &Reconciler{Client: fake.NewClientBuilder().WithObjects(objects...).Build()} + assert.NilError(t, r.reconcileTLSCondition(t.Context(), cluster)) + + condition := condition(t, cluster, metav1.ConditionTrue) + assert.Equal(t, condition.Reason, "TLSSecretsFound") + assert.Equal(t, condition.Message, "") + assert.Equal(t, condition.ObservedGeneration, int64(13)) + }) +} + // TestReconcileCerts tests the proper reconciliation of the root ca certificate // secret, leaf certificate secrets and the updates that occur when updates are // made to the cluster certificates generally. For the removal of ownership From 2c6b22ffb410439a1fde09dd67c9abd638e89239 Mon Sep 17 00:00:00 2001 From: Andrii Dema Date: Fri, 24 Jul 2026 17:13:16 +0300 Subject: [PATCH 10/10] fix `upgrade-minor` test --- e2e-tests/tests/upgrade-minor/05-assert.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/e2e-tests/tests/upgrade-minor/05-assert.yaml b/e2e-tests/tests/upgrade-minor/05-assert.yaml index 68dea800a..d5645a1c4 100644 --- a/e2e-tests/tests/upgrade-minor/05-assert.yaml +++ b/e2e-tests/tests/upgrade-minor/05-assert.yaml @@ -139,6 +139,9 @@ status: - type: APIGroupMigration reason: APIGroupMigrationNotNeeded status: "True" + - type: TLSSecretsReady + reason: TLSSecretsFound + status: "True" pgbouncer: ready: 3 size: 3