Files
PartsInventorySystem/tools/backup.sh
T
thejayman77 ccb3da51ad Make the backup script wait for readiness and verify the archive restores
Running it against the live service exposed two gaps. It announced "App
restarted" the moment the container existed, so the very next request got a 502
from an app that wasn't listening yet; it now polls /healthz until the app is
actually serving. And it only checked that the archive contained the right
files — it now opens the archived database and runs SQLite's integrity_check,
because an archive that exists but won't restore is the worst kind of backup.

End to end on the server, with a throwaway part and photo inserted to exercise
the verification path: 2.6 seconds of downtime, archive verified, service
answering immediately on return, throwaway data removed afterwards.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 09:59:43 -04:00

91 lines
3.4 KiB
Bash
Executable File

#!/usr/bin/env bash
# Consistent, verified backup of the parts inventory.
#
# The database and the photos are two resources that reference each other, so
# capturing them at different moments is not a backup: a photo deleted between
# the two steps leaves the saved database pointing at a file the archive does
# not contain, and one added leaves the reverse. Neither is repairable
# afterwards, and pruning only fixes the harmless direction.
#
# So the app is stopped for the couple of seconds the copy takes. With no
# process attached, parts.db and any -wal/-shm sidecars are a consistent set and
# the images directory cannot move underneath us.
#
# ./tools/backup.sh [output-directory]
set -euo pipefail
OUT_DIR="$(cd "${1:-$PWD}" && pwd)"
cd "$(dirname "$0")/.."
NAME="parts-backup-$(date +%F-%H%M%S).tar.gz"
ARCHIVE="$OUT_DIR/$NAME"
VOLUME="$(docker volume ls --format '{{.Name}}' | grep -E '^parts.*data$' | head -1)"
if [ -z "$VOLUME" ]; then
echo "Could not find the parts data volume." >&2
exit 1
fi
echo "Stopping the app so both halves are captured at one moment..."
docker compose stop parts >/dev/null
restarted=0
restart() {
[ "$restarted" = 1 ] && return
restarted=1
docker compose start parts >/dev/null
# Report readiness only once it is actually serving. Announcing "restarted"
# the instant the container exists is how you get a 502 from the very next
# request.
for _ in $(seq 1 60); do
if docker exec parts python -c \
"import urllib.request;urllib.request.urlopen('http://127.0.0.1:8100/healthz',timeout=2)" \
>/dev/null 2>&1; then
echo "App restarted and serving."
return
fi
sleep 0.5
done
echo "WARNING: the app was restarted but is not answering /healthz." >&2
}
trap restart EXIT
# What the database expects to exist, read while nothing can be writing to it.
EXPECTED="$(docker run --rm -v "$VOLUME":/data -e PARTS_DB=/data/parts.db \
--entrypoint python parts -m app.admin list-image-files | sort)"
docker run --rm -v "$VOLUME":/data -v "$OUT_DIR":/out alpine \
tar -czf "/out/$NAME" -C /data .
# 1. Every referenced photo is actually in the archive.
IN_TAR="$(tar -tzf "$ARCHIVE" | sed -n 's#^\./images/##p' | sort)"
MISSING="$(comm -23 <(printf '%s\n' "$EXPECTED" | grep -v '^$' || true) \
<(printf '%s\n' "$IN_TAR" | grep -v '^$' || true) || true)"
if [ -n "$MISSING" ]; then
echo "BACKUP INCOMPLETE — referenced photos missing from the archive:" >&2
printf ' %s\n' $MISSING >&2
exit 1
fi
# 2. The archived database actually opens and passes SQLite's own check. An
# archive that exists but won't restore is the worst kind of backup.
WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"; restart' EXIT
tar -xzf "$ARCHIVE" -C "$WORK" ./parts.db
VERDICT="$(docker run --rm -v "$WORK":/v --entrypoint python parts -c "
import sqlite3
c = sqlite3.connect('/v/parts.db')
ok = c.execute('PRAGMA integrity_check').fetchone()[0]
parts = c.execute('SELECT COUNT(*) FROM parts').fetchone()[0]
photos = c.execute('SELECT COUNT(*) FROM part_images').fetchone()[0]
print(f'{ok}|{parts}|{photos}')
")"
STATUS="${VERDICT%%|*}"
COUNTS="${VERDICT#*|}"
if [ "$STATUS" != "ok" ]; then
echo "BACKUP UNUSABLE — archived database failed integrity_check: $STATUS" >&2
exit 1
fi
echo "Wrote $ARCHIVE ($(du -h "$ARCHIVE" | cut -f1))"
echo "Verified: integrity_check ok, ${COUNTS%|*} part(s), ${COUNTS#*|} photo(s), all photo files present."