Skip to content

feat(selfhost): add a backup restore/validation drill (verify-backup.sh) - #2010

Merged
JSONbored merged 3 commits into
mainfrom
claude/selfhost-backup-verify
Jul 1, 2026
Merged

feat(selfhost): add a backup restore/validation drill (verify-backup.sh)#2010
JSONbored merged 3 commits into
mainfrom
claude/selfhost-backup-verify

Conversation

@JSONbored

@JSONbored JSONbored commented Jul 1, 2026

Copy link
Copy Markdown
Owner

Part of #2011.

Summary

The backup profile writes Postgres pg_dump / SQLite backups, but nothing verified they are actually restorable — a backup you can't restore is not a backup. This adds verify-backup.sh, an on-demand drill that checks the newest backup (or a specific file) without touching the live database, plus an opt-in scratch restore.

What changed

  • scripts/verify-backup.sh (new):
    • Postgres .dumppg_restore --list must parse the archive and find a non-empty table of contents.
    • SQLite .sqlite.gzgzip -t integrity + PRAGMA integrity_check on a temp copy.
    • Opt-in scratch restore (VERIFY_RESTORE_SCRATCH=1 + GITTENSORY_VERIFY_SCRATCH_DATABASE_URL): restores the dump into a throwaway database and sanity-counts tables. It refuses to run when the scratch URL is empty or equals the live backup source, so a destructive pg_restore --clean cannot hit production by accident.
    • Accepts an explicit backup path, or auto-selects the newest for the active DB type.
  • docker-compose.yml — mounts the script into the backup service (sh /verify-backup.sh) and passes the opt-in scratch env through.
  • Docs — a "Verify a backup is restorable" section on the backup/scaling page, including what a healthy run looks like and the guarded scratch-restore recipe.

Why

Goal #1 of the Postgres reliability hardening (#2011): give operators a safe, documented way to prove backups restore, prioritized as the smallest high-value slice.

Validation

  • test/unit/selfhost-verify-backup-script.test.ts (new, 10 cases; fake pg_restore/psql/sqlite3, real gzip): Postgres validation pass/fail, no-dump, the two scratch guards (missing URL / equals live), the scratch happy path, explicit-file mode, and the SQLite integrity/gzip failures.
  • npm run db:migrations:check, npx tsc --noEmit, npm run ui:lint, npm run ui:typecheck — all clean.

UI Evidence

Docs-only content addition to an existing page (docs.self-hosting-backup-scaling.tsx) — no new component, styling, or layout change; uses the existing CodeBlock/Callout primitives, and passes ui:lint + ui:typecheck. Rendered copy is the new "Verify a backup is restorable" section shown in the diff. Happy to attach a rendered screenshot if you'd like one.

Notes

First slice of #2011, kept deliberately narrow. pg_restore/psql are already installed in the backup image (postgresql16-client); no image change needed.

@dosubot dosubot Bot added the size:L label Jul 1, 2026
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jul 1, 2026

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Preview URL Updated (UTC)
✅ Deployment successful!
View logs
gittensory-ui 7efaf83 Commit Preview URL

Branch Preview URL
Jul 01 2026, 09:13 AM

@loopover-orb

loopover-orb Bot commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

Warning

🟨🟨🟨🟨🟨🟨🟨🟨🟨🟨🟨🟨

⏸️ Gittensory review result - manual review recommended

Review updated: 2026-07-01 09:23:46 UTC

4 files · 1 AI reviewer · no blockers · readiness 98/100 · CI green · clean

⏸️ Suggested Action - Manual Review

  • Touches a guarded path — held for manual review

Review summary
This adds an on-demand backup verification script, compose wiring, docs, and focused unit coverage for Postgres and SQLite backup validation. The basic archive and integrity checks are coherent, but the destructive scratch restore guard is incomplete in explicit-file mode when no live source URL is configured. In that reachable case, the script proceeds to `pg_restore --clean` without proving the scratch target differs from the live database.

