Skip to content

feat(backup): scheduled object-storage snapshot module - #1020

Merged
RonaldHensbergen merged 7 commits into
mainfrom
feat/669-object-storage-backup
Oct 30, 2026
Merged

RonaldHensbergen merged 7 commits into
mainfrom
feat/669-object-storage-backup

Conversation

@RonaldHensbergen

@RonaldHensbergen RonaldHensbergen commented Oct 28, 2026 •

Copy link
Copy Markdown
Owner

Closes #669.

Summary

  • New experimental object-storage-backup module (modules-experimental/backup/object-storage): scheduled, immutable, UTC-timestamped snapshot copies of an object-storage contract's bucket to a separately configured S3-compatible destination (rclone copy, not sync, with --immutable), so source overwrites and deletions never erase earlier snapshots. Timestamped copies stand in for the S3 object versioning Garage does not implement.
  • Follows the backup-module conventions from feat: add generic file/volume backup capability (Dagster IO manager, DuckDB, dlt) #668: production-only (service carries production: true; instantiating outside production fails E143), digest-pinned image, hardened service (non-root, read-only rootfs, cap_drop: ALL, no-new-privileges, pids limit, tmpfs /tmp), separate least-privilege destination credentials, and a source/destination sameness check. Every successful pass ends by writing a .cds-snapshot-complete marker into its timestamped prefix, so a prefix left by a failed pass is recognizable as partial; failed passes retry with a fresh timestamp after a configurable delay (retryDelaySeconds) instead of crash-looping. A destination configured as literally the bound source endpoint and bucket fails planning with a new E147 diagnostic before anything boots; the service's start-time guard remains as a defense-in-depth backstop.
  • Explicit productionSuitable: false (plaintext snapshots, operator-managed restore/retention), so the security scan raises CDS-SEC-073 — production use requires a scoped runtime-policy waiver, documented in the README and docs/backup-restore.md. Each pass is a full copy of the current bucket contents, so destination storage grows linearly with the number of retained snapshots.
  • Wiring tests cover the config schema, snapshot semantics, hardening, credential placeholder rendering, required contract binding, E143 outside production, E147 for same-source destinations, and the production security pass (CDS-SEC-073 accepted, nothing else unexpected).
  • Docker-gated runtime proof (tests/test_object_storage_backup_runtime.py, runs in the Docker Runtime Smoke Test workflow): boots two real Garage instances as source and destination, seeds a marker object, waits for the scheduled snapshot under its immutable timestamped prefix, overwrites the source object, and recovers it byte-for-byte from the snapshot prefix with the documented rclone copy procedure. The same cycle was verified manually against real Garage instances before committing.

