feat(db): bring ad-hoc dump/restore on-box as rig db (Closes #15) #18

Merged
dan-claude-bot merged 2 commits from feat/rig-db into main 2026-07-17 15:55:17 +00:00
dan-claude-bot commented 2026-07-17 15:17:09 +00:00 (Migrated from github.com)

Closes #15.

Plan

Bring ad-hoc PostgreSQL dump/restore on-box as rig db subcommands — the imperative, interactive counterpart to rig coolify backup install (which is the scheduled, declarative, forensics-only path). This is the "give me a copy of that database right now" / "put this artifact back" verb an operator reaches for by hand, and it conceptually retires cast's off-box restore-db.sh: the restore logic now lives with the box that runs the containers, connecting as the container's own superuser instead of assuming a role name from off-box.

Key design decisions:

  • --no-owner --no-acl on every dump (mandatory). A cross-instance restore runs as the target's superuser, and Coolify randomizes that role per database. A plain dump carries ALTER OWNER/GRANT statements naming the source's role, which does not exist on the target — under ON_ERROR_STOP=1 that aborts the entire restore on the first such statement. Stripping ownership/ACLs makes the dump describe data and schema, portable onto any instance. Dumps also use --clean --if-exists so a re-restore is idempotent.
  • Container-own superuser, never hardcoded postgres. $POSTGRES_USER/$POSTGRES_DB are read inside the container via a single-quoted sh -c '...' — the container's own environment, not the host's. On the next container a hardcoded postgres is simply the wrong role.
  • Confirm gate on restore. Restore overwrites the target in place, so it prompts y/N; --yes (or --force) is the automation bypass. Artifact existence/non-emptiness is validated before the prompt and before anything touches the DB — and, being a pure filesystem check, before the root/docker guards too, so a fat-fingered path fails cheaply and is unit-testable without root.
  • [db] arg for shared containers. The optional third arg targets a named database in a shared Postgres (e.g. a umami database alongside others). It is passed into the container as an env var (RIG_TARGET_DB) rather than spliced into the command string; empty falls back to the container's own $POSTGRES_DB.
  • No truncated-but-valid artifacts. dump runs under pipefail (a failing pg_dump otherwise exits 0 through the pipe and gzip faithfully compresses the truncated output into a valid-looking .gz), writes to a sibling temp promoted only on success, and refuses to keep an empty artifact. restore runs with ON_ERROR_STOP=1 so a bad restore fails loudly instead of half-applying and reporting success.

House style: args validated before the root check; log/warn/die helpers (usage errors exit 2, refusals/runtime exit 1); Debian-family warn sourcing /etc/os-release only in a subshell; guards are root, docker, and gzip/gunzip.

What changed

  • commands/db.sh (new) — dump and restore subcommands with a shared guard block. docker exec (dump) / docker exec -i (restore, for the stdin pipe).
  • bin/rig — new db command dispatching dump/restore to commands/db.sh; bad/missing subcommand → usage on stderr, exit 2. Added a db stanza to the usage() heredoc.
  • test/cli.sh — assertions in the house style: bare db → usage exit 2; db --help exit 0; bad subcommand exit 2; missing/too many args exit 2; unknown flag exit 2; non-root refusals; a nonexistent-artifact restore failing before the docker/root path; and regression greps pinning the embedded command strings (--no-owner --no-acl, container-own $POSTGRES_USER/$POSTGRES_DB, ON_ERROR_STOP=1).
  • README.md — a ### rig db section covering the imperative-vs-declarative contrast, the --no-owner --no-acl rationale, container-own-superuser, the confirm gate, and the [db] shared-container arg.

Testing

  • bash test/cli.sh82 passed, 0 failed.
  • shellcheck -x over bin/* + **/*.shclean (exit 0).

The runtime docker exec dump/restore paths (a live pg_dump/psql against a real container) are covered by the end-to-end rehearsal, not unit tests — the dependency-free harness cannot fabricate a running Postgres container or root. What the unit tests do cover is that arg validation, the confirm gate's bypass, and the artifact pre-check are all reachable and correct before that runtime path, and that the mandatory dump/restore flags stay embedded.

Closes #15. ## Plan Bring ad-hoc PostgreSQL dump/restore on-box as `rig db` subcommands — the **imperative, interactive** counterpart to `rig coolify backup install` (which is the scheduled, declarative, forensics-only path). This is the "give me a copy of that database right now" / "put this artifact back" verb an operator reaches for by hand, and it conceptually retires cast's off-box `restore-db.sh`: the restore logic now lives with the box that runs the containers, connecting as the container's own superuser instead of assuming a role name from off-box. Key design decisions: - **`--no-owner --no-acl` on every dump (mandatory).** A cross-instance restore runs as the *target's* superuser, and Coolify randomizes that role per database. A plain dump carries `ALTER OWNER`/`GRANT` statements naming the *source's* role, which does not exist on the target — under `ON_ERROR_STOP=1` that aborts the entire restore on the first such statement. Stripping ownership/ACLs makes the dump describe *data and schema*, portable onto any instance. Dumps also use `--clean --if-exists` so a re-restore is idempotent. - **Container-own superuser, never hardcoded `postgres`.** `$POSTGRES_USER`/`$POSTGRES_DB` are read *inside* the container via a single-quoted `sh -c '...'` — the container's own environment, not the host's. On the next container a hardcoded `postgres` is simply the wrong role. - **Confirm gate on restore.** Restore overwrites the target in place, so it prompts `y/N`; `--yes` (or `--force`) is the automation bypass. Artifact existence/non-emptiness is validated *before* the prompt and before anything touches the DB — and, being a pure filesystem check, before the root/docker guards too, so a fat-fingered path fails cheaply and is unit-testable without root. - **`[db]` arg for shared containers.** The optional third arg targets a *named* database in a shared Postgres (e.g. a `umami` database alongside others). It is passed *into* the container as an env var (`RIG_TARGET_DB`) rather than spliced into the command string; empty falls back to the container's own `$POSTGRES_DB`. - **No truncated-but-valid artifacts.** dump runs under `pipefail` (a failing `pg_dump` otherwise exits 0 through the pipe and `gzip` faithfully compresses the truncated output into a valid-looking `.gz`), writes to a sibling temp promoted only on success, and refuses to keep an empty artifact. `restore` runs with `ON_ERROR_STOP=1` so a bad restore fails loudly instead of half-applying and reporting success. House style: args validated before the root check; `log/warn/die` helpers (usage errors exit 2, refusals/runtime exit 1); Debian-family warn sourcing `/etc/os-release` only in a subshell; guards are root, docker, and gzip/gunzip. ## What changed - **`commands/db.sh`** (new) — `dump` and `restore` subcommands with a shared guard block. `docker exec` (dump) / `docker exec -i` (restore, for the stdin pipe). - **`bin/rig`** — new `db` command dispatching `dump`/`restore` to `commands/db.sh`; bad/missing subcommand → usage on stderr, exit 2. Added a `db` stanza to the `usage()` heredoc. - **`test/cli.sh`** — assertions in the house style: bare `db` → usage exit 2; `db --help` exit 0; bad subcommand exit 2; missing/`too many` args exit 2; unknown flag exit 2; non-root refusals; a nonexistent-artifact restore failing *before* the docker/root path; and regression greps pinning the embedded command strings (`--no-owner --no-acl`, container-own `$POSTGRES_USER`/`$POSTGRES_DB`, `ON_ERROR_STOP=1`). - **`README.md`** — a `### rig db` section covering the imperative-vs-declarative contrast, the `--no-owner --no-acl` rationale, container-own-superuser, the confirm gate, and the `[db]` shared-container arg. ## Testing - `bash test/cli.sh` → **82 passed, 0 failed**. - `shellcheck -x` over `bin/*` + `**/*.sh` → **clean** (exit 0). The runtime `docker exec` dump/restore paths (a live pg_dump/psql against a real container) are covered by the end-to-end rehearsal, not unit tests — the dependency-free harness cannot fabricate a running Postgres container or root. What the unit tests *do* cover is that arg validation, the confirm gate's bypass, and the artifact pre-check are all reachable and correct before that runtime path, and that the mandatory dump/restore flags stay embedded.
dan-claude-bot commented 2026-07-17 15:49:13 +00:00 (Migrated from github.com)

Added executable verification that dump/restore actually round-trips (not just arg parsing).

test/db-integration.sh — a real Docker-backed probe. It stands up two throwaway Postgres containers whose superusers differ by construction (src_super vs dst_super), seeds a known checksummable table, runs the real rig db dump and rig db restore, then reads the rows back out and compares an ordered md5 fingerprint + row count. That proves both invariants the design rests on:

  • the code reads the container’s OWN $POSTGRES_USER/$POSTGRES_DB — a hardcoded postgres would fail immediately against the non-default source superuser;
  • --no-owner --no-acl makes the dump portable across instances whose superusers differ — a plain dump would abort under ON_ERROR_STOP=1 on the missing role.

It also asserts default-outfile naming, restore idempotency (--clean --if-exists → a second restore yields the same 3 rows, no duplication), and the named-[db] scratch-database path. It skips cleanly (exit 0) when Docker is absent/unreachable or root is unobtainable, and always cleans up containers + artifact via a trap with a unique $$-based name prefix.

CI — new db-integration job on ubuntu-latest (Docker preinstalled + passwordless sudo, so it executes for real, not skip), kept separate from the fast check job so an image pull can’t slow lint feedback.

README — a "Verifying a dump/restore actually works" subsection: the safe manual round-trip against a real Coolify container via a fresh scratch db (createdb … rig_verify → restore → spot-check → dropdb), echoing "a backup you have never read back is not yet a backup."

Ran it locally against real Postgres containers — 12 passed, 0 failed (green round-trip, fingerprint a8ddf579… survived src→dst and both re-restores). test/cli.sh still 82 passed / 0 failed; shellcheck clean including the new file.

Added executable verification that dump/restore actually round-trips (not just arg parsing). **`test/db-integration.sh`** — a real Docker-backed probe. It stands up two throwaway Postgres containers whose superusers differ by construction (`src_super` vs `dst_super`), seeds a known checksummable table, runs the real `rig db dump` and `rig db restore`, then reads the rows back out and compares an ordered md5 fingerprint + row count. That proves both invariants the design rests on: - the code reads the container’s OWN `$POSTGRES_USER`/`$POSTGRES_DB` — a hardcoded `postgres` would fail immediately against the non-default source superuser; - `--no-owner --no-acl` makes the dump portable across instances whose superusers differ — a plain dump would abort under `ON_ERROR_STOP=1` on the missing role. It also asserts default-outfile naming, restore idempotency (`--clean --if-exists` → a second restore yields the same 3 rows, no duplication), and the named-`[db]` scratch-database path. It **skips cleanly (exit 0)** when Docker is absent/unreachable or root is unobtainable, and **always cleans up** containers + artifact via a `trap` with a unique `$$`-based name prefix. **CI** — new `db-integration` job on `ubuntu-latest` (Docker preinstalled + passwordless sudo, so it executes for real, not skip), kept separate from the fast `check` job so an image pull can’t slow lint feedback. **README** — a "Verifying a dump/restore actually works" subsection: the safe manual round-trip against a real Coolify container via a fresh scratch db (`createdb … rig_verify` → restore → spot-check → `dropdb`), echoing "a backup you have never read back is not yet a backup." Ran it locally against real Postgres containers — **12 passed, 0 failed** (green round-trip, fingerprint `a8ddf579…` survived src→dst and both re-restores). `test/cli.sh` still 82 passed / 0 failed; shellcheck clean including the new file.
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: heavy-duty/rig#18
No description provided.