Blockers

  • scripts/verify-backup.sh:79 and scripts/verify-backup.sh:93 let `VERIFY_RESTORE_SCRATCH=1` proceed when `PG_DB` is empty or non-Postgres, so an explicit dump plus a scratch URL can run `pg_restore --clean` with no live-source identity comparison.
Nits — 6 non-blocking
  • nit: scripts/verify-backup.sh:107 exits 0 when `sqlite3` is missing after only `gzip -t`, which is weaker than the documented SQLite `integrity_check` verification.
  • nit: test/unit/selfhost-verify-backup-script.test.ts:123 names the Postgres case as newest-backup selection but only creates one dump, so it does not prove the `ls -1t` selection behavior.
  • nit: test/unit/selfhost-verify-backup-script.test.ts should cover the empty-TOC and zero-restored-tables failure branches in scripts/verify-backup.sh:32 and scripts/verify-backup.sh:97.
  • In scripts/verify-backup.sh:42, require `PG_DB` to be a Postgres URL before any scratch restore and fail closed if it is missing, then run the identity comparison before line 93.
  • Add a regression test with `VERIFY_RESTORE_SCRATCH=1`, an explicit dump path, `GITTENSORY_VERIFY_SCRATCH_DATABASE_URL` set, and no live source URL to prove the guarded path refuses to restore.
  • Touches a guarded path — held for manual review — A maintainer must review and merge this change.
Signal Result Evidence
Code review ✅ No blockers 1 reviewer
Linked issue ✅ No-issue rationale PR body explains why no issue is linked.
Related work ✅ No active overlap found No same-issue or scoped active PR overlap found.
Change scope ✅ 20/20 Low review scope from cached public metadata (size label size:L; no linked issue context).
Validation posture ✅ 25/25 PR body includes validation/test evidence.
Contributor workload ✅ 10/10 Author activity: 68 registered-repo PR(s), 58 merged, 202 issue(s).
Contributor context ✅ Confirmed Gittensor contributor JSONbored; Gittensor profile; 68 PR(s), 202 issue(s).
Gate result ⚠️ Not blocking Advisory; not blocking this PR.
Review context
  • Author: JSONbored
  • Role context: owner (maintainer lane)
  • Public audience mode: oss maintainer
  • Lane context: Repository registration is not available in the local Gittensory cache.
  • Public profile languages: not available
  • Official Gittensor activity: 68 PR(s), 202 issue(s).
  • PR-specific overlap: none found.
Contributor next steps
  • Treat this as maintainer-lane context rather than normal contributor-lane activity.
  • No action.
  • Link the issue being solved, or explicitly explain why this is a no-issue PR.
Signal definitions
  • Related work = same linked issue, overlapping active PRs, or title/path similarity.
  • Change scope = cached public metadata such as size labels, draft state, and review-burden hints.
  • Validation posture = whether the PR provides enough public validation/test evidence for maintainer review.
  • Contributor workload = public contributor activity and cleanup pressure, not a repo-wide quality failure.
  • Contributor context = public GitHub/Gittensor identity context; non-Gittensor status is not a blocker.

🟩 Safe / merged · 🟦 Advisory · 🟨 Held for review · 🟥 Blocked / closed


💰 Earn for open-source contributions like this. Gittensor lets GitHub contributors earn for the work they already do — register to start earning →.

Checked by Gittensory, a quiet PR intelligence layer for OSS maintainers.

  • Re-run Gittensory review

@loopover-orb loopover-orb Bot added gittensor gittensor:feature Gittensor-scored feature linked to a feature issue — scores a 0.25x multiplier. labels Jul 1, 2026
@JSONbored JSONbored linked an issue Jul 1, 2026 that may be closed by this pull request
4 tasks
@JSONbored

Copy link
Copy Markdown
Owner Author

