Skip to content

feat(backups): support PostgreSQL point-in-time recovery (PITR) - #3383

Merged
Andrey Kolkov (androndo) merged 3 commits into
mainfrom
feat/2774-postgres-pitr
Jul 23, 2026
Merged

Andrey Kolkov (androndo) merged 3 commits into
mainfrom
feat/2774-postgres-pitr

Conversation

@androndo

@androndo Andrey Kolkov (androndo) commented Jul 21, 2026 •

Copy link
Copy Markdown
Contributor

What this PR does

Makes PostgreSQL point-in-time recovery (PITR) a genuinely supported, tested capability for the new backups.cozystack.io API. Fixes #2774.

Was stacked on #3313 (fix/3300-cnpg-barman-plugin) for the recoveryTime → CNPG bootstrap.recovery.recoveryTarget.targetTime plumbing; #3313 has since merged and this branch is rebased on main. This PR adds the reliability + verification layer #2774 asked for.

Changes

  • Precise failure reason for an unreachable target. The restore deadline (spec.options.restoreTimeoutSeconds, default 30m) stays the sole authority for "this restore is stuck" — the client waits the full window either way. What changes is the diagnosis: once the deadline has elapsed, if recoveryTime was set and the recovery Cluster never went healthy, the CNPG driver reads the full-recovery pod logs and — when several distinct attempts exited with PostgreSQL's FATAL: recovery ended before configured recovery target was reached — fails the RestoreJob with RecoveryConverged=False / Ready=False (reason RecoveryTargetUnreachable) and an actionable message, instead of a bare generic timeout. Timing is unchanged; only the reason/message differ. (For a faster rejection, set a shorter restoreTimeoutSeconds.) Design points:
    • We deliberately do NOT trip early off the FATAL. A valid near-now target emits that same FATAL transiently — repeatedly, for many attempts, on a slow archiver — before it converges, so any earlier trip would wrongly reject a recoverable restore (observed twice in CI). The FATAL is used only to explain a failure the deadline has already declared.
    • Health wins unconditionally — the deadline/classification check precedes the healthy check, but a recovery that converges within the window reaches success and is never classified as failed (a live dev7 restore did exactly this).
    • Counting distinct FATAL-ing attempts (not pod failures) tolerates a one-off archive-lag miss on a near-now target and ignores transient recovery-pod crashes (a node blip / brief API-server unreachability never emits that FATAL).
    • Adds the pods/log RBAC grant the log read needs (distinct subresource from pods); a Forbidden is logged loudly and surfaced in the failure message rather than silently disabling the classification.
  • e2e (examples/backups/postgres/run-all.sh, wired into the Chainsaw postgres-2-backup-roundtrip suite):
    • Step 45 — write before, capture server timestamp (µs precision), write after, restore to that timestamp, assert before present and after absent.
    • Step 46 — a recoveryTime an hour ahead must fail with reason RecoveryTargetUnreachable once the deadline elapses (asserts the reason, so it proves the classification fired, not just that a timeout occurred).
    • Chainsaw budget raised 25m → 40m to cover three sequential restores + the post-deadline classification.
  • Docs + example: uncommented 45-restorejob-pitr.yaml; a docs/operations/backup-classes.md PITR section (recoverable window, discovering earliest/latest restorable time from the backup catalog since firstRecoverabilityPoint is unreliable under the plugin, archive-lag tolerance, idempotency under GitOps).

Verification

  • go build / go vet clean; unit tests pass (logIndicatesRecoveryTargetUnreachable, recoveryUnreachableFromLogs, recoveryPodsToInspect, nil-Clientset guard, a reconcile-level success-path test asserting Succeeded + RecoveryConverged=True, and — added per review — TestReconcileCNPGRestore_DeadlineClassifiesRecoveryTargetUnreachable, a reconcile-level test driving the deadline branch through all three sub-paths: FATAL found → reason RecoveryTargetUnreachable, FATAL absent → generic timeout, Forbidden log read → generic timeout + missing-pods/log-RBAC note. It fails if the call-site reason is reverted to the generic one).
  • Reviewed via adversarial branch-review; fixes include a critical missing-RBAC dead-on-arrival, a classification false-positive on transient recovery-pod crashes, and a sub-second e2e flake.
  • dev7: backup + WAL archiving + recovery convergence verified live on the fix(backups): support cnpg-operator with barman plugin #3313 build. The load-bearing assumption of the classification — CNPG accumulates failed full-recovery pods rather than reusing one — was confirmed (observed 7 simultaneous failed recovery pods). A fully-green live step-46 run was blocked by intermittent COSI bucket-provisioning flakiness on dev7; the negative path is covered by the CI Chainsaw e2e.

Downstream repositories

Walked the trigger map in docs/agents/contributing.md against the diff. The only user-facing surface is the new docs/operations/backup-classes.md PITR section; the website keeps a hand-mirrored copy per docs version.

feat(backups): PostgreSQL RestoreJob now supports point-in-time recovery (PITR). Set spec.options.recoveryTime (RFC3339) to restore to an exact instant. A recoveryTime past the latest archived WAL still runs to the restore deadline (spec.options.restoreTimeoutSeconds, default 30m), but the failure is now reported with the precise reason RecoveryTargetUnreachable and an actionable message instead of a generic timeout; set a shorter restoreTimeoutSeconds for a faster rejection.

Summary by CodeRabbit

  • New Features
    • Added PostgreSQL point-in-time recovery (PITR) support via RFC3339 recoveryTime.
    • Introduced a PITR RestoreJob example, plus helper variables and an unreachable-target scenario.
  • Bug Fixes
    • Improved restore deadline handling with deterministic, log-based fail-fast classification for unreachable recovery targets, including clearer conditions.
  • Documentation
    • Added detailed PITR operational documentation (recovery windows, failure behavior, and GitOps/idempotent behavior).
    • Expanded README and example comments for PITR verification and failure expectations.
  • Tests
    • Extended unit tests and end-to-end chainsaw workflow to cover PITR success and unreachable failures.
  • Chores
    • Updated RBAC to allow pod log reads; refined cleanup and run scripts for additional RestoreJob artifacts.

@github-actions github-actions Bot added area/storage Issues or PRs related to storage (linstor, seaweedfs, bucket, velero, harbor) kind/feature Categorizes issue or PR as related to a new feature size/XL This PR changes 500-999 lines, ignoring generated files labels Jul 21, 2026
@coderabbitai

