#!/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 CONTAINER=parts OUT_DIR="$(cd "${1:-$PWD}" && pwd)" cd "$(dirname "$0")/.." NAME="parts-backup-$(date +%F-%H%M%S).tar.gz" ARCHIVE="$OUT_DIR/$NAME" # --- resolve the data volume from the container, never by guessing ------------ # Matching volume names by pattern can silently pick a stale or restored volume, # and every subsequent check would then faithfully verify the wrong database. # Ask the container what is actually mounted at /data, and refuse ambiguity. MOUNTS="$(docker inspect "$CONTAINER" --format \ '{{range .Mounts}}{{if eq .Destination "/data"}}{{.Type}}|{{.Name}}|{{.Source}}{{"\n"}}{{end}}{{end}}' \ | grep . || true)" MOUNT_COUNT="$(printf '%s\n' "$MOUNTS" | grep -c . || true)" if [ "$MOUNT_COUNT" -ne 1 ]; then echo "Expected exactly one mount at /data in container '$CONTAINER', found $MOUNT_COUNT." >&2 printf '%s\n' "$MOUNTS" >&2 exit 1 fi MOUNT_TYPE="${MOUNTS%%|*}" MOUNT_REST="${MOUNTS#*|}" MOUNT_NAME="${MOUNT_REST%%|*}" MOUNT_SOURCE="${MOUNT_REST#*|}" case "$MOUNT_TYPE" in volume) DATA_REF="$MOUNT_NAME" ;; bind) DATA_REF="$MOUNT_SOURCE" ;; *) echo "Unsupported mount type at /data: $MOUNT_TYPE" >&2; exit 1 ;; esac echo "Backing up $MOUNT_TYPE '$DATA_REF' (mounted at /data in $CONTAINER)." # --- restore the original state whatever happens ------------------------------ # Installed *before* stopping anything: an interrupt during the stop would # otherwise leave the service down with no trap to bring it back. WAS_RUNNING="$(docker inspect -f '{{.State.Running}}' "$CONTAINER" 2>/dev/null || echo false)" WORK="" finished=0 cleanup() { [ "$finished" = 1 ] && return finished=1 [ -n "$WORK" ] && rm -rf "$WORK" if [ "$WAS_RUNNING" = "true" ]; then docker compose start "$CONTAINER" >/dev/null 2>&1 || true # 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 "$CONTAINER" 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 else echo "The app was not running before this backup; leaving it stopped." fi } trap cleanup EXIT INT TERM if [ "$WAS_RUNNING" = "true" ]; then echo "Stopping the app so both halves are captured at one moment..." docker compose stop "$CONTAINER" >/dev/null fi run_in_data() { docker run --rm -v "$DATA_REF":/data "$@"; } # --- is the source data itself healthy? --------------------------------------- DATA_STATUS=ok if ! run_in_data -e PARTS_DB=/data/parts.db --entrypoint python parts \ -m app.admin check-images; then DATA_STATUS=damaged fi EXPECTED="$(run_in_data -e PARTS_DB=/data/parts.db --entrypoint python parts \ -m app.admin list-image-files | sort)" run_in_data -v "$OUT_DIR":/out alpine tar -czf "/out/$NAME" -C /data . # --- is the archive complete and restorable? ---------------------------------- 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 WORK="$(mktemp -d)" 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." if [ "$DATA_STATUS" != ok ]; then echo "WARNING: the source data was already inconsistent (see check-images above)." >&2 echo "The archive is a faithful copy of it, but the damage predates this backup." >&2 exit 2 fi