feat: back up and restore the release-storage volume, not just Postgres
backup.sh now also streams the fiscal-hub container's release binaries (dist tarball, never in Postgres) into BACKUP_DIR alongside the pg_dump, with the same 14-backup retention; restore_releases.sh is the companion restore. Verified with a real restore smoke test in production: the restored dump's agent_releases row and the restored tarball's exe both match the published sha256 exactly. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
+14
-2
@@ -1,6 +1,8 @@
|
|||||||
#!/usr/bin/env sh
|
#!/usr/bin/env sh
|
||||||
# PostgreSQL backup of the GTI Fiscal Hub (custom format, restorable with restore.sh). Run from deploy/hub.
|
# PostgreSQL + release-storage backup of the GTI Fiscal Hub (restorable with
|
||||||
# ./backup.sh -> ./backups/gti_fiscal_YYYYmmdd_HHMMSS.dump (last 14 kept)
|
# restore.sh + restore_releases.sh). Run from deploy/hub.
|
||||||
|
# ./backup.sh -> ./backups/gti_fiscal_YYYYmmdd_HHMMSS.dump (Postgres, last 14 kept)
|
||||||
|
# -> ./backups/gti_fiscal_releases_YYYYmmdd_HHMMSS.tgz (release binaries + AgentRelease metadata is IN the dump above)
|
||||||
set -eu
|
set -eu
|
||||||
COMPOSE="docker compose -f docker-compose.prod.yml --env-file .env"
|
COMPOSE="docker compose -f docker-compose.prod.yml --env-file .env"
|
||||||
DIR="${BACKUP_DIR:-./backups}"
|
DIR="${BACKUP_DIR:-./backups}"
|
||||||
@@ -13,3 +15,13 @@ $COMPOSE exec -T postgres pg_dump -U gti -d gti_fiscal -Fc -f "/backups/${FILE}"
|
|||||||
# backup directory as root, so a host-side rm would fail from the 15th dump.
|
# backup directory as root, so a host-side rm would fail from the 15th dump.
|
||||||
$COMPOSE exec -T postgres sh -c 'ls -1t /backups/gti_fiscal_*.dump | tail -n +15 | xargs -r rm -f'
|
$COMPOSE exec -T postgres sh -c 'ls -1t /backups/gti_fiscal_*.dump | tail -n +15 | xargs -r rm -f'
|
||||||
echo "backup ok: $DIR/$FILE"
|
echo "backup ok: $DIR/$FILE"
|
||||||
|
|
||||||
|
# Release storage (central/app/release_storage.py: every published Agent
|
||||||
|
# package zip/exe, never in Postgres — only its metadata is, in the dump
|
||||||
|
# above). Streamed out of the fiscal-hub container's stdout so this works
|
||||||
|
# regardless of the compose project/container name.
|
||||||
|
RELEASES_FILE="gti_fiscal_releases_${STAMP}.tgz"
|
||||||
|
$COMPOSE exec -T fiscal-hub tar -C /app/data -cz releases > "$DIR/$RELEASES_FILE"
|
||||||
|
[ -s "$DIR/$RELEASES_FILE" ] || { echo "release backup empty or missing: $DIR/$RELEASES_FILE" >&2; exit 1; }
|
||||||
|
find "$DIR" -maxdepth 1 -name 'gti_fiscal_releases_*.tgz' -type f -printf '%T@ %p\n' 2>/dev/null | sort -rn | tail -n +15 | cut -d' ' -f2- | xargs -r rm -f
|
||||||
|
echo "release storage backup ok: $DIR/$RELEASES_FILE"
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
# Restore the release-storage volume (backup.sh's *.tgz) into the Hub.
|
||||||
|
# DESTRUCTIVE for the current release binaries: asks for confirmation. The
|
||||||
|
# AgentRelease metadata (version/sha256/status) lives in Postgres, not here —
|
||||||
|
# restore that separately with restore.sh from a dump taken around the same time.
|
||||||
|
# ./restore_releases.sh ./backups/gti_fiscal_releases_20260923_224551.tgz
|
||||||
|
set -eu
|
||||||
|
[ $# -eq 1 ] || { echo "usage: $0 <releases_backup.tgz>" >&2; exit 2; }
|
||||||
|
FILE="$(basename "$1")"
|
||||||
|
COMPOSE="docker compose -f docker-compose.prod.yml --env-file .env"
|
||||||
|
[ -s "${BACKUP_DIR:-./backups}/$FILE" ] || { echo "backup not found in ${BACKUP_DIR:-./backups}: $FILE" >&2; exit 1; }
|
||||||
|
printf "This REPLACES the current release-storage contents with %s. Type RESTORE to continue: " "$FILE"
|
||||||
|
read -r answer
|
||||||
|
[ "$answer" = "RESTORE" ] || { echo "aborted"; exit 1; }
|
||||||
|
$COMPOSE exec -T fiscal-hub sh -c 'rm -rf /app/data/releases && mkdir -p /app/data/releases'
|
||||||
|
$COMPOSE exec -T fiscal-hub tar -C /app/data -xz < "${BACKUP_DIR:-./backups}/$FILE"
|
||||||
|
echo "restore ok: $FILE"
|
||||||
@@ -29,8 +29,21 @@ def test_hub_port_restriction_uses_docker_user_chain():
|
|||||||
|
|
||||||
|
|
||||||
def test_backup_retention_does_not_delete_on_the_host():
|
def test_backup_retention_does_not_delete_on_the_host():
|
||||||
# Docker creates the bind-mounted backup directory as root, so a host-side
|
# The Postgres dump is written by pg_dump INSIDE the postgres container
|
||||||
# rm by the deploy user fails once retention kicks in.
|
# into the bind-mounted (root-owned) backup dir, so only that container
|
||||||
|
# can rm it. The release-storage tarball is different: backup.sh writes
|
||||||
|
# it itself via host-side shell redirection (`> "$DIR/..."`), so it's
|
||||||
|
# host-owned and a host-side rm is correct there, not a bug.
|
||||||
lines = [line.strip() for line in (HUB / "backup.sh").read_text(encoding="utf-8").splitlines() if not line.strip().startswith("#")]
|
lines = [line.strip() for line in (HUB / "backup.sh").read_text(encoding="utf-8").splitlines() if not line.strip().startswith("#")]
|
||||||
retention = [line for line in lines if "rm -f" in line]
|
retention = [line for line in lines if "rm -f" in line]
|
||||||
assert retention and all(line.startswith("$COMPOSE exec -T postgres") for line in retention)
|
assert len(retention) == 2
|
||||||
|
assert retention[0].startswith("$COMPOSE exec -T postgres") and "gti_fiscal_*.dump" in retention[0]
|
||||||
|
assert retention[1].startswith("find \"$DIR\"") and "gti_fiscal_releases_*.tgz" in retention[1]
|
||||||
|
|
||||||
|
|
||||||
|
def test_backup_also_covers_release_storage():
|
||||||
|
# Part H: PostgreSQL alone isn't enough — every published Agent package
|
||||||
|
# binary lives only on the release-storage volume (never in Postgres).
|
||||||
|
script = (HUB / "backup.sh").read_text(encoding="utf-8")
|
||||||
|
assert "fiscal-hub" in script and "/app/data" in script and "releases" in script
|
||||||
|
assert "gti_fiscal_releases_" in script
|
||||||
|
|||||||
Reference in New Issue
Block a user