Repository navigation
feat(backup): scheduled object-storage snapshot module - #1020
Conversation
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
left a comment
There was a problem hiding this comment.
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(notsync) +--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: falsewith 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
-
(medium) Partial prefixes after a failed pass.
rclone copyfailing 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. -
(low) Crash-retry has no backoff.
sh -ecmakes any failed pass exit, andrestart: unless-stoppedre-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 shortsleepbefore exiting on failure would tame the loop. -
(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.
-
(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-runfirst or restoring into a scratch bucket/prefix before overwriting live data. -
(medium, concrete)
.gitignoremisses the generated proof compose file.tests/test_object_storage_backup_runtime.pywritesdocker-compose.objstore-proof.ymlat the repo root, but.gitignorelists onlydocker-compose.vault-proof.yml,docker-compose.restore-proof.yml, anddocker-compose.restic-proof.yml. If the test is killed before itsaddCleanupunlink runs, the file lingers as untracked and risks an accidental commit. Adddocker-compose.objstore-proof.ymlnext to the other three. -
(nit) The
finally: passblock at the end oftest_snapshot_recovers_overwritten_objectis dead code — drop it. -
(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 withintervalHours: 1closes 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>
|
All seven review findings are addressed in 5c05bfa:
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 |
…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>
|
Gap closure follow-up (the "not exercised by any automated test" items from the review): all three are now covered. Failure branch (in CI, Sameness guard (in CI, 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 Validation: full offline suite 1938 tests OK, module suite 8/8, markdownlint/yamllint/ruff clean; CI green on |
#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>
|
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>



Closes #669.
Summary
object-storage-backupmodule (modules-experimental/backup/object-storage): scheduled, immutable, UTC-timestamped snapshot copies of anobject-storagecontract's bucket to a separately configured S3-compatible destination (rclone copy, notsync, with--immutable), so source overwrites and deletions never erase earlier snapshots. Timestamped copies stand in for the S3 object versioning Garage does not implement.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-completemarker 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.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.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 documentedrclone copyprocedure. 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 withoutCDS_RUN_DOCKER_SMOKE=1; the harness phases (render, boot, health, seed) were validated locally, the full proof runs in CI (this daemon cannot execno-new-privilegescontainers, the same limitation documented on fix(backup): one restic snapshot per pass so multi-source restores are complete #1018)yaml.safe_loadon the new module.yaml and the workflow; markdownlint on all touched docs<prefix>/<bucket>/<timestamp>/, overwrite, byte-for-byte restoreGenerated by Mistral Vibe.
Co-Authored-By: Mistral Vibe vibe@mistral.ai