Fixed. Root cause was worse than a missing package: `docker compose run --rm backup sh /verify-backup.sh` replaces `command:`, not `entrypoint:`, and the old entrypoint (`/bin/sh -c`) folded the apk install into `command:` — so an override skipped the install AND, because `-c`'s extra args become the nested shell's $0/$1 rather than a script to run, it collapsed into a bare interactive `sh` that just hangs reading stdin. (Confirmed live with real `docker compose run` — it hung until killed. The pre-existing documented `sh /backup.sh` one-shot had the identical bug.)

Fix: the install now lives in the entrypoint, ending in `exec "$@"`, with a placeholder `sh` entrypoint arg so the real payload (default loop or a `run` override) lands in `"$@"` starting at $1 — mirrors the inline entrypoint-script pattern this file already uses for grafana.

Verified against real Docker, not just reasoned about:

  • `docker compose --profile backup up -d backup` → normal backup.sh cycle completes (`[backup] complete …`).
  • `docker compose run --rm backup sh /verify-backup.sh` → now actually executes (previously hung).
  • Seeded a dummy Postgres dump and confirmed `pg_restore` is present and invoked (`pg_restore --list` reports its own parse error on the fake dump, not "command not found").

New commit: 24d76af.

Comment thread scripts/verify-backup.sh
@superagent-security superagent-security Bot added the pr:flagged PR flagged for review by security analysis. label Jul 1, 2026
@codecov

codecov Bot commented Jul 1, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 95.71%. Comparing base (704cebc) to head (7efaf83).
✅ All tests successful. No failed tests found.

Additional details and impacted files
@@           Coverage Diff           @@
##             main    #2010   +/-   ##
=======================================
  Coverage   95.71%   95.71%           
=======================================
  Files         222      222           
  Lines       24620    24620           
  Branches     8936     8936           
=======================================
  Hits        23565    23565           
  Misses        432      432           
  Partials      623      623           
🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

JSONbored added 3 commits July 1, 2026 02:08
The backup profile writes Postgres pg_dump / SQLite backups but nothing checked
they are restorable. Add scripts/verify-backup.sh, run on demand via the backup
service, which verifies the newest backup (or an explicit file) WITHOUT touching
the live database:
  - Postgres .dump: `pg_restore --list` must parse the archive and find a
    non-empty table of contents.
  - SQLite .sqlite.gz: gzip integrity + `PRAGMA integrity_check` on a temp copy.
  - Opt-in scratch restore (VERIFY_RESTORE_SCRATCH=1 + a dedicated scratch DB
    URL): restores the dump into a throwaway database and sanity-counts tables.
    It refuses to run when the scratch URL is empty or equals the live source,
    so a destructive restore cannot hit production by accident.

Mounts the script into the backup service and passes the scratch env through;
documents the drill (and what a healthy run looks like) in the backup docs page.
Covered by test/unit/selfhost-verify-backup-script.test.ts (fake pg_restore /
psql / sqlite3, real gzip): validation pass/fail, the scratch guards, the
scratch happy path, explicit-file mode, and the SQLite integrity/gzip failures.
… packages

`docker compose run --rm backup sh /verify-backup.sh` REPLACES the service's
`command:`, not its `entrypoint:`. The old entrypoint (`/bin/sh -c`) took the
package-install as part of `command:`, so an on-demand run skipped it entirely —
worse, since `-c`'s extra positional args become the nested shell's $0/$1 rather
than a script to execute, the override became a bare interactive `sh` that just
hangs reading stdin (confirmed with a live `docker compose run`; the documented
`sh /backup.sh` one-shot had the exact same pre-existing bug).

Move the package install into the entrypoint and end it with `exec "$@"`, with a
placeholder `sh` entrypoint arg so the real payload — the default loop or a
`run` override — lands in "$@" starting at $1, not $0. Mirrors the inline
entrypoint-script pattern this file already uses for grafana.

Verified against real Docker: `docker compose --profile backup up -d backup`
still completes its normal backup.sh cycle, and `docker compose run --rm backup
sh /verify-backup.sh` now actually executes (previously hung) with pg_restore
present and invoked (confirmed via a seeded dummy dump producing pg_restore's
own parse error, not "command not found").
…ection URLs

