diff --git a/deploy/hub/backup.sh b/deploy/hub/backup.sh index a41d5b9..3d09555 100644 --- a/deploy/hub/backup.sh +++ b/deploy/hub/backup.sh @@ -1,6 +1,8 @@ #!/usr/bin/env sh -# PostgreSQL backup of the GTI Fiscal Hub (custom format, restorable with restore.sh). Run from deploy/hub. -# ./backup.sh -> ./backups/gti_fiscal_YYYYmmdd_HHMMSS.dump (last 14 kept) +# PostgreSQL + release-storage backup of the GTI Fiscal Hub (restorable with +# 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 COMPOSE="docker compose -f docker-compose.prod.yml --env-file .env" 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. $COMPOSE exec -T postgres sh -c 'ls -1t /backups/gti_fiscal_*.dump | tail -n +15 | xargs -r rm -f' 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" diff --git a/deploy/hub/restore_releases.sh b/deploy/hub/restore_releases.sh new file mode 100644 index 0000000..74b0f31 --- /dev/null +++ b/deploy/hub/restore_releases.sh @@ -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 " >&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" diff --git a/tests/test_deploy_scripts.py b/tests/test_deploy_scripts.py index 359f595..27ef97e 100644 --- a/tests/test_deploy_scripts.py +++ b/tests/test_deploy_scripts.py @@ -29,8 +29,21 @@ def test_hub_port_restriction_uses_docker_user_chain(): def test_backup_retention_does_not_delete_on_the_host(): - # Docker creates the bind-mounted backup directory as root, so a host-side - # rm by the deploy user fails once retention kicks in. + # The Postgres dump is written by pg_dump INSIDE the postgres container + # 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("#")] 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