From 9c47bf9946944cbbe51c1baaa6d4f48d442ebe95 Mon Sep 17 00:00:00 2001 From: yoav-katz Date: Tue, 1 Sep 2026 22:50:23 +0300 Subject: [PATCH 1/4] feat(additionalSANs)g --- .../pgv2.percona.com_perconapgclusters.yaml | 4 ++ .../pgv2.percona.com_perconapgclusters.yaml | 4 ++ ...eam.pgv2.percona.com_postgresclusters.yaml | 4 ++ deploy/bundle.yaml | 8 +++ deploy/cr.yaml | 3 + deploy/crd.yaml | 8 +++ deploy/cw-bundle.yaml | 8 +++ .../12-recreate-cluster.yaml | 1 + .../14-verify-operator-provided-only.yaml | 23 ++++++++ .../tests/cert-manager-tls/29-add-sans.yaml | 14 +++++ .../cert-manager-tls/30-verify-sans.yaml | 55 +++++++++++++++++++ .../controller/postgrescluster/pgbouncer.go | 1 + internal/controller/postgrescluster/pki.go | 5 ++ internal/pgbouncer/reconcile.go | 1 + percona/certmanager/certmanager.go | 11 ++++ percona/certmanager/certmanager_test.go | 37 +++++++++++++ .../v1beta1/postgrescluster_types.go | 11 ++++ .../v1beta1/zz_generated.deepcopy.go | 5 ++ 18 files changed, 203 insertions(+) create mode 100644 e2e-tests/tests/cert-manager-tls/29-add-sans.yaml create mode 100644 e2e-tests/tests/cert-manager-tls/30-verify-sans.yaml diff --git a/build/crd/percona/generated/pgv2.percona.com_perconapgclusters.yaml b/build/crd/percona/generated/pgv2.percona.com_perconapgclusters.yaml index 26c410f159..b38d56ed04 100644 --- a/build/crd/percona/generated/pgv2.percona.com_perconapgclusters.yaml +++ b/build/crd/percona/generated/pgv2.percona.com_perconapgclusters.yaml @@ -16678,6 +16678,10 @@ spec: type: object tls: properties: + SANs: + items: + type: string + type: array caValidityDuration: type: string certManagementPolicy: diff --git a/config/crd/bases/pgv2.percona.com_perconapgclusters.yaml b/config/crd/bases/pgv2.percona.com_perconapgclusters.yaml index 009197d9f7..8d3e2d6c9b 100644 --- a/config/crd/bases/pgv2.percona.com_perconapgclusters.yaml +++ b/config/crd/bases/pgv2.percona.com_perconapgclusters.yaml @@ -17092,6 +17092,10 @@ spec: type: object tls: properties: + SANs: + items: + type: string + type: array caValidityDuration: type: string certManagementPolicy: diff --git a/config/crd/bases/upstream.pgv2.percona.com_postgresclusters.yaml b/config/crd/bases/upstream.pgv2.percona.com_postgresclusters.yaml index 1bac3e9433..6c712cb2eb 100644 --- a/config/crd/bases/upstream.pgv2.percona.com_postgresclusters.yaml +++ b/config/crd/bases/upstream.pgv2.percona.com_postgresclusters.yaml @@ -14352,6 +14352,10 @@ spec: type: array tls: properties: + SANs: + items: + type: string + type: array caValidityDuration: type: string certManagementPolicy: diff --git a/deploy/bundle.yaml b/deploy/bundle.yaml index 7325889dcd..58e550e679 100644 --- a/deploy/bundle.yaml +++ b/deploy/bundle.yaml @@ -17293,6 +17293,10 @@ spec: type: object tls: properties: + SANs: + items: + type: string + type: array caValidityDuration: type: string certManagementPolicy: @@ -36005,6 +36009,10 @@ spec: type: array tls: properties: + SANs: + items: + type: string + type: array caValidityDuration: type: string certManagementPolicy: diff --git a/deploy/cr.yaml b/deploy/cr.yaml index e305a1fcf0..87c34e378c 100644 --- a/deploy/cr.yaml +++ b/deploy/cr.yaml @@ -63,6 +63,9 @@ spec: # certValidityDuration: 2160h # caValidityDuration: 26280h # pgBackRestCertValidityDuration: 2160h +# SANs: +# - pg.example.com +# - "*.pg.internal.example.com" # issuerConf: # name: some-selfsigned-issuer # kind: ClusterIssuer diff --git a/deploy/crd.yaml b/deploy/crd.yaml index 568ef9a50e..6d56420a5d 100644 --- a/deploy/crd.yaml +++ b/deploy/crd.yaml @@ -17293,6 +17293,10 @@ spec: type: object tls: properties: + SANs: + items: + type: string + type: array caValidityDuration: type: string certManagementPolicy: @@ -36005,6 +36009,10 @@ spec: type: array tls: properties: + SANs: + items: + type: string + type: array caValidityDuration: type: string certManagementPolicy: diff --git a/deploy/cw-bundle.yaml b/deploy/cw-bundle.yaml index d94cb9764f..3a8901d235 100644 --- a/deploy/cw-bundle.yaml +++ b/deploy/cw-bundle.yaml @@ -17293,6 +17293,10 @@ spec: type: object tls: properties: + SANs: + items: + type: string + type: array caValidityDuration: type: string certManagementPolicy: @@ -36005,6 +36009,10 @@ spec: type: array tls: properties: + SANs: + items: + type: string + type: array caValidityDuration: type: string certManagementPolicy: diff --git a/e2e-tests/tests/cert-management-policy/12-recreate-cluster.yaml b/e2e-tests/tests/cert-management-policy/12-recreate-cluster.yaml index 1d80d77753..f8ba829950 100644 --- a/e2e-tests/tests/cert-management-policy/12-recreate-cluster.yaml +++ b/e2e-tests/tests/cert-management-policy/12-recreate-cluster.yaml @@ -19,6 +19,7 @@ commands: get_cr "$cluster" \ | yq '.spec.tls.certManagementPolicy = "operatorProvidedOnly"' \ | yq '.spec.tls.issuerConf = {"name":"cert-management-policy-ignored-issuer","kind":"ClusterIssuer"}' \ + | yq '.spec.tls.SANs = ["pg.example.com","*.pg.internal.example.com"]' \ | 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/14-verify-operator-provided-only.yaml b/e2e-tests/tests/cert-management-policy/14-verify-operator-provided-only.yaml index ec6157bf41..90317a1bad 100644 --- a/e2e-tests/tests/cert-management-policy/14-verify-operator-provided-only.yaml +++ b/e2e-tests/tests/cert-management-policy/14-verify-operator-provided-only.yaml @@ -42,6 +42,29 @@ commands: fi done + # spec.tls.SANs must reach the internal-PKI leaves too, not just cert-manager. + verify_san() { + local secret="$1" + local key="$2" + local escaped_key="${key//./\\.}" + shift 2 + local sans + sans=$(kubectl -n "$NAMESPACE" get secret "$secret" \ + -o jsonpath="{.data.${escaped_key}}" | base64 -d | openssl x509 -noout -ext subjectAltName) + for san in "$@"; do + if [[ "$sans" != *"$san"* ]]; then + echo "Certificate $secret/$key is missing SAN $san" >&2 + echo "Got: $sans" >&2 + return 1 + fi + done + } + + verify_san "${cluster}-cluster-cert" tls.crt \ + pg.example.com '*.pg.internal.example.com' "${cluster}-primary" + verify_san "${cluster}-pgbouncer" pgbouncer-frontend.crt \ + pg.example.com '*.pg.internal.example.com' "${cluster}-pgbouncer" + instance_sts=$(kubectl -n "$NAMESPACE" get sts \ -l postgres-operator.crunchydata.com/cluster="$cluster",postgres-operator.crunchydata.com/instance-set=instance1 \ -o jsonpath='{.items[*].metadata.name}') diff --git a/e2e-tests/tests/cert-manager-tls/29-add-sans.yaml b/e2e-tests/tests/cert-manager-tls/29-add-sans.yaml new file mode 100644 index 0000000000..db30239f18 --- /dev/null +++ b/e2e-tests/tests/cert-manager-tls/29-add-sans.yaml @@ -0,0 +1,14 @@ +apiVersion: kuttl.dev/v1beta1 +kind: TestStep +commands: + - timeout: 120 + script: |- + set -o errexit + set -o xtrace + + source ../../functions + + kubectl -n "$NAMESPACE" patch pg cert-manager-tls --type=merge \ + -p '{"spec":{"tls":{"SANs":["pg.example.com","*.pg.internal.example.com"]}}}' + + wait_cluster_consistency cert-manager-tls diff --git a/e2e-tests/tests/cert-manager-tls/30-verify-sans.yaml b/e2e-tests/tests/cert-manager-tls/30-verify-sans.yaml new file mode 100644 index 0000000000..da7d02e5d3 --- /dev/null +++ b/e2e-tests/tests/cert-manager-tls/30-verify-sans.yaml @@ -0,0 +1,55 @@ +apiVersion: kuttl.dev/v1beta1 +kind: TestStep +commands: + - timeout: 600 + script: |- + set -o errexit + set -o xtrace + + source ../../functions + + # The Certificate CR already existed before spec.tls.SANs was set, so this + # also covers the DNSNames drift path in ApplyClusterCertificate. + wait_for_san_in_cert() { + local cert_name="$1" + local san="$2" + + for i in {1..60}; do + if kubectl -n "$NAMESPACE" get certificate "$cert_name" -o jsonpath='{.spec.dnsNames}' | grep -q -- "$san"; then + return 0 + fi + sleep 5 + done + echo "Certificate $cert_name never got SAN $san" + kubectl -n "$NAMESPACE" get certificate "$cert_name" -o jsonpath='{.spec.dnsNames}' + return 1 + } + + wait_for_san_in_cert cert-manager-tls-cluster-cert pg.example.com + wait_for_san_in_cert cert-manager-tls-cluster-cert '*.pg.internal.example.com' + wait_for_san_in_cert cert-manager-tls-pgbouncer-cert pg.example.com + + wait_for_san_in_secret() { + local secret_name="$1" + local san="$2" + + for i in {1..60}; do + local sans + sans=$(kubectl -n "$NAMESPACE" get secret "$secret_name" -o jsonpath='{.data.tls\.crt}' \ + | base64 -d | openssl x509 -noout -ext subjectAltName) + if [[ "$sans" == *"$san"* ]]; then + return 0 + fi + sleep 5 + done + echo "Secret $secret_name certificate never got SAN $san" + echo "Got: $sans" + return 1 + } + + wait_for_san_in_secret cert-manager-tls-cluster-cert pg.example.com + wait_for_san_in_secret cert-manager-tls-cluster-cert '*.pg.internal.example.com' + wait_for_san_in_secret cert-manager-tls-pgbouncer-frontend-tls pg.example.com + + # The generated service names must survive alongside the custom ones. + wait_for_san_in_secret cert-manager-tls-cluster-cert cert-manager-tls-primary diff --git a/internal/controller/postgrescluster/pgbouncer.go b/internal/controller/postgrescluster/pgbouncer.go index ec1caba06a..1b486e6699 100644 --- a/internal/controller/postgrescluster/pgbouncer.go +++ b/internal/controller/postgrescluster/pgbouncer.go @@ -220,6 +220,7 @@ func (r *Reconciler) reconcileCertManagerPGBouncerSecret(ctx context.Context, cl if dnsErr != nil { return nil, errors.Wrap(dnsErr, "get pgbouncer service DNS names") } + dnsNames = append(dnsNames, cluster.Spec.TLS.GetSANs()...) err := c.ApplyPGBouncerCertificate(ctx, cluster, dnsNames) if err != nil { diff --git a/internal/controller/postgrescluster/pki.go b/internal/controller/postgrescluster/pki.go index 45c058e54b..c85abbaa06 100644 --- a/internal/controller/postgrescluster/pki.go +++ b/internal/controller/postgrescluster/pki.go @@ -436,6 +436,10 @@ func (r *Reconciler) reconcileInternalClusterCertificate( dnsNames := append(primaryServiceDNSNames, replicaServiceDNSNames...) dnsFQDN := dnsNames[0] + // ponytail: SANs land in DNSNames verbatim, matching the MySQL operators. + // A bare IP entry is a dNSName, not an iPAddress SAN; thread IPAddresses + // through internal/pki if real IP SANs are ever needed. + dnsNames = append(dnsNames, cluster.Spec.TLS.GetSANs()...) if err == nil { // Unmarshal and validate the stored leaf. These first errors can @@ -523,6 +527,7 @@ func (r *Reconciler) reconcileCertManagerClusterCertificate( return nil, errors.Wrap(err, "get replica service DNS names") } dnsNames := append(primaryDNSNames, replicaDNSNames...) + dnsNames = append(dnsNames, cluster.Spec.TLS.GetSANs()...) err = c.ApplyClusterCertificate(ctx, cluster, dnsNames) if err != nil { diff --git a/internal/pgbouncer/reconcile.go b/internal/pgbouncer/reconcile.go index 0509dbd395..5ea06c067d 100644 --- a/internal/pgbouncer/reconcile.go +++ b/internal/pgbouncer/reconcile.go @@ -156,6 +156,7 @@ func Secret(ctx context.Context, return errors.Wrap(err, "get service dns names") } dnsFQDN = dnsNames[0] + dnsNames = append(dnsNames, inCluster.Spec.TLS.GetSANs()...) } if err == nil { diff --git a/percona/certmanager/certmanager.go b/percona/certmanager/certmanager.go index 4d4504aca3..f77e8c6292 100644 --- a/percona/certmanager/certmanager.go +++ b/percona/certmanager/certmanager.go @@ -4,6 +4,7 @@ import ( "context" "os" "regexp" + "slices" "time" "github.com/cert-manager/cert-manager/pkg/apis/certmanager" @@ -545,6 +546,11 @@ func (c *controller) ApplyClusterCertificate(ctx context.Context, cluster *v1bet needsUpdate = true } + if !slices.Equal(existing.Spec.DNSNames, dnsNames) { + existing.Spec.DNSNames = dnsNames + needsUpdate = true + } + if !needsUpdate { return nil } @@ -766,6 +772,11 @@ func (c *controller) ApplyPGBouncerCertificate(ctx context.Context, cluster *v1b needsUpdate = true } + if !slices.Equal(existing.Spec.DNSNames, dnsNames) { + existing.Spec.DNSNames = dnsNames + needsUpdate = true + } + if !needsUpdate { return nil } diff --git a/percona/certmanager/certmanager_test.go b/percona/certmanager/certmanager_test.go index 3a985bc7c7..065254e022 100644 --- a/percona/certmanager/certmanager_test.go +++ b/percona/certmanager/certmanager_test.go @@ -1529,3 +1529,40 @@ func TestApplyCertificateIssuerRefDrift(t *testing.T) { assert.Equal(t, "vault-issuer", cert.Spec.IssuerRef.Name) }) } + +func TestApplyCertificateDNSNamesDrift(t *testing.T) { + t.Run("cluster certificate picks up added SANs on update", func(t *testing.T) { + cluster := testCluster() + cluster.Name = "drift-cluster-sans" + client := setupFakeClient(t, cluster) + ctrl := NewController(client, client.Scheme(), false) + + dnsNames := []string{"drift-cluster-sans-primary.test-namespace.svc"} + require.NoError(t, ctrl.ApplyClusterCertificate(t.Context(), cluster, dnsNames)) + + withSANs := append(dnsNames, "pg.example.com", "*.pg.internal.example.com") + require.NoError(t, ctrl.ApplyClusterCertificate(t.Context(), cluster, withSANs)) + + cert := &v1.Certificate{} + secretName := naming.PostgresTLSSecret(cluster) + require.NoError(t, client.Get(t.Context(), sigs.ObjectKey{Namespace: cluster.Namespace, Name: secretName.Name}, cert)) + assert.Equal(t, withSANs, cert.Spec.DNSNames) + }) + + t.Run("pgbouncer certificate picks up added SANs on update", func(t *testing.T) { + cluster := testCluster() + cluster.Name = "drift-pgbouncer-sans" + client := setupFakeClient(t, cluster) + ctrl := NewController(client, client.Scheme(), false) + + dnsNames := []string{"drift-pgbouncer-sans-pgbouncer.test-namespace.svc"} + require.NoError(t, ctrl.ApplyPGBouncerCertificate(t.Context(), cluster, dnsNames)) + + withSANs := append(dnsNames, "bouncer.example.com") + require.NoError(t, ctrl.ApplyPGBouncerCertificate(t.Context(), cluster, withSANs)) + + cert := &v1.Certificate{} + require.NoError(t, client.Get(t.Context(), sigs.ObjectKey{Namespace: cluster.Namespace, Name: cluster.Name + "-pgbouncer-cert"}, cert)) + assert.Equal(t, withSANs, cert.Spec.DNSNames) + }) +} 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 bcbe04c601..77c0b6556d 100644 --- a/pkg/apis/upstream.pgv2.percona.com/v1beta1/postgrescluster_types.go +++ b/pkg/apis/upstream.pgv2.percona.com/v1beta1/postgrescluster_types.go @@ -324,6 +324,17 @@ type TLSSpec struct { CertManagementPolicy CertManagementPolicy `json:"certManagementPolicy,omitempty"` // +optional IssuerConf *cmmeta.IssuerReference `json:"issuerConf,omitempty"` + // SANs are extra Subject Alternative Names appended to the Postgres server + // and pgBouncer certificates the operator issues. + // +optional + SANs []string `json:"SANs,omitempty"` +} + +func (s *TLSSpec) GetSANs() []string { + if s == nil { + return nil + } + return s.SANs } func (s *TLSSpec) GetCertManagementPolicy() CertManagementPolicy { diff --git a/pkg/apis/upstream.pgv2.percona.com/v1beta1/zz_generated.deepcopy.go b/pkg/apis/upstream.pgv2.percona.com/v1beta1/zz_generated.deepcopy.go index 508413d9d4..c18bf5f43a 100644 --- a/pkg/apis/upstream.pgv2.percona.com/v1beta1/zz_generated.deepcopy.go +++ b/pkg/apis/upstream.pgv2.percona.com/v1beta1/zz_generated.deepcopy.go @@ -2805,6 +2805,11 @@ func (in *TLSSpec) DeepCopyInto(out *TLSSpec) { *out = new(metav1.IssuerReference) **out = **in } + if in.SANs != nil { + in, out := &in.SANs, &out.SANs + *out = make([]string, len(*in)) + copy(*out, *in) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TLSSpec. From 81ff2973233da77d16df56ad16389cd4572d255f Mon Sep 17 00:00:00 2001 From: yoav-katz <111126160+yoav-katz@users.noreply.github.com> Date: Tue, 1 Sep 2026 23:10:24 +0300 Subject: [PATCH 2/4] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- e2e-tests/tests/cert-manager-tls/30-verify-sans.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/e2e-tests/tests/cert-manager-tls/30-verify-sans.yaml b/e2e-tests/tests/cert-manager-tls/30-verify-sans.yaml index da7d02e5d3..834e17d7ee 100644 --- a/e2e-tests/tests/cert-manager-tls/30-verify-sans.yaml +++ b/e2e-tests/tests/cert-manager-tls/30-verify-sans.yaml @@ -15,7 +15,7 @@ commands: local san="$2" for i in {1..60}; do - if kubectl -n "$NAMESPACE" get certificate "$cert_name" -o jsonpath='{.spec.dnsNames}' | grep -q -- "$san"; then + if kubectl -n "$NAMESPACE" get certificate "$cert_name" -o jsonpath='{range .spec.dnsNames[*]}{.}{"\n"}{end}' | grep -Fxq -- "$san"; then return 0 fi sleep 5 From 49317953757efdf60e0da9118d762983a27dd5cb Mon Sep 17 00:00:00 2001 From: yoav-katz Date: Thu, 3 Sep 2026 22:51:36 +0300 Subject: [PATCH 3/4] removed comment --- internal/controller/postgrescluster/pki.go | 3 --- 1 file changed, 3 deletions(-) diff --git a/internal/controller/postgrescluster/pki.go b/internal/controller/postgrescluster/pki.go index c85abbaa06..a8c8d20ce2 100644 --- a/internal/controller/postgrescluster/pki.go +++ b/internal/controller/postgrescluster/pki.go @@ -436,9 +436,6 @@ func (r *Reconciler) reconcileInternalClusterCertificate( dnsNames := append(primaryServiceDNSNames, replicaServiceDNSNames...) dnsFQDN := dnsNames[0] - // ponytail: SANs land in DNSNames verbatim, matching the MySQL operators. - // A bare IP entry is a dNSName, not an iPAddress SAN; thread IPAddresses - // through internal/pki if real IP SANs are ever needed. dnsNames = append(dnsNames, cluster.Spec.TLS.GetSANs()...) if err == nil { From 4cb999859953e374f00a19048aa6b30dd954819f Mon Sep 17 00:00:00 2001 From: yoav-katz Date: Fri, 4 Sep 2026 00:48:51 +0300 Subject: [PATCH 4/4] fix(e2e_test) --- e2e-tests/tests/cert-manager-tls/30-verify-sans.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/e2e-tests/tests/cert-manager-tls/30-verify-sans.yaml b/e2e-tests/tests/cert-manager-tls/30-verify-sans.yaml index 834e17d7ee..77d1430d58 100644 --- a/e2e-tests/tests/cert-manager-tls/30-verify-sans.yaml +++ b/e2e-tests/tests/cert-manager-tls/30-verify-sans.yaml @@ -15,7 +15,7 @@ commands: local san="$2" for i in {1..60}; do - if kubectl -n "$NAMESPACE" get certificate "$cert_name" -o jsonpath='{range .spec.dnsNames[*]}{.}{"\n"}{end}' | grep -Fxq -- "$san"; then + if kubectl -n "$NAMESPACE" get certificate "$cert_name" -o jsonpath='{range .spec.dnsNames[*]}{@}{"\n"}{end}' | grep -Fxq -- "$san"; then return 0 fi sleep 5