Skip to content

K8SPXC-1847: detect CA rotation and re-issue leaf TLS certificates - #2412

Merged
hors merged 6 commits into
percona:mainfrom
larainema:fix/ca-rotation-leaf-cert-reissuance
May 3, 2026
Merged

K8SPXC-1847: detect CA rotation and re-issue leaf TLS certificates#2412
hors merged 6 commits into
percona:mainfrom
larainema:fix/ca-rotation-leaf-cert-reissuance

Conversation

@larainema

Copy link
Copy Markdown
Contributor

Problem

When cert-manager renews a self-signed CA certificate (created by the operator), it generates a new key pair. The leaf TLS certificates (ssl and ssl-internal) become invalid because they were signed by the old CA.

However, reconcileSSL() short-circuits with an early return nil when both leaf secrets already exist, never detecting that the CA has changed. The leaf certs remain signed by the old (now-expired) CA, eventually causing TLS handshake failures across the PXC cluster.

Fixes #2411

Root Cause

Three interacting issues:

  1. reconcileSSL early return — when both SSL secrets exist, the function returns immediately without checking if the CA is still valid or consistent.
  2. Create-only semanticscreateSSLByCertManager uses r.client.Create() with IsAlreadyExists guard, so it never updates existing Certificate CRs.
  3. cert-manager CA renewal — when cert-manager renews a self-signed CA, it generates a new key pair. All leaf certs signed by the old CA become invalid, but the operator never detects this.

Fix

Add reconcileCARotation() that runs when both leaf secrets exist. It:

  1. Skips clusters using a custom issuer (user-managed CA)
  2. Skips manually created secrets (no cert-manager annotation)
  3. Fetches the current CA secret and compares tls.crt against ca.crt in each leaf secret using bytes.Equal
  4. On mismatch: deletes the stale leaf cert-manager Certificate resources and their Secrets
  5. The next reconcile loop (~5s) re-enters createSSLByCertManager which re-creates them signed by the new CA

Pod rolling restarts are handled automatically by the existing percona.com/ssl-hash annotation mechanism in updatePod().

Edge Cases Handled

  • Custom issuer (spec.tls.issuerConf set) — skipped, CA is user-managed
  • Manually created secrets (no cert-manager.io/issuer-kind annotation) — skipped
  • CA secret not found or empty — skipped (first deployment, still issuing)
  • Shared secret names (SSLSecretName == SSLInternalSecretName) — only one deletion target
  • Race with cert-manager — uses IsNotFound guards on all deletes

@it-percona-cla

it-percona-cla commented Mar 31, 2026

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

@larainema larainema changed the title fix: detect CA rotation and re-issue leaf TLS certificates K8SPXC: detect CA rotation and re-issue leaf TLS certificates Mar 31, 2026
Comment thread pkg/controller/pxc/tls.go Outdated
if k8serr.IsNotFound(err) {
return nil
}
return fmt.Errorf("get CA secret %s: %v", caSecretName, err)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

please wrap errors here and lines below. preferably with errors.Wrap or fmt.Errorf("...: %w")

@egegunes egegunes changed the title K8SPXC: detect CA rotation and re-issue leaf TLS certificates K8SPXC-1847: detect CA rotation and re-issue leaf TLS certificates Apr 1, 2026
@egegunes egegunes added this to the v1.20.0 milestone Apr 1, 2026
@larainema
larainema force-pushed the fix/ca-rotation-leaf-cert-reissuance branch from fa0054b to 9578100 Compare April 1, 2026 06:48
@egegunes

egegunes commented Apr 2, 2026

Copy link
Copy Markdown
Contributor

@larainema I wonder if this is reproducible by simple running cmctl renew? If that's the case, I think we need to test renewals in our e2e tests.

@larainema

Copy link
Copy Markdown
Contributor Author

@larainema I wonder if this is reproducible by simple running cmctl renew? If that's the case, I think we need to test renewals in our e2e tests.

@egegunes I think this is reproducible with cmctl renew — specifically by renewing the CA certificate

cmctl renew <cluster-name>-ca-cert

