Files
SnapOtter/tests/qa/backup-restore-drill.sh
SnapOtterandGitHub d10d0f544f fix: release QA hardening across processing, media, security, and CI gates (#649)
A release-readiness QA pass over the whole product. The commits split into
defects a user would hit and gates that were reporting green while measuring
nothing.

## Fixes that change behaviour

Rate limiting was bypassable on every install: TRUST_PROXY defaulted to true, so
request.ip came from a client-set header and a forged X-Forwarded-For got past
the login limiter. The default is now a private-network trust list.

A transient Postgres outage stranded in-flight jobs, leaving finished output on
disk with no row pointing at it. A reconciler now resolves those rows and adopts
the bytes rather than dropping the work.

A Redis connection that moved to a new address wedged every read-blocked
consumer, so completions stopped signalling while health still answered 200.
Socket timeouts plus subscriber pings recover it.

Installing more than one AI bundle left the shared venv multi-versioned and
silently broke three tools. The installer now reconciles distributions to one
version each.

Converting an image to JXL at quality 1 through 4 returned a 500, because
libjxl 0.7 rejects the distance those values compute. The quality is floored at
what the encoder honours. A missing ffmpeg was also reported to the user as a
corrupt upload; it now says the engine is unavailable.

RAW uploads reached an unpatched LibRaw on arm64, so it is built from source at
0.22.2, and the release scan was split so it can fail on an unfixed critical
instead of hiding it behind ignore-unfixed.

## Gates that could not fail

Two mutation lanes ran zero mutants because Stryker crawled the gitignored docs
build; coverage discarded its whole report on any failing test; the lint gate
skipped root tests, scripts, and two workspaces; and several generated matrices
counted a host missing ffmpeg as a passing tool. Each now measures what it
claims.

Full evidence and the outstanding release items are tracked locally and are not
part of this branch.
2026-07-27 15:37:30 +08:00

397 lines
19 KiB
Bash
Executable File

#!/usr/bin/env bash
set -euo pipefail
# End-to-end backup, restore, upgrade and rollback drill.
#
# Runs entirely on disposable stacks: a run-scoped Compose project with its own
# volumes, created and destroyed by this script. It never touches an operator
# volume, and it refuses to start if the project name it was given already
# exists, so it cannot adopt somebody else's containers.
#
# Stages
# 1 seed real user objects through the API, plus DB and Redis markers
# 2 logical dump (pg_dump custom) and archive validation
# 3 quiesced volume snapshot with the app and Redis stopped
# 4 destroy the source volumes, restore into empty ones, verify by checksum
# 5 repeat-startup idempotency
# 6 recovery from a destructive database failure (pgdata wiped)
# 7 application rollback onto an already-migrated database (optional)
# 8 1.x SQLite import, with the db-only backup as the negative control
#
# Usage
# QA_IMAGE=<64-hex digest> docker.io/snapotter/snapotter@sha256:<digest>
# QA_IMAGE_REF=<full reference> any local tag, for a locally built candidate
# QA_PREVIOUS_IMAGE_REF=<ref> enables stage 7
# QA_LEGACY_FIXTURE_DIR=<dir> enables stage 8 (see backup-restore-legacy-fixture.mts)
# QA_COMPOSE_FILE=<path> defaults to tests/qa/docker-compose.qa.yml
# QA_RESULTS=<path> JSONL stage log
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
COMPOSE_FILE=${QA_COMPOSE_FILE:-"$SCRIPT_DIR/docker-compose.qa.yml"}
RUN_ID=${QA_BACKUP_RUN_ID:-"$(date -u +%Y%m%d%H%M%S)-$$"}
case "$RUN_ID" in
*[!a-zA-Z0-9_-]*)
echo "QA_BACKUP_RUN_ID may contain only letters, digits, underscore, and hyphen" >&2
exit 2
;;
esac
if [ -n "${QA_IMAGE_REF:-}" ]; then
CANDIDATE_IMAGE="$QA_IMAGE_REF"
else
: "${QA_IMAGE:?Set QA_IMAGE to the release-candidate digest, or QA_IMAGE_REF to a full reference}"
QA_IMAGE_REPOSITORY=${QA_IMAGE_REPOSITORY:-snapotter/snapotter}
CANDIDATE_IMAGE="$QA_IMAGE_REPOSITORY@sha256:$QA_IMAGE"
fi
export QA_IMAGE_REF="$CANDIDATE_IMAGE"
export QA_PROJECT_NAME="snapotter-backup-$RUN_ID"
export QA_APP_PORT=${QA_APP_PORT:-0}
BACKUP_DIR=$(mktemp -d "${TMPDIR:-/tmp}/snapotter-backup-drill.XXXXXX")
# Where the volume archives are staged. Docker Desktop on macOS serves bind
# mounts over virtiofs, which reproducibly hands back a corrupt multi-hundred-MB
# tar even when it hashed correctly moments earlier, so the default is a Docker
# volume the daemon owns end to end. Set QA_BACKUP_STAGING=bind to put the
# archives on the host filesystem instead.
BACKUP_VOLUME="snapotter-backup-stage-$RUN_ID"
BACKUP_STAGING=${QA_BACKUP_STAGING:-volume}
KEEP_BACKUP=${KEEP_QA_BACKUP:-0}
RESULTS=${QA_RESULTS:-"$BACKUP_DIR/drill-results.jsonl"}
BASE_URL=""
# The base Compose file is snapshotted into the run directory before anything
# else: other QA lanes edit the shared file, and a drill that destroys volumes
# must not have its definition change underneath it mid-run. The override
# supplies the image, so a locally built candidate with no registry digest is
# just as usable as a published one.
BASE_COMPOSE="$BACKUP_DIR/base.compose.yml"
OVERRIDE_COMPOSE="$BACKUP_DIR/override.compose.yml"
cp -- "$COMPOSE_FILE" "$BASE_COMPOSE"
cat > "$OVERRIDE_COMPOSE" <<YAML
services:
app:
image: \${QA_IMAGE_REF}
YAML
# The base file interpolates a digest before the override is merged, so it needs
# a syntactically valid placeholder even when the override replaces the result.
export QA_IMAGE=${QA_IMAGE:-0000000000000000000000000000000000000000000000000000000000000000}
compose() {
docker compose -f "$BASE_COMPOSE" -f "$OVERRIDE_COMPOSE" -p "$QA_PROJECT_NAME" "$@"
}
compose_project() {
local project="$1"
shift
docker compose -f "$BASE_COMPOSE" -f "$OVERRIDE_COMPOSE" -p "$project" "$@"
}
record() {
printf '{"stage":"%s","status":"%s","detail":"%s","at":"%s"}\n' \
"$1" "$2" "${3//\"/\'}" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$RESULTS"
echo "[$2] $1: ${3:-}" >&2
}
fail() {
record "$1" "fail" "${2:-}"
exit 1
}
# Mount arguments for the archive staging area, whichever backend is in use.
stage_mount() {
if [ "$BACKUP_STAGING" = "volume" ]; then
printf '%s\n' "-v" "$BACKUP_VOLUME:/backup"
else
printf '%s\n' "-v" "$BACKUP_DIR:/backup"
fi
}
cleanup() {
compose down --volumes --remove-orphans >/dev/null 2>&1 || true
if [ "$BACKUP_STAGING" = "volume" ] && [ "$KEEP_BACKUP" != "1" ]; then
docker volume rm -f "$BACKUP_VOLUME" >/dev/null 2>&1 || true
fi
for project in "${LEGACY_PROJECTS[@]:-}"; do
[ -n "$project" ] || continue
compose_project "$project" down --volumes --remove-orphans >/dev/null 2>&1 || true
done
if [ "$KEEP_BACKUP" = "1" ]; then
echo "Backup artifacts retained at $BACKUP_DIR" >&2
else
rm -rf -- "$BACKUP_DIR"
fi
}
trap cleanup EXIT HUP INT TERM
# Refuse to adopt an existing project: a drill that deletes volumes must be
# certain every volume it deletes is one it created.
LEGACY_PROJECTS=()
if compose ps -aq 2>/dev/null | grep -q .; then
echo "Project $QA_PROJECT_NAME already has containers; refusing to adopt them" >&2
exit 2
fi
wait_for_postgres() {
local attempts=0
until compose exec -T postgres pg_isready -U snapotter -d snapotter >/dev/null 2>&1; do
attempts=$((attempts + 1))
[ "$attempts" -ge 60 ] && return 1
sleep 2
done
}
wait_for_app() {
local attempts=0
until compose exec -T app curl -fsS --max-time 5 http://localhost:1349/api/v1/health >/dev/null 2>&1; do
attempts=$((attempts + 1))
if [ "$attempts" -ge 90 ]; then
compose logs --tail 60 app >&2 || true
return 1
fi
sleep 2
done
}
resolve_base_url() {
local binding
binding=$(compose port app 1349 2>/dev/null) || return 1
BASE_URL="http://127.0.0.1:${binding##*:}"
}
api() {
curl -sS --max-time 60 "$@"
}
# macOS ships shasum, Linux ships sha256sum; the drill has to run on both.
if command -v sha256sum >/dev/null 2>&1; then
host_sha256() { sha256sum | awk '{print $1}'; }
else
host_sha256() { shasum -a 256 | awk '{print $1}'; }
fi
# ── Stage 1: seed ────────────────────────────────────────────────────────────
record "boot-candidate" "start" "$CANDIDATE_IMAGE"
compose up -d --wait --wait-timeout 300 || fail "boot-candidate" "stack did not come up"
wait_for_app || fail "boot-candidate" "app never became healthy"
resolve_base_url || fail "boot-candidate" "no published app port"
RUNNING_IMAGE=$(docker inspect --type container --format '{{.Image}}' "$(compose ps -q app)")
record "boot-candidate" "pass" "image=$RUNNING_IMAGE url=$BASE_URL"
compose exec -T postgres psql -v ON_ERROR_STOP=1 -U snapotter -d snapotter <<'SQL' >/dev/null
CREATE TABLE IF NOT EXISTS qa_backup_marker (
marker text PRIMARY KEY,
created_at timestamptz NOT NULL DEFAULT now()
);
INSERT INTO qa_backup_marker(marker) VALUES ('release-candidate')
ON CONFLICT (marker) DO UPDATE SET created_at = now();
SQL
compose exec -T redis redis-cli SET qa:backup:marker release-candidate >/dev/null
compose exec -T app sh -c \
'mkdir -p /data/files /tmp/workspace/uploads && printf %s release-candidate > /data/files/qa-backup-marker.txt && printf %s transient > /tmp/workspace/uploads/qa-transient.txt'
EXPECTED_DATA_SHA=$(compose exec -T app sha256sum /data/files/qa-backup-marker.txt | awk '{print $1}')
# A real library object, uploaded and read back the way a user would, so the
# restore proves the object store and the database rows still agree.
LIBRARY_SOURCE="$BACKUP_DIR/library-object.png"
compose exec -T app sh -c 'cat /app/apps/web/dist/logo.png 2>/dev/null || head -c 65536 /dev/urandom' \
> "$LIBRARY_SOURCE"
test -s "$LIBRARY_SOURCE" || fail "seed-library" "could not build a library upload payload"
EXPECTED_LIBRARY_SHA=$(host_sha256 < "$LIBRARY_SOURCE")
UPLOAD_JSON=$(api -X POST "$BASE_URL/api/v1/files/upload" -F "file=@$LIBRARY_SOURCE") \
|| fail "seed-library" "upload request failed"
LIBRARY_ID=$(printf '%s' "$UPLOAD_JSON" | sed -n 's/.*"id":"\([^"]*\)".*/\1/p' | head -1)
[ -n "$LIBRARY_ID" ] || fail "seed-library" "upload returned no file id: $UPLOAD_JSON"
record "seed-library" "pass" "id=$LIBRARY_ID sha=$EXPECTED_LIBRARY_SHA"
verify_library_object() {
local stage="$1" actual
actual=$(api "$BASE_URL/api/v1/files/$LIBRARY_ID/download" | host_sha256)
[ "$actual" = "$EXPECTED_LIBRARY_SHA" ] || fail "$stage" "library checksum $actual != $EXPECTED_LIBRARY_SHA"
}
# ── Stage 2: logical dump ────────────────────────────────────────────────────
compose exec -T postgres pg_dump --format=custom --no-owner -U snapotter snapotter \
> "$BACKUP_DIR/snapotter.dump" || fail "pg-dump" "pg_dump failed"
test -s "$BACKUP_DIR/snapotter.dump" || fail "pg-dump" "dump is empty"
compose exec -T postgres pg_restore --list < "$BACKUP_DIR/snapotter.dump" >/dev/null \
|| fail "pg-dump" "dump does not list"
record "pg-dump" "pass" "$(wc -c < "$BACKUP_DIR/snapotter.dump" | tr -d ' ') bytes"
# ── Stage 3: quiesced volume snapshot ────────────────────────────────────────
compose stop app >/dev/null
compose exec -T redis redis-cli SAVE >/dev/null
compose stop redis >/dev/null
APP_CONTAINER=$(compose ps -aq app)
REDIS_CONTAINER=$(compose ps -aq redis)
[ -n "$APP_CONTAINER" ] && [ -n "$REDIS_CONTAINER" ] || fail "snapshot" "container ids missing"
mapfile -t STAGE < <(stage_mount)
if [ "$BACKUP_STAGING" = "volume" ]; then
docker volume create "$BACKUP_VOLUME" >/dev/null
docker run --rm --entrypoint /bin/sh "${STAGE[@]}" -v "$BACKUP_DIR:/host:ro" "$CANDIDATE_IMAGE" \
-c 'cp /host/snapotter.dump /backup/snapotter.dump' || fail "snapshot" "could not stage the dump"
fi
docker run --rm --entrypoint /bin/sh \
--volumes-from "$APP_CONTAINER" "${STAGE[@]}" "$CANDIDATE_IMAGE" \
-c 'tar czf /backup/snapotter-data.tar.gz -C /data .' || fail "snapshot" "data archive failed"
docker run --rm --entrypoint /bin/sh \
--volumes-from "$REDIS_CONTAINER" "${STAGE[@]}" "$CANDIDATE_IMAGE" \
-c 'tar czf /backup/snapotter-redis.tar.gz -C /data .' || fail "snapshot" "redis archive failed"
docker run --rm --entrypoint /bin/sh "${STAGE[@]}" "$CANDIDATE_IMAGE" \
-c 'cd /backup && sha256sum snapotter.dump snapotter-data.tar.gz snapotter-redis.tar.gz > SHA256SUMS && sha256sum -c SHA256SUMS' \
>/dev/null || fail "snapshot" "checksum manifest failed"
# Read the archives back from a container that did not write them, before
# anything irreversible happens. A backup you have not read is not a backup,
# and the next stage destroys the source volumes. macOS bind mounts in
# particular can hand back bytes that hashed correctly moments earlier.
docker run --rm --entrypoint /bin/sh "${STAGE[@]}" "$CANDIDATE_IMAGE" \
-c 'cd /backup && sha256sum -c SHA256SUMS && tar tzf snapotter-data.tar.gz >/dev/null && tar tzf snapotter-redis.tar.gz >/dev/null' \
>/dev/null 2>&1 || fail "snapshot" "archives do not read back intact; refusing to destroy the source volumes"
record "snapshot" "pass" "app and redis stopped before the tar; archives re-read and listed from a second container"
# ── Stage 4: destroy and restore ─────────────────────────────────────────────
compose down --volumes --remove-orphans >/dev/null
compose create app postgres redis >/dev/null
APP_CONTAINER=$(compose ps -aq app)
REDIS_CONTAINER=$(compose ps -aq redis)
docker run --rm --entrypoint /bin/sh \
--volumes-from "$APP_CONTAINER" "${STAGE[@]}" "$CANDIDATE_IMAGE" \
-c 'find /data -mindepth 1 -maxdepth 1 -exec rm -rf -- {} + && tar xzf /backup/snapotter-data.tar.gz -C /data' \
|| fail "restore" "data restore failed"
docker run --rm --entrypoint /bin/sh \
--volumes-from "$REDIS_CONTAINER" "${STAGE[@]}" "$CANDIDATE_IMAGE" \
-c 'find /data -mindepth 1 -maxdepth 1 -exec rm -rf -- {} + && tar xzf /backup/snapotter-redis.tar.gz -C /data' \
|| fail "restore" "redis restore failed"
compose up -d postgres redis >/dev/null
wait_for_postgres || fail "restore" "postgres never became ready"
compose exec -T postgres pg_restore --exit-on-error --clean --if-exists --no-owner \
-U snapotter -d snapotter < "$BACKUP_DIR/snapotter.dump" >/dev/null \
|| fail "restore" "pg_restore failed"
compose up -d app >/dev/null
wait_for_app || fail "restore" "app never became healthy after restore"
resolve_base_url || fail "restore" "no published app port after restore"
RESTORED_MARKER=$(compose exec -T postgres psql -At -v ON_ERROR_STOP=1 -U snapotter -d snapotter \
-c "SELECT marker FROM qa_backup_marker WHERE marker = 'release-candidate'")
[ "$RESTORED_MARKER" = "release-candidate" ] || fail "restore" "database marker missing"
RESTORED_REDIS=$(compose exec -T redis redis-cli --raw GET qa:backup:marker)
[ "$RESTORED_REDIS" = "release-candidate" ] || fail "restore" "redis marker missing"
RESTORED_DATA_SHA=$(compose exec -T app sha256sum /data/files/qa-backup-marker.txt | awk '{print $1}')
[ "$RESTORED_DATA_SHA" = "$EXPECTED_DATA_SHA" ] || fail "restore" "data file checksum changed"
if compose exec -T app test -e /tmp/workspace/uploads/qa-transient.txt; then
fail "restore" "ephemeral workspace survived volume replacement"
fi
verify_library_object "restore"
record "restore" "pass" "db marker, redis marker, /data checksum and API library checksum all match"
# ── Stage 5: repeat-startup idempotency ──────────────────────────────────────
BEFORE_ROWS=$(compose exec -T postgres psql -At -U snapotter -d snapotter \
-c "SELECT (SELECT count(*) FROM users) || ':' || (SELECT count(*) FROM user_files)")
for attempt in 1 2; do
compose restart app >/dev/null
wait_for_app || fail "repeat-startup" "app unhealthy on restart $attempt"
done
AFTER_ROWS=$(compose exec -T postgres psql -At -U snapotter -d snapotter \
-c "SELECT (SELECT count(*) FROM users) || ':' || (SELECT count(*) FROM user_files)")
[ "$BEFORE_ROWS" = "$AFTER_ROWS" ] || fail "repeat-startup" "row counts moved $BEFORE_ROWS -> $AFTER_ROWS"
resolve_base_url || fail "repeat-startup" "no published app port"
verify_library_object "repeat-startup"
record "repeat-startup" "pass" "two restarts, rows stable at $AFTER_ROWS, library object still readable"
# ── Stage 6: destructive database failure ────────────────────────────────────
compose stop app >/dev/null
compose stop postgres >/dev/null
PG_CONTAINER=$(compose ps -aq postgres)
docker run --rm --entrypoint /bin/sh --volumes-from "$PG_CONTAINER" "$CANDIDATE_IMAGE" \
-c 'find /var/lib/postgresql/data -mindepth 1 -maxdepth 1 -exec rm -rf -- {} +' \
|| fail "disaster-recovery" "could not wipe the data directory"
compose up -d postgres >/dev/null
wait_for_postgres || fail "disaster-recovery" "postgres did not reinitialise"
compose exec -T postgres pg_restore --exit-on-error --clean --if-exists --no-owner \
-U snapotter -d snapotter < "$BACKUP_DIR/snapotter.dump" >/dev/null \
|| fail "disaster-recovery" "pg_restore into the reinitialised cluster failed"
compose up -d app >/dev/null
wait_for_app || fail "disaster-recovery" "app unhealthy after disaster recovery"
resolve_base_url || fail "disaster-recovery" "no published app port"
RECOVERED_MARKER=$(compose exec -T postgres psql -At -U snapotter -d snapotter \
-c "SELECT marker FROM qa_backup_marker WHERE marker = 'release-candidate'")
[ "$RECOVERED_MARKER" = "release-candidate" ] || fail "disaster-recovery" "marker lost"
verify_library_object "disaster-recovery"
record "disaster-recovery" "pass" "pgdata destroyed, cluster reinitialised, dump restored, library intact"
# ── Stage 7: application rollback ────────────────────────────────────────────
if [ -n "${QA_PREVIOUS_IMAGE_REF:-}" ]; then
compose stop app >/dev/null
ROLLBACK_STATUS="pass"
ROLLBACK_DETAIL="previous image booted healthy against the candidate-migrated database"
if QA_IMAGE_REF="$QA_PREVIOUS_IMAGE_REF" compose up -d app >/dev/null 2>&1 && wait_for_app; then
resolve_base_url || fail "rollback" "no published app port after rollback"
verify_library_object "rollback"
else
ROLLBACK_STATUS="fail"
ROLLBACK_DETAIL="previous image did not become healthy against the candidate-migrated database"
fi
record "rollback" "$ROLLBACK_STATUS" "$QA_PREVIOUS_IMAGE_REF: $ROLLBACK_DETAIL"
# Return to the candidate so later stages describe the artifact under test.
compose stop app >/dev/null
compose up -d app >/dev/null
wait_for_app || fail "rollback" "candidate did not come back after rollback"
else
record "rollback" "skip" "set QA_PREVIOUS_IMAGE_REF to exercise it"
fi
compose down --volumes --remove-orphans >/dev/null
# ── Stage 8: 1.x SQLite import, with the db-only negative control ────────────
if [ -n "${QA_LEGACY_FIXTURE_DIR:-}" ]; then
legacy_import() {
local variant="$1" project="snapotter-backup-$RUN_ID-$1" app_container attempts=0
LEGACY_PROJECTS+=("$project")
compose_project "$project" create app postgres redis >/dev/null 2>&1
app_container=$(compose_project "$project" ps -aq app)
# DATA_DIR is /data in the image, and the boot importer probes
# /data/snapotter.db before any default user is created, so dropping the
# files in is the whole setup an operator would do.
docker run --rm --entrypoint /bin/sh --volumes-from "$app_container" \
-v "$QA_LEGACY_FIXTURE_DIR/$variant:/legacy:ro" "$CANDIDATE_IMAGE" \
-c 'cp /legacy/snapotter.db /data/snapotter.db && { [ -s /legacy/snapotter.db-wal ] && cp /legacy/snapotter.db-wal /data/snapotter.db-wal || true; } && chown 999:999 /data/snapotter.db*' \
>/dev/null 2>&1
compose_project "$project" up -d >/dev/null 2>&1 || true
until compose_project "$project" exec -T app \
curl -fsS --max-time 5 http://localhost:1349/api/v1/health >/dev/null 2>&1; do
attempts=$((attempts + 1))
if [ "$attempts" -ge 90 ]; then
compose_project "$project" logs --tail 40 app >&2 || true
echo "-1"
return 0
fi
sleep 2
done
compose_project "$project" exec -T postgres psql -At -U snapotter -d snapotter \
-c "SELECT count(*) FROM pipelines" 2>/dev/null | tr -d '\r' || echo "-1"
compose_project "$project" down --volumes --remove-orphans >/dev/null 2>&1
}
FULL_PIPELINES=$(legacy_import "full" | tail -1)
DBONLY_PIPELINES=$(legacy_import "db-only" | tail -1)
if [ "$FULL_PIPELINES" -le 0 ]; then
fail "legacy-import" "db+wal import produced $FULL_PIPELINES pipelines"
fi
if [ "$DBONLY_PIPELINES" -ge "$FULL_PIPELINES" ]; then
fail "legacy-import" \
"db-only import produced $DBONLY_PIPELINES pipelines, same as db+wal ($FULL_PIPELINES); the WAL fixture proves nothing"
fi
record "legacy-import" "pass" \
"db+wal restored $FULL_PIPELINES pipelines, snapotter.db alone restored $DBONLY_PIPELINES: backing up the db file alone loses committed rows"
else
record "legacy-import" "skip" "set QA_LEGACY_FIXTURE_DIR to exercise it"
fi
record "drill" "pass" "all enabled stages passed"
echo "Results: $RESULTS" >&2