Repository navigation
bug(backups/postgres): repeat in-place RestoreJob silently succeeds without restoring (destructive purge skipped when target still carries bootstrap.recovery) #3311
Description
Summary
A repeat in-place PostgreSQL RestoreJob into a target that was already
restored once silently no-ops: the job reports Succeeded and sets the
TargetPurged condition message "Cluster ... deleted; awaiting chart-rendered
re-bootstrap", but the destructive purge never runs. The live cnpg.io Cluster
and its PVCs are never deleted, no *-full-recovery pods appear, and the
database keeps its pre-restore contents.
Verified against main (internal/backupcontroller/cnpgstrategy_controller.go).
Reproduction
- Back up a postgres app (e.g. 3000 rows).
- Restore it once in-place. Works: data restored.
- Dirty the DB (e.g. add rows, now 3100 with garbage).
- Run a second in-place RestoreJob from the same backup.
Expected: DB purged and re-bootstrapped from the backup, back to 3000 rows.
Actual: RestoreJob = Succeeded, condition message "Cluster deleted; awaiting
re-bootstrap", but:
- PVC unchanged (same uid), disk not recreated;
- no
*-full-recoverypods; - DB still has the 3100 dirty rows.
Root cause
The restore patch sets spec.bootstrap.enabled=true on the Postgres app and it
is never reset:
buildPostgresAppRestorePatchsetspatched.Spec.Bootstrap.Enabled = true
(cnpgstrategy_controller.go:861); no code path clears it.- While that flag is true, the chart keeps rendering
spec.bootstrap.recovery
on the live cnpg.io Cluster (packages/apps/postgres/templates/db.yaml:162,
{{- if .Values.bootstrap.enabled }}).
On the second RestoreJob the re-purge guard misreads this leftover as "an
earlier reconcile of this restore already purged":
clusterHasRecoveryBootstrapreturns true because
cluster.Spec.Bootstrap.Recovery != nil(:1157), left over from restore Removewould violate PodSecurity "restricted:latest"warnings #1.cnpgPurgeNeeded(purgedCondition=false, liveClusterHasRecovery=true)returns
!true == false, sopurgeExistingCluster(Cluster + PVC delete) is skipped
(:322-327, guard at:622).- The reconcile still sets
TargetPurged=Truewith the misleading "Cluster
deleted; awaiting chart-rendered re-bootstrap" message (:729-734); on the
next pass the still-healthy old cluster passescnpgClusterHealthyand the
job is markedSucceeded(:749-773).
The liveClusterHasRecovery guard was added to avoid re-purging a
freshly-recovered cluster when the post-purge Status().Update races/fails. It
cannot distinguish that race from "a previous, completed restore left
bootstrap.recovery on the cluster." The wrong assumption is locked in by a
passing test: TestCNPGPurgeNeeded case "live cluster already recovered (status
write raced): skip - the bug fix" (cnpgstrategy_controller_test.go:573-578)
asserts skip for exactly purgedCondition=false, hasRecovery=true.
Impact
Silent data-integrity failure: a restore reports success while doing nothing. A
tenant using restore to roll back bad data gets a false Succeeded and keeps
the corrupted data.
Proposed fix (direction)
Distinguish "recovery bootstrap left by THIS RestoreJob's own purge" from
"recovery bootstrap left by a previous completed restore":
- Record the purged Cluster's UID/creationTimestamp on the RestoreJob status at
purge time; skip re-purge only when the live Cluster is a different
(chart-recreated) object. (Safer.) - Reset
spec.bootstrap.enabled=falseafter a successful restore - must reuse
the same HR-suspend flow, since flipping bootstrap on an initialized Cluster
hits CNPG's "Only one bootstrap" admission.
Either way: add an integration/e2e test for the repeat-in-place-restore
scenario and fix the TestCNPGPurgeNeeded case that encodes the wrong
assumption.
Related
Related to #2774 (PITR tracking epic). That issue lists the mirror failure mode
(recovery killed mid-flight by a Flux re-trigger); this is the opposite - the
purge is skipped so recovery never runs.
Activity
test(backups/postgres): reconcile-level repeat-restore regression + c…
fix(backups/postgres): purge stale recovery cluster on repeat in-plac…
test(backups/postgres): reconcile-level repeat-restore regression + c…
androndo commentedon Aug 3, 2026
Fixed on
main. Closing after verifying against04b742670.PR #3318 (
7212e9b8c, merged 2026-07-16) replaced theclusterHasRecoveryBootstrappredicate withcnpgClusterFreshlyRecovered(hasRecovery, clusterCreatedAt, restoreStartedAt)ininternal/backupcontroller/cnpgstrategy_controller.go, whose body isreturn clusterCreatedAt.After(restoreStartedAt.Time), and fed it intocnpgPurgeNeeded. A stale recovery Cluster left by an earlier restore predates the new RestoreJob'sstatus.startedAt, so it is now purged instead of being mistaken for the one this restore just created — which is exactly the destructive silent-success path reported here.The test that encoded the old assumption was rewritten rather than deleted:
cnpgstrategy_controller_test.gonow carries"stale recovery cluster from a previous completed restore: purge (repeat-restore fix)"withwant: true.Two things deliberately not carried by that fix, so they are not lost with this close:
bootstrap.enabled=falseon the application after a completed restore) was not taken;buildPostgresAppRestorePatchstill setsBootstrap.Enabled = truewith no clearing path, which is the root of fix(backups): platform-flow (useSystemBucket) in-place Postgres restore cannot converge past the chart's bootstrap guard #3327 (platform-flow in-place restore cannot converge past the chart guard). The provenance hardening of the purge gate itself lives in bug(backups/postgres): CNPG in-place restore purge-skip relies on creationTimestamp, not true provenance #3322.Found during an audit of all open backup issues.