coderabbitai Bot commented Jul 21, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

PostgreSQL RestoreJob now supports tested point-in-time recovery with timestamp substitution, marker-based validation, fail-fast detection for unreachable targets, convergence status conditions, pod-log permissions, and documentation for recovery windows and reconciliation behavior.

Changes

PostgreSQL PITR

Layer / File(s) Summary
Restore failure status and log access
internal/backupcontroller/restorejob_controller.go, packages/system/backupstrategy-controller/templates/rbac.yaml
The reconciler creates a Kubernetes clientset, supports caller-provided failure reasons, and can read recovery pod logs.
PITR convergence detection
internal/backupcontroller/cnpgstrategy_controller.go
CNPG recovery logs are inspected for unreachable-target failures; RestoreJobs record RecoveryConverged status and classify detected failures as RecoveryTargetUnreachable.
Controller behavior tests
internal/backupcontroller/cnpgstrategy_controller_test.go
Tests cover log matching, pod selection, clientset handling, deadline classification, and successful convergence conditions.
PITR examples and end-to-end flow
examples/backups/postgres/*, hack/e2e-chainsaw/postgres/chainsaw-test.yaml
Examples and scripts run timestamp-based restores, marker assertions, unreachable-target checks, cleanup, and the expanded Chainsaw timeout.
PITR operational documentation
docs/operations/backup-classes.md
Documentation explains recovery targets, restorable time bounds, failure modes, timestamp discovery, and in-progress restore reconciliation.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant run-all.sh
  participant RestoreJob
  participant CNPG
  participant PostgreSQL
  run-all.sh->>RestoreJob: submit recoveryTime restore
  RestoreJob->>CNPG: configure recovery target
  CNPG->>PostgreSQL: replay archived WAL
  PostgreSQL-->>CNPG: recovered state or unreachable-target FATAL
  CNPG-->>RestoreJob: cluster health and recovery status
  RestoreJob-->>run-all.sh: Succeeded or RecoveryTargetUnreachable
Loading

Suggested labels: area/testing

Suggested reviewers: lllamnyp

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The PR appears to satisfy #2774 by adding PITR docs, an enabled e2e flow, clear unreachable-target failure handling, and a working example.
Out of Scope Changes check ✅ Passed The changes are focused on PostgreSQL PITR support, with docs, examples, controller logic, RBAC, and tests all directly related.
Docstring Coverage ✅ Passed Docstring coverage is 81.25% which is sufficient. The required threshold is 80.00%.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: adding PostgreSQL PITR support for backups.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/2774-postgres-pitr

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

myasnikovdaniil added a commit that referenced this pull request Jul 21, 2026
…i-driver (#3386)

## What this PR does

`quay.io/centos/centos:stream9` is a **rolling tag** — upstream deletes
the previously-pinned digest whenever it publishes a new `stream9`
build. The digest re-pinned in ce4b263 (`sha256:a93e3316…`) has now
itself been deleted upstream, so every `kubevirt-csi-driver` image build
fails:

```
#3 ERROR: quay.io/centos/centos:stream9@sha256:a93e3316…: not found
ERROR: failed to solve: … failed to resolve source metadata … not found
make: *** [Makefile:43: image-kubevirt-csi-driver] Error 1
```

This breaks the **Build packages/apps/kubernetes** job on `main` and on
every open PR (e.g. observed on #3383).

Re-pins to the current `stream9` manifest-list digest
`sha256:3714c89f1903dac5a5e1eb6c02d84189ff6d962a0c18df01feba0a5406856926`,
resolved via `docker buildx imagetools inspect
quay.io/centos/centos:stream9`.

> Note: this is the second recurrence of the same class of breakage
(ce4b263 fixed the first). Because `stream9` is a rolling tag, a
pinned digest will keep rotting; a follow-up could track it via renovate
or pin a date-stamped/immutable tag instead. Out of scope here — this
just unblocks CI.

```release-note
NONE
```

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Chores**
* Updated the container build to use a refreshed CentOS Stream 9 base
image.
  * No end-user-facing functionality or behavior changed.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
Base automatically changed from fix/3300-cnpg-barman-plugin to main July 21, 2026 10:45
@androndo
Andrey Kolkov (androndo) marked this pull request as ready for review July 21, 2026 10:54
@dosubot dosubot Bot added the area/database Issues or PRs related to managed databases (postgres, mariadb, redis, etcd, kafka, clickhouse) label Jul 21, 2026
@gemini-code-assist

Copy link
Copy Markdown
Contributor

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request enables point-in-time recovery (PITR) for PostgreSQL backups within the backups.cozystack.io API. By analyzing recovery pod logs, the controller can now detect when a requested recovery time is outside the available WAL archive window, allowing it to fail fast with a clear reason instead of waiting for the default restore timeout. The changes include necessary RBAC updates, expanded e2e test coverage, and updated documentation to guide users through PITR operations.

Highlights

  • Point-in-Time Recovery (PITR) Support: Added support for spec.options.recoveryTime in RestoreJob, allowing users to restore PostgreSQL databases to a specific timestamp.
  • Fail-Fast Mechanism: Implemented a log-based detection system that monitors full-recovery pod logs to identify unreachable recovery targets, preventing the system from hanging until the restore deadline.
  • RBAC Permissions: Updated RBAC rules to grant the controller pods/log access, which is required to inspect recovery pod logs for failure signals.
  • Testing and Verification: Added comprehensive e2e tests in the Chainsaw suite and unit tests for log analysis logic to ensure reliability and prevent regressions.
  • Documentation: Added a new section to the operations guide detailing PITR, recoverable windows, and how to discover restorable time bounds.
New Features

🧠 You can now enable Memory (public preview) to help Gemini Code Assist learn from your team's feedback. This makes future code reviews more consistent and personalized to your project's style. Click here to enable Memory in your admin console.

Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment Gemini (@gemini-code-assist) Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize the Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counterproductive. You can react with 👍 and 👎 on Gemini (@gemini-code-assist) comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution. ↩

@gemini-code-assist gemini-code-assist Bot left a comment

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.

Code Review

This pull request implements point-in-time recovery (PITR) for PostgreSQL databases managed by CNPG. It introduces a fail-fast mechanism that scans bootstrap-recovery pod logs for specific PostgreSQL FATAL errors to quickly identify unreachable recovery targets, preventing restore jobs from hanging. To support this, the controller was updated to use a Kubernetes Clientset, and RBAC permissions were expanded to allow reading pods/log. The changes also include comprehensive documentation, updated test scripts, expanded E2E chainsaw tests, and new unit tests to validate the log-parsing and pod-filtering logic. No review comments were provided, so there is no feedback to address.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 3

🧹 Nitpick comments (1)
docs/operations/backup-classes.md (1)

239-242: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Qualify the firstRecoverabilityPoint observation.

“Unreliable under the barman-cloud plugin” is generalized from one dev7 observation on CNPG 1.28.1 and may become stale or misleading across versions and environments. Mark it as an environment/version-specific caveat and retain the catalog-based procedure as the recommended fallback.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/operations/backup-classes.md` around lines 239 - 242, Update the
“Discovering the earliest / latest restorable time” section to qualify the empty
firstRecoverabilityPoint observation as specific to the dev7 test cluster
running CNPG 1.28.1 with the barman-cloud plugin, rather than presenting it as
generally unreliable. Retain the backup-catalog procedure as the recommended
fallback.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/operations/backup-classes.md`:
- Around line 243-248: Update the backup catalog command to include only
completed backup resources, using the appropriate phase filter before sorting
and displaying STOP. Preserve the existing columns and ordering, and ensure
operators cannot mistake failed or in-progress rows for recoverable backups.
- Around line 231-235: Revise the “Latest” and very-recent recovery guidance in
the backup documentation to avoid implying a fixed seconds-long or
wall-clock-safe margin. State that the restorable boundary depends on the most
recent WAL shipped to object storage, archive lag, segment boundaries, and the
available WAL/catalog state; direct readers to verify that state when selecting
a recoveryTime, while preserving the existing retry and unreachable-target
behavior.

In `@internal/backupcontroller/cnpgstrategy_controller.go`:
- Around line 1480-1499: Update RestoreJobReconciler.readPodContainerLog to
create a derived context with a finite timeout before calling req.Stream, using
the controller’s established timeout constant or configuration if available.
Ensure the derived context is canceled via defer and pass it to Stream so
stalled log retrieval cannot block indefinitely.

---

Nitpick comments:
In `@docs/operations/backup-classes.md`:
- Around line 239-242: Update the “Discovering the earliest / latest restorable
time” section to qualify the empty firstRecoverabilityPoint observation as
specific to the dev7 test cluster running CNPG 1.28.1 with the barman-cloud
plugin, rather than presenting it as generally unreliable. Retain the
backup-catalog procedure as the recommended fallback.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 75939325-a4e7-48f3-94ff-f1259a6a7b69

📥 Commits

Reviewing files that changed from the base of the PR and between e73ad68 and 596ed77.

📒 Files selected for processing (12)
  • docs/operations/backup-classes.md
  • examples/backups/postgres/00-helpers.sh
  • examples/backups/postgres/35-restorejob-in-place.yaml
  • examples/backups/postgres/45-restorejob-pitr.yaml
  • examples/backups/postgres/README.md
  • examples/backups/postgres/cleanup.sh
  • examples/backups/postgres/run-all.sh
  • hack/e2e-chainsaw/postgres/chainsaw-test.yaml
  • internal/backupcontroller/cnpgstrategy_controller.go
  • internal/backupcontroller/cnpgstrategy_controller_test.go
  • internal/backupcontroller/restorejob_controller.go
  • packages/system/backupstrategy-controller/templates/rbac.yaml

Comment thread docs/operations/backup-classes.md Outdated
Comment on lines +243 to +248
```bash
# Completed base backups, oldest first: STOP is the earliest instant that
# backup alone can restore to; the oldest STOP is the window's lower bound.
kubectl -n <ns> get backups.postgresql.cnpg.io \
--sort-by=.status.stoppedAt \
-o custom-columns=NAME:.metadata.name,PHASE:.status.phase,START:.status.startedAt,STOP:.status.stoppedAt,BEGINWAL:.status.beginWal,ENDWAL:.status.endWal

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.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Filter the catalog output to completed backups.

The command lists every cnpg.io/Backup and only prints its phase, despite the surrounding text saying these are completed backups. A failed or in-progress resource with stoppedAt could be mistaken for the earliest recoverable point. Explicitly filter or instruct operators to discard non-completed rows before using STOP.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/operations/backup-classes.md` around lines 243 - 248, Update the backup
catalog command to include only completed backup resources, using the
appropriate phase filter before sorting and displaying STOP. Preserve the
existing columns and ordering, and ensure operators cannot mistake failed or
in-progress rows for recoverable backups.

Comment on lines +1480 to +1499
// readPodContainerLog returns the tail of a pod container's log via the
// clientset (the controller-runtime cache client cannot read the log
// subresource).
func (r *RestoreJobReconciler) readPodContainerLog(ctx context.Context, namespace, podName, container string) (string, error) {
tail := int64(cnpgRecoveryLogTailLines)
req := r.Clientset.CoreV1().Pods(namespace).GetLogs(podName, &corev1.PodLogOptions{
Container: container,
TailLines: &tail,
})
stream, err := req.Stream(ctx)
if err != nil {
return "", err
}
defer stream.Close()
data, err := io.ReadAll(stream)
if err != nil {
return "", err
}
return string(data), nil
}

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.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Bound the pod-log stream with a timeout.

req.Stream(ctx) has no per-call deadline. The reconcile ctx passed in typically carries no timeout unless the caller set one, so a stalled/slow connection to the API server while streaming a recovery pod's log can block this call - and the reconcile goroutine - indefinitely. TailLines bounds response size, not connection duration.

🔧 Proposed fix
 func (r *RestoreJobReconciler) readPodContainerLog(ctx context.Context, namespace, podName, container string) (string, error) {
+	ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
+	defer cancel()
 	tail := int64(cnpgRecoveryLogTailLines)
 	req := r.Clientset.CoreV1().Pods(namespace).GetLogs(podName, &corev1.PodLogOptions{
 		Container: container,
 		TailLines: &tail,
 	})
 	stream, err := req.Stream(ctx)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// readPodContainerLog returns the tail of a pod container's log via the
// clientset (the controller-runtime cache client cannot read the log
// subresource).
func (r *RestoreJobReconciler) readPodContainerLog(ctx context.Context, namespace, podName, container string) (string, error) {
tail := int64(cnpgRecoveryLogTailLines)
req := r.Clientset.CoreV1().Pods(namespace).GetLogs(podName, &corev1.PodLogOptions{
Container: container,
TailLines: &tail,
})
stream, err := req.Stream(ctx)
if err != nil {
return "", err
}
defer stream.Close()
data, err := io.ReadAll(stream)
if err != nil {
return "", err
}
return string(data), nil
}
// readPodContainerLog returns the tail of a pod container's log via the
// clientset (the controller-runtime cache client cannot read the log
// subresource).
func (r *RestoreJobReconciler) readPodContainerLog(ctx context.Context, namespace, podName, container string) (string, error) {
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
tail := int64(cnpgRecoveryLogTailLines)
req := r.Clientset.CoreV1().Pods(namespace).GetLogs(podName, &corev1.PodLogOptions{
Container: container,
TailLines: &tail,
})
stream, err := req.Stream(ctx)
if err != nil {
return "", err
}
defer stream.Close()
data, err := io.ReadAll(stream)
if err != nil {
return "", err
}
return string(data), nil
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/backupcontroller/cnpgstrategy_controller.go` around lines 1480 -
1499, Update RestoreJobReconciler.readPodContainerLog to create a derived
context with a finite timeout before calling req.Stream, using the controller’s
established timeout constant or configuration if available. Ensure the derived
context is canceled via defer and pass it to Stream so stalled log retrieval
cannot block indefinitely.

@androndo
Andrey Kolkov (androndo) force-pushed the feat/2774-postgres-pitr branch 2 times, most recently from 5f3e094 to f6313e2 Compare July 21, 2026 17:06

@IvanHunters IvanHunters left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

LGTM with non-blocking notes

Reviewed the change end-to-end. The core premise (CNPG accumulates multiple
failed bootstrap-recovery pods rather than replacing one in place) is
independently confirmed against upstream cloudnative-pg sources: the
full-recovery bootstrap is a batch/v1 Job with RestartPolicy: Never and a
default BackoffLimit=6, i.e. up to 1 + 6 = 7 coexisting pods, which justifies
the cnpgRecoveryMaxInspectPods=5 / cnpgRecoveryUnreachableMinAttempts=3
constants. Build, vet and tests are clean; the CRD/RBAC/upgrade surface is
purely additive.

MINOR

internal/backupcontroller/cnpgstrategy_controller.go:1435-1437 — Forbidden
guard degrades silently.
When readPodContainerLog returns IsForbidden
(the pods/log grant added by this PR is absent or stripped by an external
policy layer), the code logs at Info level and continues, disabling the
RecoveryTargetUnreachable fail-fast for this and all subsequent reconciles,
with no Event and no dedicated status condition/reason. Kept at MINOR because
the path still terminates via the pre-existing restoreTimeoutSeconds (not
touched by this PR), which sets Ready=False with a PITR hint
(cnpgstrategy_controller.go:490-495) — it is a fallback to prior accepted
behavior, not a new silent hang. Suggestion: after N consecutive Forbidden
reads, set a dedicated status condition or emit a Warning Event.

Makes RestoreJob.spec.options.recoveryTime a genuinely supported, tested
capability for the new backups.cozystack.io API (issue #2774). The
recoveryTime -> CNPG bootstrap.recovery.recoveryTarget.targetTime plumbing
already existed on the barman-cloud-plugin branch; this adds the reliability
and verification layer the issue asked for:

- Fail-fast for an unreachable target: when a RestoreJob has recoveryTime set
  and the recovery Cluster is not healthy, the driver reads the CNPG
  full-recovery pod logs and, once several distinct attempts have exited with
  PostgreSQL's "recovery ended before configured recovery target was reached"
  FATAL, fails the RestoreJob with conditions RecoveryConverged=False /
  Ready=False (reason RecoveryTargetUnreachable) instead of hanging to the
  restore deadline. Health is checked first, so a converged recovery always
  wins; counting distinct FATAL-ing attempts (not pod failures) tolerates a
  transient archive-lag miss on a near-now target and ignores transient
  recovery-pod crashes. Adds the pods/log RBAC grant the log read requires.

- e2e (examples/backups/postgres/run-all.sh, wired into the Chainsaw
  postgres-2-backup-roundtrip suite): step 45 writes a before marker, captures
  the server timestamp, writes an after marker, restores to that timestamp and
  asserts the before row survived and the after row did not; step 46 submits a
  recoveryTime an hour ahead and asserts it fails fast with reason
  RecoveryTargetUnreachable.

- Working, uncommented example (45-restorejob-pitr.yaml) and a docs section
  (docs/operations/backup-classes.md) covering the recoverable window, how to
  discover the earliest/latest restorable time from the backup catalog, the
  archive-lag tolerance, and idempotency under GitOps.

Refs #2774

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Signed-off-by: Andrey Kolkov <[email protected]>
@androndo

Copy link
Copy Markdown
Contributor Author

IvanHunters thanks for the review! Addressed the MINOR note (head 6a1e2d0d0): a Forbidden pods/log read is no longer silent — recoveryTargetUnreachable now flags it, and at the deadline the driver emits a Warning/RecoveryClassificationForbidden Event and appends a note to the failure message that the RecoveryTargetUnreachable diagnosis was unavailable because the pods/log grant is missing (the restore still terminates via restoreTimeoutSeconds as before). The log-read is now injectable, so the classification path — the unreachable-target FATAL match, the no-FATAL case, and the Forbidden branch — is unit-tested (TestRecoveryTargetUnreachable_ClassifiesFromLogs). Note the constants you cited (cnpgRecoveryUnreachableMinAttempts etc.) were from an earlier revision: a slow CI archiver showed a valid near-now target emitting that same FATAL transiently many times before converging, so the count-based early fail-fast was replaced by a deadline-authoritative design — the restore deadline decides "stuck", and the FATAL log is used only to classify the reason at that point.

@IvanHunters IvanHunters left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Solid feature with the right overall shape (additive RBAC verb, safe upgrade path, non-PITR path byte-for-byte unchanged, helper unit tests added). Two things worth addressing before merge:

  1. The core new logic isn't covered by a reconcile-level test. The deadline-exceeded PITR classification branch in reconcileCNPGRestore (internal/backupcontroller/cnpgstrategy_controller.go:924-963) is only exercised through the pure helpers and recoveryTargetUnreachable directly, never through reconcileCNPGRestore itself. Mutation check confirms the gap: swapping RecoveryTargetUnreachable back to the generic RestoreFailed at the call site keeps the whole suite green, so the call site can be silently broken. The readPodLog seam already exists; a reconcile-level test covering the three sub-paths (FATAL found / not found / RBAC Forbidden) would close it.

  2. The "fails fast" wording in the PR body and release note contradicts the code. Classification only runs after time.Since(StartedAt) > deadline, and the code comment plus the new docs/operations/backup-classes.md say so explicitly ("we do NOT try to fail earlier"). With the default restoreTimeoutSeconds the client still waits the full timeout; only the reason/message changes, not the timing. Since the release note ships to the public changelog, it's worth rewording to match the docs.

Review of #3383 found the RecoveryTargetUnreachable classification branch in
reconcileCNPGRestore was only covered through the pure helpers and
recoveryTargetUnreachable directly, never through reconcileCNPGRestore itself —
a mutation swapping the call-site reason back to the generic RestoreFailed kept
the suite green.

Add TestReconcileCNPGRestore_DeadlineClassifiesRecoveryTargetUnreachable: it
backdates StartedAt past the restore deadline with a recoveryTime set and drives
the real reconcile through the readPodLog seam, asserting all three deadline
sub-paths — FATAL present (reason RecoveryTargetUnreachable on both
RecoveryConverged and Ready), FATAL absent (generic timeout, no
RecoveryTargetUnreachable), and a Forbidden log read (generic timeout + the
missing-pods/log-RBAC note). Verified it fails when the call-site reason is
reverted to the generic RestoreFailed.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Signed-off-by: Andrey Kolkov <[email protected]>
@androndo

Copy link
Copy Markdown
Contributor Author

Thanks — both addressed.

1. Reconcile-level coverage of the classification call site. Added TestReconcileCNPGRestore_DeadlineClassifiesRecoveryTargetUnreachable (commit b5d03f8): it backdates StartedAt past the restore deadline with recoveryTime set and drives the real reconcileCNPGRestore through the readPodLog seam, asserting all three sub-paths — FATAL present → reason RecoveryTargetUnreachable on both RecoveryConverged and Ready; FATAL absent → generic timeout (no RecoveryTargetUnreachable); Forbidden log read → generic timeout + the missing-pods/log-RBAC note. Confirmed it closes the mutation gap you flagged: reverting the call site to the generic RestoreFailed now fails the test —

cnpgstrategy_controller_test.go: expected Ready reason=RecoveryTargetUnreachable, got ...Reason:RestoreFailed...

2. "Fails fast" wording. You're right — classification only runs after time.Since(StartedAt) > deadline, so the client waits the full window and only the reason/message change, not the timing. Reworded the PR body and the release-note block accordingly (dropped "fails fast … instead of hanging to the deadline"; now: runs to the deadline, but reports the precise RecoveryTargetUnreachable reason instead of a generic timeout, and points at a shorter restoreTimeoutSeconds for a faster rejection) to match the code and docs/operations/backup-classes.md.

@github-actions github-actions Bot removed the size/XL This PR changes 500-999 lines, ignoring generated files label Jul 22, 2026
@github-actions github-actions Bot added the size/XXL This PR changes 1000+ lines, ignoring generated files label Jul 22, 2026

@coderabbitai coderabbitai Bot left a comment

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.

🧹 Nitpick comments (1)
internal/backupcontroller/cnpgstrategy_controller_test.go (1)

2369-2384: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider asserting the RecoveryClassificationForbidden Warning event.

The PR contract states a forbidden log read emits a Warning event with reason RecoveryClassificationForbidden. This subcase verifies the status message note but never drains record.NewFakeRecorder(10), so a regression that drops the event would go unnoticed. Draining the recorder's Events channel and asserting the reason would close that gap.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/backupcontroller/cnpgstrategy_controller_test.go` around lines 2369
- 2384, The “Forbidden log read” test should also verify the required Warning
event. In the subcase using newReconciler, retain and drain the fake recorder’s
Events channel after reconcileToFailed, then assert that an emitted event has
reason RecoveryClassificationForbidden.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@internal/backupcontroller/cnpgstrategy_controller_test.go`:
- Around line 2369-2384: The “Forbidden log read” test should also verify the
required Warning event. In the subcase using newReconciler, retain and drain the
fake recorder’s Events channel after reconcileToFailed, then assert that an
emitted event has reason RecoveryClassificationForbidden.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 329db766-c289-4dd0-a709-08ef27180042

📥 Commits

Reviewing files that changed from the base of the PR and between 6a1e2d0 and b5d03f8.

📒 Files selected for processing (1)
  • internal/backupcontroller/cnpgstrategy_controller_test.go

@IvanHunters IvanHunters left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Verdict

NOT LGTM

The deadline-exceeded branch in reconcileCNPGRestore fires unconditionally before the cluster-health check, so a restore that has actually converged can be reported Failed/RecoveryTargetUnreachable instead of Succeeded whenever the reconcile that observes it lands late (e.g. a controller restart spanning the restore window) — reproduced with a driving go test against this exact code path.

Findings

[CRITICAL] internal/backupcontroller/cnpgstrategy_controller.go:924-925 vs :966-980 — deadline check precedes and fully bypasses the health check; a converged restore can be misreported Failed, and the existing purge-on-retry logic can then delete the already-recovered data.

reconcileCNPGRestore checks time.Since(restoreJob.Status.StartedAt.Time) > deadline at line 925 and, if true, unconditionally runs the new classification and returns via markRestoreJobFailedReason/markRestoreJobFailed (lines 926-963) — it never falls through to hasRecovery/cnpgClusterHealthy (lines 966-980) in that same call. So if the reconcile that first observes "deadline elapsed" happens after the target Cluster has already reached cnpgClusterHealthyPhase (e.g. the backupstrategy-controller pod was down or its workqueue stalled for longer than restoreTimeoutSeconds while the recovery quietly finished converging), the RestoreJob is marked Failed even though the underlying Postgres cluster is healthy and serving.

I reproduced this with a driven unit test built on the PR's own test harness (TestReconcileCNPGRestore_HealthyClusterSucceeds / TestReconcileCNPGRestore_DeadlineClassifiesRecoveryTargetUnreachable patterns): StartedAt 72h in the past (past the 30m default deadline), the recovery Cluster.Status.Phase = "Cluster in healthy state", one leftover recovery pod whose log still carries the transient FATAL the PR's own body says is normal for a converging near-now target ("a valid near-now target hits the same FATAL transiently ... before it converges", and dev7 observed multiple simultaneous failed recovery pods). Result: Phase="Failed", Ready reason RecoveryTargetUnreachable, message telling the tenant to "pick a recoveryTime inside the recoverable window" — a confident, specific, and wrong diagnosis for a restore that in fact succeeded. (Test was run against this checkout, then removed; not left in the tree.)

This directly contradicts the PR's own design claim ("Health wins unconditionally ... a recovery that converges within the window reaches success and is never classified as failed") — that statement holds only while reconciliation keeps up in real time against the 5s cnpgPollInterval; it does not hold across a reconcile gap that itself exceeds the deadline, which the PR's own "Idempotency under GitOps" docs section explicitly anticipates as a supported case ("a controller restart mid-restore therefore re-attaches to the recovering cluster rather than deleting it and starting over" — true for avoiding a double-purge of the same RestoreJob, but it does not make the health-vs-deadline race safe).

Escalation to data loss: if an operator or automation reacts to the false Failed by resubmitting the restore, the new RestoreJob's purge guard (purgedCondition/freshlyRecovered at lines 786-793, cnpgPurgeNeeded) sees a recovery Cluster created before the new RestoreJob's StartedAt and treats it as "a recovery Cluster left over from an earlier completed restore" that "must be purged" — deleting the already-successfully-restored Cluster and its PVCs to start over.

The ordering itself (deadline check before health check) predates this PR, but this PR is the one that gives that pre-existing race a confident, specific, wrong reason (RecoveryTargetUnreachable) instead of the old generic timeout message, and it is precisely the code this PR extends with the classification feature. Fix: check hasRecovery/cnpgClusterHealthy before (or as a short-circuit inside) the deadline branch, so an already-converged Cluster always reports Succeeded regardless of how late the reconcile discovers it, and add the missing test corner (StartedAt far past deadline and Cluster healthy) as a regression test — none of the new tests cover this combination; all three deadline-branch sub-tests use a Cluster with Status.Phase left empty/unhealthy.

[MINOR] internal/backupcontroller/cnpgstrategy_controller.go:1485 — defer stream.Close() on the new pod-log reader ignores the Close error (golangci-lint errcheck). Low impact (read-only log stream, no cleanup depends on the error), but it's new code from this PR.

[MINOR] packages/system/backupstrategy-controller/tests/ — no helm-unittest assertion covers the new pods/log ClusterRole rule (packages/system/backupstrategy-controller/templates/rbac.yaml:29-35); the chart's test suite has zero RBAC-rule assertions at all, so this doesn't regress existing coverage, but Phase 5d's RBAC-change requirement ("an assertion on the resulting access surface") is unmet.

Claim mismatches

[PARTIAL] "Health wins unconditionally ... a recovery that converges within the window reaches success and is never classified as failed" — true only while the deadline branch is never entered before the healthy branch runs at least once; false across a reconcile gap that itself exceeds the deadline (reproduced, see Findings).

[PARTIAL] Release note: "the failure is now reported with the precise reason RecoveryTargetUnreachable ... instead of a generic timeout" — the added precision can be confidently wrong in the race above, which is a regression in diagnostic trustworthiness relative to the old generic-timeout message, not a strict improvement in every case.

[UNVERIFIABLE] "dev7: backup + WAL archiving + recovery convergence verified live" and "7 simultaneous failed recovery pods" — I have no access to the dev7 cluster to confirm; not blocking on its own since the negative path is separately covered by the CI Chainsaw e2e (step 46), which I confirmed is wired into hack/e2e-chainsaw/postgres/chainsaw-test.yaml with the budget raised 25m to 40m.

Operational risks

  • A rolling upgrade of backupstrategy-controller (including the upgrade that ships this very PR) restarts the controller pod. If that restart lands near the tail end of a live, already-converging PITR restore whose restoreTimeoutSeconds has also elapsed by the time the pod comes back, the first post-restart reconcile can flip an already-successful restore to Failed/RecoveryTargetUnreachable. A short restoreTimeoutSeconds (which the PR's own docs recommend setting for "faster rejection") makes this materially more likely to be hit by ordinary restart jitter, not just extended outages.

Caveats

  • spec.options.recoveryTime has no format validation at ingestion (parseCNPGRestoreOptions, internal/backupcontroller/cnpgstrategy_controller.go:1728-1737, only checks JSON well-formedness). A malformed timestamp (typo, non-RFC3339 string) flows straight through to CNPG's bootstrap.recovery.recoveryTarget.targetTime and burns the full restore deadline before failing with a generic reason instead of being rejected immediately with a clear message. This predates this PR (the recoveryTime plumbing itself is from #3313, untouched by this diff), so it is not counted against this PR, but it sits squarely in the feature this PR is hardening.
  • The new pods/log ClusterRole rule (packages/system/backupstrategy-controller/templates/rbac.yaml:29-35) is cluster-wide with no resourceNames scoping, so this single system controller can now read the log of any pod in any tenant namespace, not just its own recovery pods (Kubernetes RBAC cannot scope get on pods/log to a dynamic pod name set). This is consistent with the controller's existing already-broad cluster-wide grants (pods get/list/watch, apps.cozystack.io: "*", Postgres patch/update, VM update, etc.), so it is not a new category of risk, but it does widen what this one controller can read (pod logs can incidentally contain leaked secrets). Not blocking.
  • Grepped for the pre-existing "deadline check precedes health check" pattern in sibling strategy controllers (etcdstrategy_controller.go:858, foundationdbstrategy_controller.go:923,970, mariadbstrategy_controller.go:541,592) — same shape appears to recur, but these files are untouched by this PR and I did not verify each one has the same consequence; flagged only as a follow-up, not as a finding against this PR.
  • chart_lint is clean (0 render/kubeconform errors) and shell_lint only returned two info-level "not following sourced file" notices from shellcheck on examples/backups/postgres/{cleanup.sh,run-all.sh} — not actionable.
  • Grepped the repo for other consumers hardcoding the literal "RestoreFailed" reason string outside internal/backupcontroller; found none, so the new reason granularity does not appear to break any other in-tree consumer.
  • go build / go vet clean on internal/backupcontroller/...; the full PITR-related unit test suite (TestReconcileCNPGRestore_*, TestRecoveryTargetUnreachable_*, TestRecoveryUnreachableFromLogs, TestRecoveryPodsToInspect, TestLogIndicatesRecoveryTargetUnreachable) passes.

Recommended follow-ups

  • Reorder reconcileCNPGRestore so cnpgClusterHealthy (and hasRecovery) is checked before the deadline-exceeded branch is allowed to mark the RestoreJob Failed, and add the missing regression test (StartedAt far past deadline and Cluster already healthy).
  • Add time.Parse(time.RFC3339, ...) validation for spec.options.recoveryTime at parse time in parseCNPGRestoreOptions, surfaced as a specific Ready=False reason rather than a silent 30-minute wait followed by a generic failure.
  • Add a helm-unittest asserting the new pods/log rule on the backupstrategy-controller ClusterRole.
  • Audit the sibling strategy controllers (etcd/foundationdb/mariadb) for the same deadline-vs-health ordering.
  • The PR body already tracks an unopened cozystack/website docs-mirror follow-up for the new PITR section; no action needed here beyond what's already noted.

Addresses review CHANGES_REQUESTED on #3383.

CRITICAL: reconcileCNPGRestore checked the restore deadline BEFORE the
cluster-health check, so a reconcile that first observes "deadline elapsed"
AFTER the recovery Cluster already reached a healthy state (a controller
restart or stalled workqueue spanning restoreTimeoutSeconds) would mark an
already-converged restore Failed / RecoveryTargetUnreachable. A false Failed
can then escalate to data loss: a resubmitted RestoreJob's purge guard treats
the recovered Cluster as a stale leftover and deletes it + its PVCs.

Fix: check hasRecovery + cnpgClusterHealthy first and return Succeeded
unconditionally when healthy, regardless of how late the reconcile runs; only
a not-yet-healthy restore reaches the deadline/classification branch. Adds
TestReconcileCNPGRestore_HealthyPastDeadlineSucceeds (StartedAt 72h past the
deadline + healthy Cluster + recoveryTime set; the injected readPodLog fails
the test if the classification path is entered, proving health short-circuits).

MINOR: handle the deferred Close error on the pod-log reader (errcheck).
Signed-off-by: Andrey Kolkov <[email protected]>
@androndo

Copy link
Copy Markdown
Contributor Author

IvanHunters thanks — the CRITICAL is a real bug, fixed in ad3f8b248.

[CRITICAL] health-vs-deadline ordering — reordered reconcileCNPGRestore so hasRecovery+cnpgClusterHealthy is checked before the deadline branch: an already-healthy recovery Cluster now returns Succeeded unconditionally, no matter how late the reconcile observes it (controller restart / stalled workqueue spanning restoreTimeoutSeconds), so the false-Failed→resubmit→purge-the-recovered-data escalation can't happen. Added the missing regression corner as TestReconcileCNPGRestore_HealthyPastDeadlineSucceeds: StartedAt 72h past the 30m deadline and Cluster healthy and recoveryTime set — and the injected readPodLog fails the test if the classification path is entered at all, so it proves health short-circuits (it fails on the old ordering).

[MINOR] defer stream.Close() errcheck — fixed (defer func() { _ = stream.Close() }()).

[MINOR] helm-unittest for the pods/log rule — not adding a chart-render assertion here: that grant's contract is already guarded by the e2e, not a static template check. Chainsaw step 46 asserts the failure reason is RecoveryTargetUnreachable, which requires the pods/log read to succeed — if the grant were missing/stripped the read 403s, the reason degrades to the generic timeout, and step 46 fails. A helm-unittest grepping the rendered ClusterRole would be a weaker drift-guard than the e2e that exercises the running plugin↔cluster contract. Happy to add one if you'd still prefer belt-and-suspenders.

RFC3339 validation of recoveryTime — left as a follow-up (you noted it predates this PR). Strict time.Parse(RFC3339) at ingestion would false-reject timestamp forms CNPG/Postgres accept but RFC3339 doesn't (e.g. a space separator instead of T); doing it safely means matching CNPG's own accepted layouts, which is worth its own change rather than folding a possible new false-rejection into this one.

Sibling controllers (etcd/fdb/mariadb) same ordering — agree it's worth auditing; tracking as a separate follow-up since those files are untouched here and each needs its own repro to confirm the same consequence.

Fresh CI is running.

@IvanHunters IvanHunters left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Verdict

LGTM with non-blocking notes

The change is a fail-safe diagnostic layer over an existing deadline-bounded restore path: it adds a precise RecoveryTargetUnreachable failure reason, a pods/log RBAC grant, and reorders the health check before the deadline check; build/vet/tests pass and the reconcile-level regression test is non-vacuous (verified by mutation). Two load-bearing assumptions couple to CNPG/PostgreSQL internals that cannot be verified in a hermetic static review, but both degrade gracefully rather than breaking, so they are Caveats, not blockers.

Findings

None blocking.

Claim mismatches

None. Spot-checks of the load-bearing self-claims held up:

  • "Reconcile-level test fails if the call-site reason is reverted to the generic one" — [OK], confirmed by mutation: reverting internal/backupcontroller/cnpgstrategy_controller.go:1002 (markRestoreJobFailedReason(..., "RecoveryTargetUnreachable", msg) → markRestoreJobFailed(..., msg)) turns TestReconcileCNPGRestore_DeadlineClassifiesRecoveryTargetUnreachable/recovery-target-unreachable_FATAL... RED.
  • "go build / go vet clean; unit tests pass" — [OK], reproduced locally.
  • "adds the pods/log RBAC grant" — [OK], packages/system/backupstrategy-controller/templates/rbac.yaml:34-36 grants pods/log: [get], a scoped additive grant (no wildcard).
  • "Health wins unconditionally, checked before the deadline" — [OK], cnpgstrategy_controller.go:921 health block precedes the deadline block at :939; guarded by TestReconcileCNPGRestore_HealthyPastDeadlineSucceeds (injected readPodLog t.Fatalf if the classification path is entered for a healthy cluster).

Caveats

  • Upgrade path (Phase 5b.A): the only chart change is an additive pods/log grant on the backups.cozystack.io:strategy-controller ClusterRole (rbac.yaml:34-36). No values.yaml / values.schema.json / Chart.yaml / CRD change, so no make generate artifacts and no numbered migration are required, and none were added (correct). RBAC is strictly widened, never contracted — no running tenant loses access. During the brief upgrade window where the new controller image lands before the ClusterRole reconciles, GetLogs returns Forbidden, and the code handles that explicitly (cnpgstrategy_controller.go Forbidden branch → Warning Event + note appended to the failure message, restore still bounded by the deadline). Verified by reading the diff; not exercised on a live upgrade.
  • Fresh install (Phase 5b.B): Clientset is wired in restorejob_controller.go SetupWithManager via kubernetes.NewForConfig(cfg), and all call sites are nil-safe (recoveryTargetUnreachable returns false when Clientset == nil && readPodLog == nil). chart_lint reported 0 render/kubeconform errors. Verified statically.
  • Upstream-string coupling (CNPG/PostgreSQL internals): the classification depends on (a) the recovery container being named full-recovery (cnpgRecoveryContainerName, cnpgstrategy_controller.go:129) and (b) the exact FATAL substring "recovery ended before configured recovery target was reached" (:143). Both are documented as verified against CNPG 1.28.1 / postgresql:18.1 on dev7, but I could not verify them hermetically. Failure mode if either drifts on a future CNPG/PG bump is fail-safe, not breakage: recoveryPodsToInspect returns empty / the FATAL match misses → unreachable=false → the restore still fails at the deadline with the generic timeout reason (no hang-forever, no data loss). The negative e2e (step 46) is the drift detector, as the code comment states.
  • Phase 5c (config-combination matrix): not applicable — no values.yaml toggles and no {{- if/with }} on values were introduced; rbac.yaml is a static template.
  • Live negative-path verification: the PR body discloses that a fully-green live step-46 run was blocked by COSI bucket-provisioning flakiness on dev7, so the RecoveryTargetUnreachable classification was proven only via the pure/reconcile unit tests plus the CI Chainsaw e2e, not a live dev cluster. Accepted as disclosed; the CI Chainsaw suite (hack/e2e-chainsaw/postgres/chainsaw-test.yaml, budget raised 25m→40m) is the standing guard.

Recommended follow-ups

  • Live end-to-end validation of step 45/46 on a disposable dev cluster (via the separate cozystack-pr-test skill) would close the one surface this static review could not exercise: that CNPG 1.28.1 actually names the container full-recovery and emits the matched FATAL, and that the negative case reports RecoveryTargetUnreachable within the 300s restoreTimeoutSeconds. Out of scope for a hermetic review (needs a live cluster + object storage).
  • PITR docs mirror to cozystack/website (already tracked by the author as a follow-up alongside #3313's backup-classes changes).

@androndo
Andrey Kolkov (androndo) merged commit 8aeedde into main Jul 23, 2026
74 of 75 checks passed
@androndo
Andrey Kolkov (androndo) deleted the feat/2774-postgres-pitr branch July 23, 2026 10:15
Andrey Kolkov (androndo) added a commit to cozystack/website that referenced this pull request Jul 24, 2026
Mirrors the new **Point-in-time recovery (PostgreSQL)** section from the
monorepo `docs/operations/backup-classes.md` into the website copies
(`next` and `v1.6`).

Follows up cozystack/cozystack#3383 (PITR support for the
`backups.cozystack.io` RestoreJob API, fixes cozystack/cozystack#2774),
which added the section to the monorepo docs that this site mirrors.
#3313's earlier `backup-classes.md` mirror landed in #622; this is the
PITR delta on top of it.

The section covers:
- setting `spec.options.recoveryTime` (RFC3339) on a `RestoreJob`, with
a worked example;
- the recoverable window (earliest = oldest base backup, latest = last
archived WAL);
- discovering the earliest/latest restorable time from the backup
catalog (since `firstRecoverabilityPoint` is not reliably populated
under the barman-cloud plugin);
- the deadline-bounded failure behavior and the precise
`RecoveryTargetUnreachable` reason for a target past the archive;
- idempotency under GitOps.

Added to `next` and `v1.6` (the release line that already carries the
barman-cloud plugin backup docs). Not added to `v1.5` — PITR is a new
feature on `main`, not part of 1.5. If 1.6 has already been cut without
this feature, drop the `v1.6` file.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Documentation**
* Added guidance for PostgreSQL point-in-time recovery using
`RestoreJob`.
* Documented recovery-time configuration, valid recovery windows, and
example manifests.
* Explained how to identify the earliest and latest restorable
timestamps.
* Clarified failure behavior for unavailable or out-of-range recovery
targets.
  * Documented idempotent restore behavior during GitOps reconciliation.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
xrmtech-isk pushed a commit to katamarina-ru/cozystack-website that referenced this pull request Jul 27, 2026
Mirror the new "Point-in-time recovery (PostgreSQL)" section from the monorepo
docs/operations/backup-classes.md into the website copies (next + v1.6): how to
set spec.options.recoveryTime on a RestoreJob, the recoverable window, how to
discover the earliest/latest restorable time from the backup catalog, the
RecoveryTargetUnreachable failure behavior, and idempotency under GitOps.

Follows up cozystack/cozystack#3383 (PITR support, issue #2774), which added
the section to the monorepo docs the website mirrors.

Signed-off-by: Andrey Kolkov <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/database Issues or PRs related to managed databases (postgres, mariadb, redis, etcd, kafka, clickhouse) area/storage Issues or PRs related to storage (linstor, seaweedfs, bucket, velero, harbor) kind/feature Categorizes issue or PR as related to a new feature size/XXL This PR changes 1000+ lines, ignoring generated files

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Make PostgreSQL Point-in-Time Recovery (PITR) actually work and tested in the new backups API

2 participants