Skip to content

fix(cluster): strip stale primary label during failover - #10409

Merged
mnencia merged 8 commits into
mainfrom
dev/10403
Apr 20, 2026
Merged

mnencia merged 8 commits into
mainfrom
dev/10403

Conversation

@armru

@armru armru commented Apr 2, 2026 •

Copy link
Copy Markdown
Member

When the operator initiates a failover, the old primary's pod keeps its cnpg.io/instanceRole=primary label until ReconcileMetadata runs. But ReconcileMetadata is skipped during the entire failover window (the CurrentPrimary != TargetPrimary guard returns early), so the -rw service keeps routing to the old primary. If the old primary comes back (e.g. after a temporary network partition), replicas reconnect through the -rw service, satisfy the sync quorum, and writes committed on the stale primary are lost to pg_rewind.

Introduce a third value for the instance role label, unhealthy, and apply it to the old primary as soon as failover starts. Since neither the -rw nor the -ro service selector matches unhealthy, the pod is immediately isolated from all service traffic for the duration of the failover window. ReconcileMetadata restores the replica label once CurrentPrimary == TargetPrimary.

The label is applied best-effort at the point where failover is initiated, and re-applied on every pass of the reconcile loop while the failover is in progress so transient API errors are retried automatically.

Note: stripping the label removes the pod from the service Endpoints, but does not drop TCP connections already established by a replica's walreceiver. This fix closes the reconnection window; established connections must still be terminated by the Postgres-level promotion on the new primary.

Closes #10403

@armru
armru requested a review from a team April 2, 2026 13:40
@dosubot dosubot Bot added the size:L This PR changes 100-499 lines, ignoring generated files. label Apr 2, 2026
@armru

armru commented Apr 2, 2026

Copy link
Copy Markdown
Member Author

/test

@cnpg-bot cnpg-bot added backport-requested ◀️ This pull request should be backported to all supported releases release-1.25 release-1.28 labels Apr 2, 2026
@github-actions

github-actions Bot commented Apr 2, 2026

Copy link
Copy Markdown
Contributor

❗ By default, the pull request is configured to backport to all release branches.

  • To stop backporting this pr, remove the label: backport-requested ◀️ or add the label 'do not backport'
  • To stop backporting this pr to a certain release branch, remove the specific branch label: release-x.y

@github-actions

github-actions Bot commented Apr 2, 2026

Copy link
Copy Markdown
Contributor

