forked from heavy-duty/rig
Add `rig db dump <container> [outfile]` and `rig db restore <artifact> <container> [db] [--yes]` — imperative on-box PostgreSQL tooling, the interactive counterpart to the scheduled, declarative `coolify backup install`. Key decisions: - Dumps carry `--clean --if-exists --no-owner --no-acl`. `--no-owner --no-acl` is mandatory for cross-instance restores: the target's superuser differs (Coolify randomizes it), so a plain dump aborts under ON_ERROR_STOP=1 on the first GRANT/ALTER OWNER for a missing role. - $POSTGRES_USER/$POSTGRES_DB are read INSIDE the container (single-quoted `sh -c`), never hardcoded to `postgres` on the host. - restore connects as the container's own superuser and runs with ON_ERROR_STOP=1; the optional [db] arg targets a NAMED database in a shared container, passed in via a container env var rather than string splicing. - restore overwrites the target, so it prompts y/N; --yes/--force is the automation bypass. Artifact existence/non-emptiness is checked before the confirm gate and before anything touches the DB. - dump uses pipefail + a sibling temp promoted only on success, and refuses to keep an empty artifact — a failed pg_dump must never leave a plausible-looking .gz behind. Args are validated before the root check (testable without root); guards are root, Debian-family warn, docker, and gzip/gunzip. Adds CLI tests and a `### rig db` README section. Closes #15 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
181 lines
8 KiB
Bash
Executable file
181 lines
8 KiB
Bash
Executable file
#!/usr/bin/env bash
|
|
# rig db — ad-hoc PostgreSQL dump/restore for the containers running on THIS box.
|
|
#
|
|
# Imperative on-box tooling, deliberately: this is the "I need a copy of that
|
|
# database right now" / "put this artifact back" verb an operator reaches for by
|
|
# hand. It is the counterpart to `coolify backup install`, which is the
|
|
# scheduled, declarative, forensics-only path — this one is interactive, targets
|
|
# any container, and (for restore) overwrites live data behind a confirm gate.
|
|
#
|
|
# Two rules run through everything below and are non-negotiable:
|
|
# * $POSTGRES_USER / $POSTGRES_DB are read INSIDE the container (that is why
|
|
# every pg_dump/psql lives in a SINGLE-quoted `sh -c '...'` — the container's
|
|
# own environment, not the host's). Coolify randomizes the superuser per
|
|
# database, so the host must never hardcode `postgres`; a hardcoded role is
|
|
# simply wrong on the next container.
|
|
# * dumps carry `--no-owner --no-acl`. Without them a cross-instance restore
|
|
# aborts under ON_ERROR_STOP=1 the moment psql hits a GRANT/ALTER OWNER for
|
|
# a role that does not exist on the target (source and target superusers
|
|
# differ by construction). The dump must describe *data and schema*, not the
|
|
# source box's role graph.
|
|
set -euo pipefail
|
|
|
|
log() { printf 'rig-db: %s\n' "$*"; }
|
|
warn() { printf 'rig-db: WARNING: %s\n' "$*" >&2; }
|
|
die() { printf 'rig-db: ERROR: %s\n' "$1" >&2; exit "${2:-1}"; }
|
|
|
|
usage() {
|
|
cat <<'EOF'
|
|
usage: rig db <dump|restore> ...
|
|
|
|
dump <container> [outfile]
|
|
Dump the PostgreSQL database inside <container> to a gzipped SQL file.
|
|
When [outfile] is omitted, writes <container>-<UTC-timestamp>.sql.gz in
|
|
the current directory. The dump is taken with --clean --if-exists
|
|
--no-owner --no-acl, so it restores cleanly onto a different instance
|
|
whose superuser differs from this one's.
|
|
|
|
restore <artifact> <container> [db] [--yes]
|
|
Restore a gzipped SQL artifact into <container>, connecting as the
|
|
container's OWN superuser. [db] targets a NAMED database in a shared
|
|
container (e.g. a `umami` database in a shared Postgres); omit it to
|
|
restore into the container's default database. Restore OVERWRITES the
|
|
target and prompts before doing so — pass --yes (or --force) to run
|
|
non-interactively.
|
|
EOF
|
|
}
|
|
|
|
# --- common guards ----------------------------------------------------------
|
|
# Called AFTER arg validation (arg errors must stay testable without root).
|
|
require_common_guards() {
|
|
[ "$(id -u)" -eq 0 ] || die "must run as root"
|
|
if [ -r /etc/os-release ]; then
|
|
# Sourced in a subshell — /etc/os-release defines VERSION and would clobber
|
|
# a caller's variables (see test/cli.sh's regression check).
|
|
local OS_FAMILY
|
|
# shellcheck source=/dev/null
|
|
OS_FAMILY="$(. /etc/os-release && printf '%s %s' "${ID:-}" "${ID_LIKE:-}")"
|
|
case "$OS_FAMILY" in
|
|
*debian*) ;;
|
|
*) warn "not a Debian-family system (${OS_FAMILY:-unknown}); proceeding anyway" ;;
|
|
esac
|
|
else
|
|
warn "cannot read /etc/os-release; proceeding anyway"
|
|
fi
|
|
# dump/restore only make sense on a box actually running the containers.
|
|
command -v docker >/dev/null \
|
|
|| die "docker not found — rig db operates on containers running on this box"
|
|
}
|
|
|
|
# --- db dump ----------------------------------------------------------------
|
|
cmd_dump() {
|
|
local container="" outfile="" tmp
|
|
local -a pos=()
|
|
while [ $# -gt 0 ]; do
|
|
case "$1" in
|
|
-h|--help) usage; exit 0 ;;
|
|
--*|-*) die "unknown flag: $1" 2 ;;
|
|
*) pos+=("$1"); shift ;;
|
|
esac
|
|
done
|
|
|
|
# Args first, so these errors are reachable without docker or root.
|
|
[ "${#pos[@]}" -le 2 ] || die "dump takes at most <container> [outfile]" 2
|
|
container="${pos[0]:-}"
|
|
outfile="${pos[1]:-}"
|
|
[ -n "$container" ] || die "dump needs a container name" 2
|
|
# Mirror coolify-dump.sh's timestamp style for the default name.
|
|
[ -n "$outfile" ] || outfile="${container}-$(date -u +%Y%m%dT%H%M%SZ).sql.gz"
|
|
|
|
require_common_guards
|
|
command -v gzip >/dev/null || die "gzip not found (part of coreutils on Debian)"
|
|
|
|
log "dumping database in container ${container} -> ${outfile}"
|
|
# Write to a sibling temp and promote on success, so a failed dump never
|
|
# leaves a plausible-looking artifact in place of a real one. Same directory
|
|
# as the target keeps the final mv atomic.
|
|
tmp="$(mktemp "${outfile}.XXXXXX")" || die "could not create a temp file next to ${outfile}"
|
|
trap 'rm -f "$tmp"' EXIT
|
|
|
|
# pipefail (set above) is load-bearing: without it a failing pg_dump still
|
|
# exits 0 through the pipe and gzip faithfully compresses the truncated output
|
|
# into a valid .gz that looks exactly like a good backup. The `sh -c` is
|
|
# single-quoted on purpose — $POSTGRES_USER/$POSTGRES_DB are the CONTAINER's.
|
|
# shellcheck disable=SC2016
|
|
if ! docker exec "$container" \
|
|
sh -c 'pg_dump -U "$POSTGRES_USER" --clean --if-exists --no-owner --no-acl "$POSTGRES_DB"' \
|
|
| gzip > "$tmp"; then
|
|
die "pg_dump failed — no artifact written"
|
|
fi
|
|
# Even an empty dump gzips to a ~20-byte VALID file; refuse to keep one.
|
|
[ -s "$tmp" ] || die "refusing to keep an empty dump artifact"
|
|
mv "$tmp" "$outfile"
|
|
trap - EXIT
|
|
log "wrote ${outfile} ($(stat -c %s "$outfile") bytes)"
|
|
}
|
|
|
|
# --- db restore -------------------------------------------------------------
|
|
cmd_restore() {
|
|
local artifact="" container="" target_db="" assume_yes=0 reply=""
|
|
local -a pos=()
|
|
while [ $# -gt 0 ]; do
|
|
case "$1" in
|
|
--yes|--force) assume_yes=1; shift ;;
|
|
-h|--help) usage; exit 0 ;;
|
|
--*|-*) die "unknown flag: $1" 2 ;;
|
|
*) pos+=("$1"); shift ;;
|
|
esac
|
|
done
|
|
|
|
# Args first: required-arg errors are exit 2 and reachable without root.
|
|
[ "${#pos[@]}" -le 3 ] || die "restore takes at most <artifact> <container> [db]" 2
|
|
artifact="${pos[0]:-}"
|
|
container="${pos[1]:-}"
|
|
target_db="${pos[2]:-}"
|
|
[ -n "$artifact" ] || die "restore needs an artifact file" 2
|
|
[ -n "$container" ] || die "restore needs a target container" 2
|
|
# Artifact existence/non-emptiness is checkable without docker or root, so we
|
|
# check it HERE — a fat-fingered path fails clearly and cheaply, before the
|
|
# confirm gate and before anything touches the database.
|
|
[ -e "$artifact" ] || die "artifact not found: ${artifact}"
|
|
[ -f "$artifact" ] || die "artifact is not a regular file: ${artifact}"
|
|
[ -s "$artifact" ] || die "artifact is empty: ${artifact}"
|
|
|
|
require_common_guards
|
|
command -v gunzip >/dev/null || die "gunzip not found (part of coreutils on Debian)"
|
|
|
|
# Destructive: restore overwrites the target database in place. Default to
|
|
# prompting; --yes/--force is the automation bypass.
|
|
if [ "$assume_yes" -eq 0 ]; then
|
|
printf 'rig-db: restore OVERWRITES the database in container %s%s. Continue? [y/N] ' \
|
|
"$container" "${target_db:+ (database ${target_db})}" >&2
|
|
read -r reply || reply=""
|
|
case "$reply" in
|
|
y|Y|yes|YES|Yes) ;;
|
|
*) die "aborted — no changes made" ;;
|
|
esac
|
|
fi
|
|
|
|
log "restoring ${artifact} into container ${container}${target_db:+ (database ${target_db})}"
|
|
# Connect as the container's OWN superuser ($POSTGRES_USER); never hardcode
|
|
# postgres. RIG_TARGET_DB carries the optional [db] arg INTO the container so
|
|
# a shared Postgres can be restored into a NAMED database; empty falls back to
|
|
# the container's own $POSTGRES_DB. ON_ERROR_STOP=1 aborts on the first error
|
|
# rather than limping to a half-applied restore and reporting success.
|
|
# shellcheck disable=SC2016
|
|
if ! gunzip -c "$artifact" \
|
|
| docker exec -i -e RIG_TARGET_DB="$target_db" "$container" \
|
|
sh -c 'psql -U "$POSTGRES_USER" -d "${RIG_TARGET_DB:-$POSTGRES_DB}" -v ON_ERROR_STOP=1'; then
|
|
die "restore failed — the database may be partially applied; inspect container ${container}"
|
|
fi
|
|
log "restore complete"
|
|
}
|
|
|
|
# --- dispatch ---------------------------------------------------------------
|
|
sub="${1:-}"
|
|
case "$sub" in
|
|
dump) shift; cmd_dump "$@" ;;
|
|
restore) shift; cmd_restore "$@" ;;
|
|
-h|--help) usage; exit 0 ;;
|
|
*) usage >&2; exit 2 ;;
|
|
esac
|