`[ "$scratch" = "$PG_DB" ]` compared connection strings byte-for-byte, so a
scratch URL that is a DIFFERENTLY-SPELLED equivalent of the live backup source
(postgresql:// vs postgres://, a host alias, an explicit vs default port) would
pass the guard and let `pg_restore --clean` drop objects in the live database.

Replace the string compare with an identity check: ask Postgres itself for
`current_database() || '@' || pg_control_system().system_identifier` on both
connections and compare THAT. system_identifier is a random 64-bit value fixed
for the life of a cluster's data directory, independent of how the connection
was dialed, and PUBLIC has EXECUTE on pg_control_system() by default (no
privilege escalation needed) — verified against a real non-superuser role.

Deliberately NOT network-address-based (e.g. inet_server_addr()): testing
against real Postgres showed the same server can report different addresses
across connections over different address families (IPv6 vs IPv4 loopback),
which would have reopened a false "these differ" negative — the wrong
direction for a safety guard. Any failure to fingerprint either side now
aborts (fails closed) rather than assuming the databases differ.

Validated two ways:
- test/unit/selfhost-verify-backup-script.test.ts: a fake psql keyed by
  connection string simulates two differently-spelled URLs resolving to the
  SAME database (the exact reported bypass) plus fail-closed cases when either
  identity query fails.
- A real end-to-end run against Postgres 16 (matching the backup image):
  refuses a byte-identical URL, refuses a differently-spelled equivalent of the
  same database, and correctly allows + completes a restore into a genuinely
  different database on the same cluster — with the live database's table
  confirmed untouched throughout.
@JSONbored
JSONbored force-pushed the claude/selfhost-backup-verify branch from 24d76af to 7efaf83 Compare July 1, 2026 09:11
@JSONbored

Copy link
Copy Markdown
Owner Author

Confirmed valid, fixed. `[ "$scratch" = "$PG_DB" ]` compared connection strings byte-for-byte, so a differently-spelled equivalent (`postgresql://` vs `postgres://`, a host alias, explicit vs default port) would slip past the guard.

Replaced the string compare with an identity check: ask Postgres itself for `current_database() || '@' || pg_control_system().system_identifier` on both connections and compare that. `system_identifier` is a random 64-bit value fixed for the life of a cluster's data directory — independent of how the connection was dialed — and PUBLIC has EXECUTE on `pg_control_system()` by default (verified against a real non-superuser role, no privilege escalation needed).

I deliberately avoided a network-address-based fingerprint (e.g. `inet_server_addr()`): testing against a real Postgres 16 instance showed the SAME server can report different addresses across connections over different address families (IPv6 `::1` vs IPv4 `127.0.0.1` loopback) — a false "these differ" that would have reopened a gap in the wrong direction for a safety guard. Any failure to fingerprint either side now aborts (fails closed).

Validated two ways:

  • Unit tests: a fake `psql` keyed by connection string simulates two differently-spelled URLs resolving to the SAME database (the exact bypass reported) plus fail-closed cases when either identity query fails.
  • Real end-to-end run against Postgres 16 (matching the backup image): refuses a byte-identical URL, refuses a differently-spelled equivalent of the same database, and correctly allows + completes a restore into a genuinely different database on the same cluster — with the live database's table confirmed untouched throughout.

New commit: 7efaf83.

@superagent-security superagent-security Bot removed the pr:flagged PR flagged for review by security analysis. label Jul 1, 2026
@JSONbored
JSONbored merged commit a52c3b0 into main Jul 1, 2026
15 checks passed
@JSONbored
JSONbored deleted the claude/selfhost-backup-verify branch July 1, 2026 09:27
@github-project-automation github-project-automation Bot moved this from Todo to Done in gittensory - v1 roadmap Jul 1, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

gittensor:feature Gittensor-scored feature linked to a feature issue — scores a 0.25x multiplier.

Projects

No open projects
Status: Done

Development

Successfully merging this pull request may close these issues.

Postgres reliability/scale hardening (post-cutover)

1 participant