(Note: cmctl renew <cluster-name>-ssl would not trigger the bug, since that just re-issues the leaf using the same CA.)

I think we can trigger renewal without installing cmctl by using kubectl annotate:

kubectl annotate certificate <cluster-name>-ca-cert cert-manager.io/renew-after=$(date -u +%Y-%m-%dT%H:%M:%SZ) --overwrite

@egegunes

egegunes commented Apr 3, 2026

Copy link
Copy Markdown
Contributor

@larainema could you please confirm this triggers the renewal of ca.crt?

kubectl annotate certificate <cluster-name>-ca-cert cert-manager.io/renew-after=$(date -u +%Y-%m-%dT%H:%M:%SZ) --overwrite

if it does, we need to add this one of the cert-manager tests. probably tls-issue-cert-manager

@larainema

Copy link
Copy Markdown
Contributor Author

@larainema could you please confirm this triggers the renewal of ca.crt?

kubectl annotate certificate <cluster-name>-ca-cert cert-manager.io/renew-after=$(date -u +%Y-%m-%dT%H:%M:%SZ) --overwrite

if it does, we need to add this one of the cert-manager tests. probably tls-issue-cert-manager

@egegunes I tested this on my environment. Here's what I found:

The kubectl annotate cert-manager.io/renew-after approach does NOT work — this annotation is not recognized by cert-manager (it's not in the official annotations list). I was mistaken in my earlier comment. cmctl renew works by setting an Issuing status condition, not an annotation.

cmctl renew works, but with an important caveat: by default, cert-manager reuses the existing private key on renewal. This means the CA cert gets a new serial/dates but the same key pair — so the leaf certs remain valid and the bug is NOT triggered.

To fully reproduce the bug, the CA Certificate needs privateKey.rotationPolicy: Always, which forces cert-manager to generate a new key pair on renewal. With that set:

# Set rotation policy (one-time setup for the test)
kubectl patch certificate <cluster>-ca-cert --type=merge  -p '{"spec":{"privateKey":{"rotationPolicy":"Always"}}}'
# Trigger renewal
cmctl renew <cluster>-ca-cert

After this, I confirmed:

✅ CA secret gets a new key pair (different modulus/SKI)
❌ Leaf secrets still embed the old CA in ca.crt
❌ openssl verify fails: error 30 - authority and subject key identifier mismatch
❌ PXC operator does not detect the mismatch (no log output, cluster stays ready)

@egegunes

egegunes commented Apr 6, 2026

Copy link
Copy Markdown
Contributor

thanks for checking @larainema. can you add this case into tls-issue-cert-manager e2e test? if you need any guidance please let me know.

@larainema
larainema force-pushed the fix/ca-rotation-leaf-cert-reissuance branch 6 times, most recently from 4a02717 to db7f4f2 Compare April 8, 2026 06:31
@larainema
larainema force-pushed the fix/ca-rotation-leaf-cert-reissuance branch 12 times, most recently from 1d34c9d to 7a4a05c Compare April 14, 2026 15:20
When cert-manager renews a self-signed CA certificate, it generates a new
key pair. The leaf certificates (ssl and ssl-internal) become invalid
because they were signed by the old CA, but reconcileSSL short-circuits
when their secrets already exist, never detecting the CA mismatch.

Add reconcileCARotation() that compares ca.crt in leaf secrets against
the current CA secret tls.crt. On mismatch, delete the stale leaf
Secrets so cert-manager re-issues them from the still-existing
Certificate CRs using the new CA.

Pod restarts are handled automatically by the existing
percona.com/ssl-hash annotation mechanism in updatePod().

Fixes percona#2411
@larainema
larainema force-pushed the fix/ca-rotation-leaf-cert-reissuance branch from 7a4a05c to 9379593 Compare April 14, 2026 23:26
Comment on lines +144 to +149
desc 'restart all PXC and HAProxy pods to pick up new certs simultaneously'
# A rolling restart cannot work for CA rotation because pods with new CA
# certs cannot do SSL handshakes with pods still running old CA certs.
# Delete all pods at once so they all restart with the new certs.
sleep 30 # wait for kubelet to sync new cert files into volume mounts
kubectl_bin delete pods -l app.kubernetes.io/instance=$cluster,app.kubernetes.io/managed-by=percona-xtradb-cluster-operator --force --grace-period=0

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@larainema I wonder if it's possible to do it without downtime. Do we have the old ca.crt accessible to the operator after rotation? Maybe we can keep both CA certs available to allow rolling restart?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@egegunes Good point — a zero-downtime rolling restart is technically achievable, but it requires a more involved CA-bundle approach. Here's the trade-off:

Why a plain rolling restart fails today

After CA rotation, ca.crt in the leaf secrets contains only the new CA. During a rolling restart:

Pod-0 restarts → loads new leaf cert (signed by new CA) and trusts only new CA
Pod-1/Pod-2 still in memory → hold old leaf certs (signed by old CA) and trust only old CA
Galera SST/IST handshake between Pod-0 ↔ Pod-1 fails in both directions (neither side trusts the peer's cert chain) → cluster never re-syncs

Zero-downtime path (CA bundle)

To make rolling restart safe, every pod's ca.crt would need to trust both the old and new CA simultaneously during the transition window:

On CA rotation detection, build a bundle containing old_ca || new_ca and write it to the leaf secrets' ca.crt field (instead of just new_ca).
Trigger a rolling restart — every pod now trusts both CAs, so old-leaf and new-leaf pods can mutually authenticate.
After all pods are restarted with the new leaf cert, run a second reconcile that prunes the old CA from ca.crt, leaving only new_ca.
Optionally, do another rolling restart to drop trust in the old CA (purely a hygiene step).

Why I didn't include this in this PR

We do still have the old CA accessible right after rotation: cert-manager keeps it in the previous tls.crt data of the leaf secrets (we read it in reconcileCARotation precisely to detect the mismatch). So the inputs are available.
However, implementing the bundle approach correctly requires:
A new state machine in the operator (bundling → rotating → pruning) to know which phase we're in across reconciles.
Patching cert-manager Certificate resources or post-processing the secrets after cert-manager writes them (cert-manager owns ca.crt, so we'd be fighting the controller unless we add additionalOutputFormats or a webhook).
Coordination with the existing ssl-hash annotation flow so the rolling restart fires at the right phase.
I felt this expanded the scope significantly beyond the original bug (operator silently keeping stale leaf certs after CA rotation).

Proposal

If you're OK with it, I'd suggest landing this PR as-is to fix the correctness bug (no more silent stale certs), then opening a follow-up issue/PR for the zero-downtime CA-bundle work. That way users running CA rotation get a working, consistent state today, and the optimization can be designed and reviewed separately.

WDYT?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@larainema yes, implementing in another PR sounds reasonable

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment thread pkg/controller/pxc/tls.go Outdated
Comment on lines +371 to +376
// Skip manually created secrets.
if sslSecret.Annotations["cert-manager.io/issuer-kind"] == "" {
return nil
}

caSecretName := cr.Name + "-ca-cert"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lets move this to the naming package, might require refactoring in the other cert issuing functions too

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 60c8687. Added [naming.CAIssuerName], [naming.IssuerName], [naming.CACertificateName] and switched both createSSLByCertManager and deleteCerts to use them.

Comment thread pkg/controller/pxc/tls.go Outdated
certNames = append(certNames, cr.Name+"-ssl")
}
if sslInternalMismatch && cr.Spec.PXC.SSLSecretName != cr.Spec.PXC.SSLInternalSecretName {
certNames = append(certNames, cr.Name+"-ssl-internal")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lets move it to naming package too

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 60c8687. Added [naming.SSLCertificateName] / [naming.SSLInternalCertificateName] and switched both createSSLByCertManager, reconcileCARotation, and deleteCerts to use them.

Comment thread pkg/controller/pxc/tls.go
if sslMismatch {
certNames = append(certNames, cr.Name+"-ssl")
}
if sslInternalMismatch && cr.Spec.PXC.SSLSecretName != cr.Spec.PXC.SSLInternalSecretName {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we need the cr.Spec.PXC.SSLSecretName != cr.Spec.PXC.SSLInternalSecretName check?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is for the shared-secret case. When SSLSecretName == SSLInternalSecretName we only manage a single leaf Certificate (see createSSLByCertManager, which short-circuits and waits on a single secret), so the SSLCertificateName entry above already covers it. Without this guard we’d try to re-issue a non-existent *-ssl-internal Certificate. I added a comment explaining this in 60c8687.

Comment thread pkg/controller/pxc/tls.go Outdated
Comment on lines +435 to +468
alreadyIssuing := false
for _, c := range cert.Status.Conditions {
if c.Type == cm.CertificateConditionIssuing && c.Status == cmmeta.ConditionTrue {
alreadyIssuing = true
break
}
}
if alreadyIssuing {
log.Info("Certificate already issuing, skipping", "certificate", name)
continue
}

log.Info("Triggering leaf certificate re-issuance", "certificate", name)

// Set the Issuing condition to True to trigger cert-manager re-issuance.
issuing := cm.CertificateCondition{
Type: cm.CertificateConditionIssuing,
Status: cmmeta.ConditionTrue,
Reason: "ManuallyTriggered",
Message: "Re-issuing due to CA rotation",
LastTransitionTime: &now,
}

found := false
for i, c := range cert.Status.Conditions {
if c.Type == cm.CertificateConditionIssuing {
cert.Status.Conditions[i] = issuing
found = true
break
}
}
if !found {
cert.Status.Conditions = append(cert.Status.Conditions, issuing)
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can't we just use apiutil.SetCertificateCondition instead?

See how cmctl does it - https://github.com/cert-manager/cmctl/blob/main/pkg/renew/renew.go#L217C2-L217C33

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good call — switched to [cmapiutil.SetCertificateCondition] and [cmapiutil.CertificateHasCondition] in 60c8687, matching what cmctl renew does.

… handling

- Move cert-manager Certificate and Issuer name helpers into the naming
  package (CAIssuerName, IssuerName, CACertificateName, SSLCertificateName,
  SSLInternalCertificateName) and use them from createSSLByCertManager,
  reconcileCARotation, and deleteCerts.
- In reconcileCARotation, replace the inline CertificateConditionIssuing
  mutation with cert-manager's apiutil.SetCertificateCondition and
  CertificateHasCondition helpers, matching the behaviour of cmctl renew.
- Document why the SSLSecretName != SSLInternalSecretName guard is needed
  when collecting certificates to re-issue (shared-secret single-Certificate
  case).
@hors hors added the community label Apr 28, 2026
@JNKPercona

Copy link
Copy Markdown
Collaborator
Test Name Result Time
auto-tuning-8-0 passed 00:00:00
allocator-8-0 passed 00:00:00
allocator-8-4 passed 00:00:00
backup-storage-tls-8-0 passed 00:00:00
cross-site-8-0 passed 00:00:00
cross-site-proxysql-8-0 passed 00:00:00
cross-site-proxysql-8-4 passed 00:00:00
custom-users-8-0 passed 00:00:00
demand-backup-cloud-8-0 passed 00:00:00
demand-backup-cloud-8-4 passed 00:00:00
demand-backup-cloud-pxb-8-0 passed 00:00:00
demand-backup-encrypted-with-tls-5-7 passed 00:00:00
demand-backup-encrypted-with-tls-8-0 passed 00:00:00
demand-backup-encrypted-with-tls-8-4 passed 00:00:00
demand-backup-encrypted-with-tls-pxb-5-7 passed 00:00:00
demand-backup-encrypted-with-tls-pxb-8-0 passed 00:00:00
demand-backup-encrypted-with-tls-pxb-8-4 passed 00:00:00
demand-backup-8-0 passed 00:00:00
demand-backup-flow-control-8-0 passed 00:00:00
demand-backup-flow-control-8-4 passed 00:00:00
demand-backup-parallel-8-0 passed 00:00:00
demand-backup-parallel-8-4 passed 00:00:00
demand-backup-without-passwords-8-0 passed 00:00:00
demand-backup-without-passwords-8-4 passed 00:00:00
extra-pvc-8-0 passed 00:00:00
haproxy-5-7 passed 00:00:00
haproxy-8-0 passed 00:00:00
haproxy-8-4 passed 00:00:00
init-deploy-5-7 passed 00:00:00
init-deploy-8-0 passed 00:00:00
limits-8-0 passed 00:00:00
monitoring-2-0-8-0 passed 00:00:00
monitoring-pmm3-8-0 passed 00:00:00
monitoring-pmm3-8-4 passed 00:00:00
one-pod-5-7 passed 00:00:00
one-pod-8-0 passed 00:00:00
pitr-8-0 passed 00:00:00
pitr-8-4 passed 00:00:00
pitr-pxb-8-0 passed 00:00:00
pitr-pxb-8-4 passed 00:00:00
pitr-gap-errors-8-0 passed 00:00:00
pitr-gap-errors-8-4 passed 00:00:00
proxy-protocol-8-0 passed 00:00:00
proxy-switch-8-0 passed 00:00:00
proxysql-sidecar-res-limits-8-0 passed 00:00:00
proxysql-scheduler-8-0 passed 00:25:40
pvc-resize-5-7 passed 00:00:00
pvc-resize-8-0 passed 00:00:00
recreate-8-0 passed 00:00:00
restore-to-encrypted-cluster-8-0 passed 00:00:00
restore-to-encrypted-cluster-8-4 passed 00:00:00
restore-to-encrypted-cluster-pxb-8-0 passed 00:00:00
restore-to-encrypted-cluster-pxb-8-4 passed 00:00:00
scaling-proxysql-8-0 passed 00:00:00
scaling-8-0 passed 00:00:00
scheduled-backup-5-7 passed 00:00:00
scheduled-backup-8-0 passed 00:00:00
scheduled-backup-8-4 passed 00:00:00
security-context-8-0 passed 00:00:00
smart-update1-8-0 passed 00:00:00
smart-update1-8-4 passed 00:00:00
smart-update2-8-0 passed 00:00:00
smart-update2-8-4 passed 00:00:00
smart-update3-8-0 passed 00:00:00
sst-retry-limit-8-0 passed 00:00:00
sst-retry-limit-8-4 passed 00:00:00
storage-8-0 passed 00:00:00
tls-issue-cert-manager-ref-8-0 passed 00:00:00
tls-issue-cert-manager-8-0 passed 00:00:00
tls-issue-self-8-0 passed 00:00:00
upgrade-consistency-8-0 passed 00:00:00
upgrade-consistency-8-4 passed 00:00:00
upgrade-haproxy-5-7 passed 00:00:00
upgrade-haproxy-8-0 passed 00:00:00
upgrade-proxysql-5-7 passed 00:00:00
upgrade-proxysql-8-0 passed 00:00:00
users-5-7 passed 00:00:00
users-8-0 passed 00:00:00
users-scheduler-8-4 passed 00:00:00
validation-hook-8-0 passed 00:00:00
Summary Value
Tests Run 80/80
Job Duration 00:49:38
Total Test Time 00:25:40

commit: a49dbbc
image: perconalab/percona-xtradb-cluster-operator:PR-2412-a49dbbcc

@hors
hors merged commit 7f885a1 into percona:main May 3, 2026
1 check passed
@hors

hors commented May 3, 2026

Copy link
Copy Markdown
Collaborator

Hi @larainema, thank you for your contribution

larainema added a commit to larainema/percona-xtradb-cluster-operator that referenced this pull request May 14, 2026
Resolve conflicts with percona#2412 (CA rotation) which has been merged to main:
- pkg/controller/pxc/tls.go: combine reconcileCARotation + naming helpers
  from percona#2412 with reconcileCertManagerCertificateSpecs + build helpers
  from this PR. Build helpers now use the shared naming constants.
- e2e-tests/tls-issue-cert-manager/run: keep cert-manager readiness
  check, inline cluster creation, and full-pod restart for CA rotation
  from percona#2412; preserve certificate spec update test from this PR.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

community size/L 100-499 lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Self-signed CA certificate expires after 3 years and is not properly renewed, causing PXC cluster TLS failure

7 participants