Validation

  • python -m unittest tests.test_object_storage_backup_module -v (10 tests)
  • python -m unittest tests.test_object_storage_backup_runtime -v — skips locally without CDS_RUN_DOCKER_SMOKE=1; the harness phases (render, boot, health, seed) were validated locally, the full proof runs in CI (this daemon cannot exec no-new-privileges containers, the same limitation documented on fix(backup): one restic snapshot per pass so multi-source restores are complete #1018)
  • Full offline suite: 1940 tests, 44 Docker-gated skips, 1 pre-existing local-environment SQLAlchemy error (cds-runtime extra not installed locally; skips cleanly in CI)
  • yaml.safe_load on the new module.yaml and the workflow; markdownlint on all touched docs
  • Manual end-to-end proof against real Garage instances: snapshot layout <prefix>/<bucket>/<timestamp>/, overwrite, byte-for-byte restore

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe vibe@mistral.ai

Protect the object-storage module's own bucket contents (#663/#669): the
experimental object-storage-backup module consumes the object-storage
contract and copies the bucket to a separately configured S3-compatible
destination on a schedule, each pass into a new immutable, UTC-timestamped
prefix (rclone copy, not sync, --immutable), so source overwrites and
deletions never erase earlier snapshots. Timestamped copies stand in for
the S3 object versioning Garage does not implement.

Follows the backup-module conventions landed with #668: production-only
(service renders only in production, E143 outside), digest-pinned image,
hardened service (non-root, read-only rootfs, cap_drop ALL,
no-new-privileges, pids limit, tmpfs /tmp). The module is explicitly
productionSuitable: false -- plaintext snapshots, operator-managed
restore and retention -- so the security scan raises CDS-SEC-073, which
production use must waive explicitly. Destination credentials are
separate least-privilege secrets; source and destination must differ.

Wiring tests cover the schema, snapshot semantics, hardening, credential
placeholder rendering, E143 outside production, and the security pass
(CDS-SEC-073 accepted, nothing else unexpected).

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
Add the Docker-gated end-to-end proof for the object-storage-backup
module, mirroring the restic proof (#668): render a production profile
with two Garage instances (source and destination) and the backup
module, boot both, seed a marker object, wait for the scheduled snapshot
to land under its immutable UTC-timestamped prefix, overwrite the source
object, and recover it byte-for-byte by copying the timestamped prefix
back with rclone -- the exact recovery procedure the module documents.

Runs in the Docker Runtime Smoke Test workflow (new step + cleanup
entry); skips locally without CDS_RUN_DOCKER_SMOKE=1. The full cycle was
additionally verified manually against real Garage instances with the
module's exact command and env configuration: snapshot layout
<prefix>/<bucket>/<timestamp>/, overwrite, and byte-for-byte restore.

Two harness details found while wiring the proof: build_plan and
render_compose both need the env file so secret aliases resolve to
${CDS_*} compose placeholders, and the bind source directory must be
world-traversable for the Docker daemon.

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>

@RonaldHensbergen RonaldHensbergen left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Review pass on this PR — verdict: looks good. Conventions match #668, and all checks are green including the Docker-gated runtime proof (seed -> scheduled snapshot -> overwrite -> byte-for-byte recovery). Findings below, none blocking.

What's solid

  • rclone copy (not sync) + --immutable + a fresh UTC-timestamped prefix per pass is the right primitive for Garage's missing object versioning: source overwrites and deletions can never touch earlier snapshots.
  • Hardening parity with the restic module: digest-pinned image, non-root, read-only rootfs, cap_drop: ALL, no-new-privileges, pids limit, tmpfs /tmp; separate least-privilege destination credentials.
  • Honest productionSuitable: false with the CDS-SEC-073 waiver path documented, and the security test pins that no other finding sneaks in for this module.
  • Tests assert the rendered output carries only ${CDS_*} placeholders (no secret material), E143 outside production, and the required contract binding.
  • The workflow step and cleanup match the restic-proof pattern.

Findings

  1. (medium) Partial prefixes after a failed pass. rclone copy failing mid-run (destination outage, network blip beyond rclone's internal --retries) leaves a partial timestamped prefix, and the destination credentials intentionally cannot delete it. On restart the retry writes a new timestamp, so the partial prefix survives as the newest recovery point — an operator restoring "latest" can silently restore a partial bucket. Since the creds can PUT, a cheap fix is a completion-marker object (e.g. <prefix>/<bucket>/<timestamp>/.cds-snapshot-complete) written after a pass succeeds, with the recovery procedure saying to restore only from prefixes carrying the marker. At minimum, document that the newest prefix may be partial.

  2. (low) Crash-retry has no backoff. sh -ec makes any failed pass exit, and restart: unless-stopped re-runs the entrypoint immediately — each restart takes a fresh snapshot right away. A persistently flaky source/destination therefore produces one partial prefix per crash cycle (amplifies finding 1). rclone's built-in retries absorb most transient blips, so this is minor; a short sleep before exiting on failure would tame the loop.

  3. (low) The sameness guard is lexical, not semantic. It compares literal endpoint strings plus bucket names, so the same Garage reachable under a different hostname/port (or http vs https of the same host) passes the guard. The README already tells operators to prefer a different backend and host, so best-effort is defensible — maybe one README line stating the guard is a string comparison.

  4. (low, docs) The documented restore clobbers current data. The recovery procedure copies a timestamped prefix straight back over the source bucket; the runtime test itself proves this silently overwrites newer objects (V2 replaced by V1). The README's recovery section could suggest a --dry-run first or restoring into a scratch bucket/prefix before overwriting live data.

  5. (medium, concrete) .gitignore misses the generated proof compose file. tests/test_object_storage_backup_runtime.py writes docker-compose.objstore-proof.yml at the repo root, but .gitignore lists only docker-compose.vault-proof.yml, docker-compose.restore-proof.yml, and docker-compose.restic-proof.yml. If the test is killed before its addCleanup unlink runs, the file lingers as untracked and risks an accidental commit. Add docker-compose.objstore-proof.yml next to the other three.

  6. (nit) The finally: pass block at the end of test_snapshot_recovers_overwritten_object is dead code — drop it.

  7. (nit) The interval path is never exercised by CI: the proof covers the first immediate snapshot, but the sleep "${SNAPSHOT_INTERVAL_HOURS}h" suffix parsing (busybox) only runs from the second pass on. Fine if the manual end-to-end proof covered a second pass; otherwise a one-off local run with intervalHours: 1 closes the gap.

- Every successful snapshot pass now writes a .cds-snapshot-complete
  marker object into its timestamped prefix, so a prefix left behind by
  a failed pass is recognizable as partial and never restored from.
- A failed pass logs the error, keeps its partial prefix in place, and
  retries with a fresh timestamp after five minutes instead of
  crash-looping through the restart policy.
- The interval sleep is arithmetic seconds, removing the shell-specific
  duration-suffix path CI never exercised.
- README: document the completion marker and restore-only-from-complete
  rule, the dry-run/scratch advice for restores, and that the
  sameness guard is a literal string comparison.
- .gitignore: cover the generated docker-compose.objstore-proof.yml like
  the other proof compose files.
- Tests: assert the marker, backoff, and arithmetic sleep in both the
  module and rendered compose; the runtime proof only accepts a
  snapshot once its completion marker has landed; drop the dead
  try/finally wrapper in the runtime test.

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
@RonaldHensbergen

Copy link
Copy Markdown
Owner Author

All seven review findings are addressed in 5c05bfa:

  1. Partial prefixes: every successful pass now writes a .cds-snapshot-complete marker object into its timestamped prefix (written last, after the copy); the README's recovery procedure says to restore only from marked prefixes, and the runtime proof only accepts a snapshot once the marker has landed.
  2. Backoff: a failed pass logs the error, leaves its marker-less partial prefix in place, and retries with a fresh timestamp after five minutes inside the loop -- no more crash-restart cycling.
  3. Sameness guard: the README now states the guard is a literal string comparison and that the destination choice remains operator responsibility.
  4. Restore safety: the recovery section recommends --dry-run or restoring into a scratch bucket/prefix before overwriting live data, and mentions excluding the marker on restore.
  5. .gitignore now lists docker-compose.objstore-proof.yml alongside the other proof compose files.
  6. The dead finally: pass wrapper is gone.
  7. The interval sleep is now arithmetic seconds ($((SNAPSHOT_INTERVAL_HOURS * 3600))), removing the busybox duration-suffix path CI never exercised; verified the arithmetic parses and runs in the actual rclone image's busybox ash, and that rclone rcat exists in 1.74.0.

Local validation: module suite 8/8, full offline suite 1938 tests OK, markdownlint/yamllint/ruff clean. The full Docker proof still needs the CI runner (this daemon cannot exec init/no-new-privileges containers, per #1018).

RonaldHensbergen and others added 2 commits October 29, 2026 22:32
…1020)

- New runtime proof: with the destination Garage down, a pass fails
  without exiting the service (restart count stays 0), logs the
  marker-less failure, and retries on the configured delay until the
  destination is reachable -- the completing pass uses a fresh
  timestamp that differs from the failed ones.
- New runtime proof: a destination configured as literally the source
  endpoint and bucket makes the service exit 1 with the guard's
  message instead of copying a bucket into itself.
- The backoff is now the retryDelaySeconds config (default 300), so
  the retry proof runs in CI with a 10-second delay instead of paying
  a five-minute wait.
- Module tests assert the new config's schema default, rendered env,
  and command usage; the runtime harness gains shared prepare, log,
  and container-state helpers.

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
- The failure proof now uses wrong destination credentials against a
  running destination: rclone does not retry auth failures, so passes
  fail fast instead of riding out a transient outage with internal
  retries (the destination-down variant never produced a failed pass;
  the copy spanned the outage and completed into its original
  timestamp). The proof asserts two failed passes with fresh
  timestamps on the configured delay, restart count 0, no completion
  marker anywhere on the destination, and nothing written at all.
- Fix the container-state probe: RestartCount is a top-level inspect
  field, not under .State -- the guard proof polled an invalid template
  and saw 'unknown' forever. The guard's exit-1 crash-refusal itself
  was verified to work.
- Both scenarios were reproduced locally against the rendered stack
  (with only the daemon-blocking no-new-privileges line stripped)
  before this fix: fresh-timestamp failures every retry delay,
  empty destination, and exit 1 with the guard's message.

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
@RonaldHensbergen

Copy link
Copy Markdown
Owner Author

Gap closure follow-up (the "not exercised by any automated test" items from the review): all three are now covered.

Failure branch (in CI, 7fd3564 + e500311): a new runtime proof boots the stack with the destination running but the module's destination credentials wrong -- rclone does not retry auth failures, so passes fail fast and deterministically. It asserts: at least two failed passes with fresh timestamps on the configured retryDelaySeconds, restart count 0 (the service stays up -- no crash-restart loop), no pass ever logs "complete", and the destination listing stays completely empty (the wrong credentials cannot write a completion marker, or anything else). The first attempt used a destination-down outage, which taught us something useful: rclone's internal --retries ride out a transient outage (the copy spanned ~2m20s of downtime and completed into its original timestamp), so the module's in-loop backoff only engages for genuinely non-transient failures. That behavior is correct (blips absorbed, hard failures retried without crashing) and is now pinned by the proof. The marker-less partial prefix after a mid-copy failure remains inspection-covered only -- staging a deterministic mid-copy failure needs a large bucket and a timed kill, which is not CI-suitable.

Sameness guard (in CI, 7fd3564 + e500311): a new runtime proof renders a profile whose destination is literally the source endpoint and bucket, boots it, and asserts the service exits with code 1 and logs the guard's message (it crash-loops under restart: unless-stopped, which is the designed fail-fast). The first CI run failed only because the test's inspect template used .State.RestartCount; RestartCount is a top-level field. Fixed and verified.

Interval path (local simulation, not CI): CI cannot wait out the 1-hour minimum interval, so the loop-after-success path was proven locally against the rendered stack (with only this daemon's blocking no-new-privileges line stripped) with the interval set to 0: ~190 consecutive successful passes, each writing its timestamped prefix and its .cds-snapshot-complete marker, with the arithmetic sleep $((SNAPSHOT_INTERVAL_HOURS * 3600)) executing between every pass, and the seeded content recovered byte-for-byte from a late timestamp. One incidental observation: while the service snapshotted once per second, full-tree rclone lsf --recursive listings against Garage intermittently came back empty until the burst settled (per-prefix listings were always correct). At the module's real cadence this is unreachable, and the restore procedure only ever lists a single timestamp prefix, but it is worth knowing if anyone builds tooling that walks the whole snapshot tree right after a pass.

Validation: full offline suite 1938 tests OK, module suite 8/8, markdownlint/yamllint/ruff clean; CI green on e500311 including the Docker Runtime Smoke Test, which ran all three object-storage proofs in 37s.

RonaldHensbergen and others added 2 commits October 30, 2026 11:31
#1020)

Second review pass follow-ups:

- New E147 plan-stage diagnostic: a backup module that configures its
  object-storage destination as literally the bound source endpoint and
  bucket now fails planning before anything boots, instead of booting a
  service that restart-loops on its start-time guard. The in-container
  guard stays as a defense-in-depth backstop for compose files run
  without the CLI; the runtime same-endpoint proof moves to an offline
  plan-stage test (trailing slash covered).
- The retry test name no longer claims exponential backoff (the module
  uses a fixed configured delay).
- README and docs/backup-restore.md now state that every pass is a full
  copy (destination storage grows linearly with retained snapshots) and
  document E147 in the backup error-code table.

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
@sonarqubecloud

Copy link
Copy Markdown

@RonaldHensbergen
RonaldHensbergen merged commit 751fa72 into main Oct 30, 2026
14 checks passed
@RonaldHensbergen
RonaldHensbergen deleted the feat/669-object-storage-backup branch October 30, 2026 11:32
RonaldHensbergen added a commit that referenced this pull request Oct 30, 2026
Resolves the PR review findings on #75 in one pass:

- E147 renumbered to E148: main (via #1020) already uses E147 for the
  object-storage backup same-destination guard; two unrelated
  diagnostics must not share a code.
- External-egress/ingress escape hatch: modules can declare custom
  NetworkPolicy allow rules via
  implementation.kubernetes.networkPolicy.ingress/.egress, rendered
  verbatim after the binding-derived rules, for destinations no binding
  can name (an external API a job reads from, a cross-namespace ingress
  controller). Malformed shapes fail closed with E149 (plan-file bypass
  of the module schema), and CDS-K8S-008 records every external-egress
  rule as a low finding so intentional openings stay visible in the
  security scan.
- The contract's Service-facing port is now mapped through the
  provider's Service targetPort, so a `port: X, targetPort: Y` remap
  can no longer silently break a port-scoped rule.
- CDS-K8S-007's recommendation now matches the behavior: an external
  ingress controller needs explicit networkPolicy.ingress rules because
  the generated policies deny it by default.
- Documented the network-isolation baseline in docs/kubernetes.md
  (default-deny posture, binding-derived allows, same-module rationale,
  DNS egress, NodePort/LB exposure, the passthrough and its security
  visibility), updated the stale "Network policy not emitted" boundary
  row, added E148/E149/W128 to the README troubleshooting table, and
  extended the CHANGELOG entry.
- W129 (binding to an unrendered provider) was considered and rejected
  as dead code: E072 already fails the whole Helm render for any plan
  module lacking a Kubernetes implementation, so the silent-skip
  cannot occur.

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: add backup/replication for the object-storage module's own bucket contents

1 participant