#!/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."