@armru armru changed the title fix(controller): strip stale primary label during failover (#10403) fix(cluster): strip stale primary label during failover (#10403) Apr 2, 2026
@dosubot dosubot Bot added the bug 🐛 Something isn't working label Apr 2, 2026
@armru
armru force-pushed the dev/10403 branch 2 times, most recently from 6ab0451 to de27f97 Compare April 2, 2026 13:46
@cnpg-bot cnpg-bot added the ok to merge 👌 This PR can be merged label Apr 2, 2026
@armru
armru force-pushed the dev/10403 branch 2 times, most recently from 9113404 to 96f77ef Compare April 7, 2026 10:01
@armru

armru commented Apr 7, 2026

Copy link
Copy Markdown
Member Author

/test

@github-actions

github-actions Bot commented Apr 7, 2026

Copy link
Copy Markdown
Contributor

@kfcss

kfcss commented Apr 8, 2026

Copy link
Copy Markdown

This is great progress. However, stripping the label will not immediately ensure that replicas don't connect to it as there is a delay in these updates. Could be nice if the primary would itself reject replicas connecting to it or tell replicas to stop connecting to anyone until a failover is complete.

@dosubot dosubot Bot added size:M This PR changes 30-99 lines, ignoring generated files. and removed size:L This PR changes 100-499 lines, ignoring generated files. labels Apr 14, 2026
@leonardoce

Copy link
Copy Markdown
Contributor

/test

@github-actions

Copy link
Copy Markdown
Contributor

@dosubot dosubot Bot added the lgtm This PR has been approved by a maintainer label Apr 17, 2026
@mnencia
mnencia requested a review from jsilvela as a code owner April 20, 2026 09:22
@dosubot dosubot Bot added size:L This PR changes 100-499 lines, ignoring generated files. and removed size:M This PR changes 30-99 lines, ignoring generated files. labels Apr 20, 2026
armru and others added 7 commits April 20, 2026 15:33
When the operator initiates a failover, the old primary's pod keeps
its cnpg.io/instanceRole=primary label until ReconcileMetadata runs.
But ReconcileMetadata is skipped during the entire failover window
(the CurrentPrimary != TargetPrimary guard returns early), so the -rw
service keeps routing to the old primary. If the old primary comes
back (e.g. after a temporary network partition), replicas reconnect
through the -rw service, satisfy the sync quorum, and writes committed
on the stale primary are lost to pg_rewind.

Strip the primary label from the old primary pod immediately when
failover is initiated, so the pod is removed from the -rw service
Endpoints for the duration of the failover window.

Fixes: #10403

Signed-off-by: Armando Ruocco <[email protected]>
Signed-off-by: Marco Nenciarini <[email protected]>
The label demotion call in replicas.go fires once during failover
initiation. If it fails, the pending marker is already set and the
CurrentPrimary != TargetPrimary guard in the reconcile loop prevents
the code from ever reaching that call site again.

Make the replicas.go call best-effort (log and continue) so a patch
failure doesn't abort the failover, and add a retryable call inside
the failover guard in cluster_controller.go. This second call runs
on every 1-second reconcile while the failover is in progress, so a
transient API server error is retried automatically.

Also rename demoteOldPrimaryLabel to ensureOldPrimaryLabelIsDemoted,
take []corev1.Pod instead of *managedResources, skip the API patch
when the label is already correct, and use slices.IndexFunc for the
pod lookup.

Refs: #10403
Signed-off-by: Armando Ruocco <[email protected]>
Setting the old primary's role to "replica" during failover was arguably
incorrect: the -ro service selector matches that value, so the old primary —
still running PostgreSQL as a primary with potentially diverged WAL — would
become a target for read-only connections.

Introduce ClusterRoleLabelUnhealthy ("unhealthy") as a third role value
for the transitional state. Since neither the -rw nor the -ro service
selectors match it, the pod is isolated from all service traffic for the
duration of the failover window. ReconcileMetadata naturally overwrites it
with "replica" once CurrentPrimary == TargetPrimary.

Rename ensureOldPrimaryLabelIsDemoted to markOldPrimaryAsUnhealthy to
reflect the new semantics, and add comments in the metadata reconcilers
explaining why overwriting "unhealthy" with "replica" is safe and correct.

Signed-off-by: Leonardo Cecchi <[email protected]>
@mnencia

mnencia commented Apr 20, 2026

Copy link
Copy Markdown
Member

/test

@github-actions

Copy link
Copy Markdown
Contributor

The helper took ObjectMeta by value. The map field is a reference type
so label writes still reached the caller's map, but the nil-labels
allocation path assigned to the local copy and was silently dropped.
All current callers pass non-nil labels, but the shape is a quiet
footgun for future use.

Switch to a pointer receiver to align with InheritLabels and
InheritAnnotations in the same package.

Signed-off-by: Marco Nenciarini <[email protected]>
@mnencia

mnencia commented Apr 20, 2026

Copy link
Copy Markdown
Member

/test

@github-actions

Copy link
Copy Markdown
Contributor

@mnencia mnencia changed the title fix(cluster): strip stale primary label during failover (#10403) fix(cluster): strip stale primary label during failover Apr 20, 2026
@mnencia
mnencia merged commit 57436b2 into main Apr 20, 2026
43 of 45 checks passed
@mnencia
mnencia deleted the dev/10403 branch April 20, 2026 17:04
cnpg-bot pushed a commit that referenced this pull request Apr 20, 2026
When the operator initiates a failover, the old primary's pod keeps its
`cnpg.io/instanceRole=primary` label until `ReconcileMetadata` runs. But
`ReconcileMetadata` is skipped during the entire failover window (the
`CurrentPrimary != TargetPrimary` guard returns early), so the `-rw`
service keeps routing to the old primary. If the old primary comes back
(e.g. after a temporary network partition), replicas reconnect through
the `-rw` service, satisfy the sync quorum, and writes committed on the
stale primary are lost to `pg_rewind`.

Introduce a third value for the instance role label, `unhealthy`, and
apply it to the old primary as soon as failover starts. Since neither
the `-rw` nor the `-ro` service selector matches `unhealthy`, the pod is
immediately isolated from all service traffic for the duration of the
failover window. `ReconcileMetadata` restores the `replica` label once
`CurrentPrimary == TargetPrimary`.

The label is applied best-effort at the point where failover is
initiated, and re-applied on every pass of the reconcile loop while the
failover is in progress so transient API errors are retried
automatically.

Note: stripping the label removes the pod from the service Endpoints,
but does not drop TCP connections already established by a replica's
walreceiver. This fix closes the reconnection window; established
connections must still be terminated by the Postgres-level promotion on
the new primary.

Closes #10403

Signed-off-by: Armando Ruocco <[email protected]>
Signed-off-by: Marco Nenciarini <[email protected]>
Signed-off-by: Leonardo Cecchi <[email protected]>
Co-authored-by: Leonardo Cecchi <[email protected]>
Co-authored-by: Marco Nenciarini <[email protected]>
(cherry picked from commit 57436b2)
cnpg-bot pushed a commit that referenced this pull request Apr 20, 2026
When the operator initiates a failover, the old primary's pod keeps its
`cnpg.io/instanceRole=primary` label until `ReconcileMetadata` runs. But
`ReconcileMetadata` is skipped during the entire failover window (the
`CurrentPrimary != TargetPrimary` guard returns early), so the `-rw`
service keeps routing to the old primary. If the old primary comes back
(e.g. after a temporary network partition), replicas reconnect through
the `-rw` service, satisfy the sync quorum, and writes committed on the
stale primary are lost to `pg_rewind`.

Introduce a third value for the instance role label, `unhealthy`, and
apply it to the old primary as soon as failover starts. Since neither
the `-rw` nor the `-ro` service selector matches `unhealthy`, the pod is
immediately isolated from all service traffic for the duration of the
failover window. `ReconcileMetadata` restores the `replica` label once
`CurrentPrimary == TargetPrimary`.

The label is applied best-effort at the point where failover is
initiated, and re-applied on every pass of the reconcile loop while the
failover is in progress so transient API errors are retried
automatically.

Note: stripping the label removes the pod from the service Endpoints,
but does not drop TCP connections already established by a replica's
walreceiver. This fix closes the reconnection window; established
connections must still be terminated by the Postgres-level promotion on
the new primary.

Closes #10403

Signed-off-by: Armando Ruocco <[email protected]>
Signed-off-by: Marco Nenciarini <[email protected]>
Signed-off-by: Leonardo Cecchi <[email protected]>
Co-authored-by: Leonardo Cecchi <[email protected]>
Co-authored-by: Marco Nenciarini <[email protected]>
(cherry picked from commit 57436b2)
mnencia added a commit that referenced this pull request Apr 22, 2026
When the operator initiates a failover, the old primary's pod keeps its
`cnpg.io/instanceRole=primary` label until `ReconcileMetadata` runs. But
`ReconcileMetadata` is skipped during the entire failover window (the
`CurrentPrimary != TargetPrimary` guard returns early), so the `-rw`
service keeps routing to the old primary. If the old primary comes back
(e.g. after a temporary network partition), replicas reconnect through
the `-rw` service, satisfy the sync quorum, and writes committed on the
stale primary are lost to `pg_rewind`.

Introduce a third value for the instance role label, `unhealthy`, and
apply it to the old primary as soon as failover starts. Since neither
the `-rw` nor the `-ro` service selector matches `unhealthy`, the pod is
immediately isolated from all service traffic for the duration of the
failover window. `ReconcileMetadata` restores the `replica` label once
`CurrentPrimary == TargetPrimary`.

The label is applied best-effort at the point where failover is
initiated, and re-applied on every pass of the reconcile loop while the
failover is in progress so transient API errors are retried
automatically.

Note: stripping the label removes the pod from the service Endpoints,
but does not drop TCP connections already established by a replica's
walreceiver. This fix closes the reconnection window; established
connections must still be terminated by the Postgres-level promotion on
the new primary.

Closes #10403

Signed-off-by: Armando Ruocco <[email protected]>
Signed-off-by: Marco Nenciarini <[email protected]>
Signed-off-by: Leonardo Cecchi <[email protected]>
Co-authored-by: Leonardo Cecchi <[email protected]>
Co-authored-by: Marco Nenciarini <[email protected]>
(cherry picked from commit 57436b2)
sdwilsh pushed a commit to sdwilsh/ansible-playbooks that referenced this pull request May 11, 2026
##### [\`v1.29.1\`](https://github.com/cloudnative-pg/cloudnative-pg/releases/tag/v1.29.1)

**Release date:** May 8, 2026

##### Security and Supply Chain

- **`CVE-2026-44477` / `GHSA-423p-g724-fr39`: metrics exporter privilege escalation**: the metrics exporter no longer authenticates as the `postgres` superuser. It now uses a dedicated `cnpg_metrics_exporter` role with `pg_monitor` privileges only, closing a chain that let a low-privilege database user gain PostgreSQL superuser. ([`GHSA-423p-g724-fr39`](GHSA-423p-g724-fr39)) <!-- 1.29 1.28 1.25 -->

  Upgrade impact: custom monitoring queries that read user-owned tables, or use `target_databases: '*'` against databases where `PUBLIC CONNECT` has been revoked, need explicit `GRANT` statements to `cnpg_metrics_exporter`. See ["Custom query privileges and safety"](../monitoring.md#custom-query-privileges-and-safety) and ["Manually creating the metrics exporter role"](../monitoring.md#manually-creating-the-metrics-exporter-role) in the monitoring documentation.

  For replica clusters, upgrade the source primary cluster before any replica clusters that consume from it. The `cnpg_metrics_exporter` role is created on the source primary and replicates downstream; a replica cluster upgraded first will scrape against a missing role until the source primary upgrades. The manual-recovery section linked above also covers replica clusters.

- **Schema-qualified catalog references in default monitoring queries**: hardened the shipped monitoring configuration and documentation samples by qualifying every `pg_catalog` object explicitly. Unqualified references resolve through `search_path`, which a database user can manipulate to shadow built-in objects. ([#10576](cloudnative-pg/cloudnative-pg#10576)) <!-- 1.29 1.28 1.25 -->

- **Discoverable SBOM and provenance attestations**: SBOM and SLSA provenance attached to operator container images now follow the OCI 1.1 Referrers spec, so standard registry tooling and supply-chain scanners can discover them automatically. ([#10601](cloudnative-pg/cloudnative-pg#10601)) <!-- 1.29 1.28 1.25 -->

- **CVE remediation in `github.com/jackc/pgx/v5`**: bumped to v5.9.2 to pick up upstream fixes for `CVE-2026-33816` (memory-safety in `pgproto3`) and `GHSA-j88v-2chj-qfwx` (SQL injection via simple-protocol dollar-quoted string handling). ([#10437](cloudnative-pg/cloudnative-pg#10437), [#10499](cloudnative-pg/cloudnative-pg#10499))

- **CVE remediation in the Go runtime**: built with Go 1.26.3 to pick up upstream fixes in `crypto/x509`, `crypto/tls`, `net/http`, and `net` (CVE-2026-32280, CVE-2026-32281, CVE-2026-33810, CVE-2026-33814, CVE-2026-33811, CVE-2026-39825). ([#10463](cloudnative-pg/cloudnative-pg#10463), [#10647](cloudnative-pg/cloudnative-pg#10647)) <!-- 1.29 1.28 1.25 -->

- **Build pipeline hardening**: the Go 1.26.3 bump also addresses CVE-2026-42501 (`cmd/go` module-checksum validation), reducing supply-chain exposure during release builds. The affected code paths are not reachable from the running operator. ([#10647](cloudnative-pg/cloudnative-pg#10647)) <!-- 1.29 1.28 1.25 -->

##### Changes

- Switched TLS peer verification from `VerifyPeerCertificate` to `VerifyConnection`, which runs on every completed handshake (the former is skipped on resumed TLS 1.3 sessions). Session resumption is not enabled in CloudNativePG today, so this has no observable effect, but it future-proofs verification if session caching is introduced later. ([#10478](cloudnative-pg/cloudnative-pg#10478)) <!-- 1.29 1.28 1.25 -->

##### Fixes

- Fixed a failover window where the former primary kept its primary label. If it returned during failover (for example, after a transient network partition), the `-rw` service kept routing to it, replicas could reconnect, and committed writes were lost to `pg_rewind`. The old primary is now labeled `unhealthy` to isolate it from service traffic during failover. ([#10409](cloudnative-pg/cloudnative-pg#10409)) <!-- 1.29 1.28 1.25 -->

- Fixed failover not being triggered when the node hosting the primary becomes unreachable. The operator now reads the pod's `Ready` condition (flipped to `False` by the node controller when the kubelet stops reporting) instead of `ContainersReady`, which stays stale as `True` in that scenario. Combined with the spurious-failover guard ([#10445](cloudnative-pg/cloudnative-pg#10445)), failover triggers only when Kubernetes itself marks the pod not Ready. ([#10448](cloudnative-pg/cloudnative-pg#10448)) <!-- 1.29 1.28 1.25 -->

- Fixed spurious failovers caused by transient failures on the primary's HTTP status endpoint. ([#10445](cloudnative-pg/cloudnative-pg#10445)) <!-- 1.29 1.28 1.25 -->

- Fixed escaping of backslashes and control characters in PostgreSQL configuration values. Previously, such characters in parameters like `log_line_prefix` could corrupt the configuration file or be silently stripped at runtime. ([#10515](cloudnative-pg/cloudnative-pg#10515)) <!-- 1.29 1.28 1.25 -->

- Fixed `restore_command` construction to shell-quote each argument. Values such as a `destinationPath` containing whitespace (for example, `s3://my bucket/wal`) were word-split by the POSIX shell and passed to the WAL restore tool as separate arguments. ([#10518](cloudnative-pg/cloudnative-pg#10518)) <!-- 1.29 1.28 1.25 -->

- Tightened `recoveryTarget` validation in the admission webhook: `targetXID` must now be a non-negative 32-bit integer, and `targetName` must be shorter than 64 bytes and free of ASCII control characters. Malformed values are rejected at admission instead of failing later during PostgreSQL recovery. ([#10565](cloudnative-pg/cloudnative-pg#10565)) <!-- 1.29 1.28 1.25 -->

- Fixed snapshot restores failing when leftover `pgsql_tmp*` directories were present in the data directory. ([#10447](cloudnative-pg/cloudnative-pg#10447)) <!-- 1.29 1.28 1.25 -->

- Fixed a deadlock occurring when PVC storage size and resource requests are changed simultaneously. ([#10427](cloudnative-pg/cloudnative-pg#10427)) <!-- 1.29 1.28 1.25 -->
@chobostar

Copy link
Copy Markdown

@mnencia
The description says:

stripping the label removes the pod from the service Endpoints, but does not drop TCP connections already
established by a replica's walreceiver.

That holds for kube-proxy, but not for Cilium kube-proxy replacement without socket LB (kube-proxy-replacement=true,
bpf-lb-sock=false; Cilium 1.19.8 in our case). There the -rw VIP is translated per packet, so when the EndpointSlice
becomes empty, established replica connections go silent and both ends hit wal_sender_timeout /
wal_receiver_timeout (5s).

Two consequences we hit in production on 1.30.0:

  1. It also runs on planned switchovers. The guard in cluster_controller.go checks only
    CurrentPrimary != TargetPrimary, so the label is stripped at the very start of a switchover, while the old
    primary is still running CHECKPOINT and streaming WAL.
  2. Replicas then miss the old primary's last WAL unevenly. Each node applies the service update at its own time.
    When the target misses the shutdown checkpoint and another replica gets it, the target promotes at an earlier LSN
    and that replica is left past the fork point (new timeline N forked off … before current recovery point), still
    Ready. This is the outcome described in [Bug]: Race condition in unschedulable node auto-switchover #11293.

Evidence: in 2 days, 8 replication timeout disconnects, every one ~5s after a switchover started, all on the Cilium
cluster; 42 switchovers on a kube-proxy cluster with the same operator: 0. Reproduced on a test cluster with pgbench
and kubectl cnpg promote: the label changed at 13:40:32.8, -rw had 0 backends in Cilium by 13:40:34.4, Postgres
fast shutdown started only at 13:40:34.2, both ends timed out at 13:40:38.5.

Suggestion: apply the label only on the failover path (e.g. keyed on the failover phase rather than on
CurrentPrimary != TargetPrimary alone), and keep the old behaviour for switchover, where the old primary is healthy
and must finish streaming before shutdown. Happy to open a separate issue with full logs.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

backport-requested ◀️ This pull request should be backported to all supported releases bug 🐛 Something isn't working lgtm This PR has been approved by a maintainer ok to merge 👌 This PR can be merged release-1.28 size:L This PR changes 100-499 lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Primary pod label not removed during failover, allowing stale -rw service routing

6 participants