The watcher scripts now write live status and append-only run history onto the shared volume; the api exposes them via /shopdbbackups/status and /history. The restore modal shows an indeterminate spinner with phase labels, and two cards list backup and restore run records. Timestamps render in UTC to match the Zulu-stamped set ids, the history directory is excluded from the set list, and a stale-status guard stops a previous run's done phase from collapsing the spinner on the next restore.
101 lines
3.8 KiB
Bash
101 lines
3.8 KiB
Bash
#!/usr/bin/env bash
|
|
# ShopDB restore.
|
|
#
|
|
# The DANGEROUS half of the pair, so it is deliberately guarded. A restore is
|
|
# only safe onto code whose schema is at least the set's - otherwise restored
|
|
# rows reference columns the live schema has not applied yet and every query on
|
|
# those tables 500s. This script refuses an incompatible restore rather than
|
|
# leaving the site half-broken.
|
|
#
|
|
# Usage (from the backup container):
|
|
# docker compose -f docker-compose.yml -f docker-compose.backup.yml \
|
|
# run --rm --entrypoint /opt/backup/restore.sh backup <snapshot-set-name>
|
|
#
|
|
# It does NOT roll back code - that is git's job (git checkout <tag> && build).
|
|
# Restore code FIRST, bring the schema up (flask db upgrade + plugin
|
|
# upgrade-all), THEN run this to load the data.
|
|
#
|
|
# ASCII only, no emojis - matches the repo style gate.
|
|
set -euo pipefail
|
|
|
|
BACKUPS_ROOT="${SHOPDB_BACKUPS_ROOT:-/backups}"
|
|
SETNAME="${1:-}"
|
|
|
|
# shellcheck source=scripts/backup/status.sh
|
|
. /opt/backup/status.sh
|
|
|
|
log() { echo "[restore] $*"; }
|
|
|
|
# On any exit, stamp the terminal phase and append a history record so the UI
|
|
# reflects success or failure even when a step aborts early (set -e).
|
|
RESTORE_DONE=false
|
|
_on_exit() {
|
|
code=$?
|
|
if [ "${RESTORE_DONE}" = true ]; then
|
|
status_write restore done "${SETNAME}" "Restore complete"
|
|
history_append restore success "${SETNAME}" "Restore complete"
|
|
else
|
|
status_write restore failed "${SETNAME}" "Restore failed (exit ${code})"
|
|
history_append restore failed "${SETNAME}" "Restore failed (exit ${code})"
|
|
fi
|
|
}
|
|
trap _on_exit EXIT
|
|
|
|
die() { echo "[restore] ERROR: $*" >&2; exit 1; }
|
|
|
|
[ -n "${SETNAME}" ] || die "usage: restore.sh <snapshot-set-name>"
|
|
|
|
status_write restore checking "${SETNAME}" "Checking schema compatibility"
|
|
|
|
SETDIR="${BACKUPS_ROOT}/${SETNAME}"
|
|
DBDUMP="${SETDIR}/db.sql.gz"
|
|
|
|
# Prefer the local fast path; fall back to pulling the set out of restic.
|
|
if [ ! -f "${DBDUMP}" ]; then
|
|
log "local dump not found; restoring set from restic"
|
|
command -v restic >/dev/null 2>&1 || die "restic not available and no local dump"
|
|
restic restore latest --target / --include "${SETDIR}" \
|
|
|| die "restic restore of ${SETNAME} failed"
|
|
fi
|
|
[ -f "${DBDUMP}" ] || die "no db.sql.gz for set ${SETNAME}"
|
|
|
|
# Compatibility gate: the running schema must cover the set's stamped heads.
|
|
log "checking schema compatibility"
|
|
CURRENT_HEADS="$(python3 /opt/backup/manifest.py \
|
|
--out /tmp/current-manifest.json \
|
|
--migrations /app/migrations --plugins /app/plugins \
|
|
>/dev/null 2>&1 && python3 -c \
|
|
'import json;print(",".join(sorted(json.load(open("/tmp/current-manifest.json"))["schemarev"]["core"])))')"
|
|
SET_HEADS="$(python3 -c \
|
|
'import json,sys;print(",".join(sorted(json.load(open(sys.argv[1]))["schemarev"]["core"])))' \
|
|
"${SETDIR}/manifest.json" 2>/dev/null || echo "")"
|
|
|
|
if [ -z "${SET_HEADS}" ]; then
|
|
die "set ${SETNAME} has no readable manifest - refusing an unverified restore"
|
|
fi
|
|
if [ "${CURRENT_HEADS}" != "${SET_HEADS}" ]; then
|
|
log "current heads : ${CURRENT_HEADS}"
|
|
log "set heads : ${SET_HEADS}"
|
|
die "schema mismatch - bring code to the set's revision, run 'flask db upgrade' and 'flask plugin upgrade-all', then retry"
|
|
fi
|
|
|
|
# Files first, then DB, so the two land together.
|
|
if command -v restic >/dev/null 2>&1 && [ -n "${RESTIC_REPOSITORY:-}" ]; then
|
|
status_write restore files "${SETNAME}" "Restoring instance files"
|
|
log "restoring instance files from restic"
|
|
restic restore latest --target / --include /app/instance \
|
|
|| die "restic restore of instance files failed"
|
|
fi
|
|
|
|
status_write restore database "${SETNAME}" "Loading database dump"
|
|
log "loading database dump"
|
|
gunzip -c "${DBDUMP}" | mysql \
|
|
--host="${MYSQL_HOST}" \
|
|
--user="${MYSQL_USER}" \
|
|
--password="${MYSQL_PASSWORD}" \
|
|
|| die "database load failed"
|
|
|
|
rm -f "${BACKUPS_ROOT}/.restore-requested" 2>/dev/null || true
|
|
RESTORE_DONE=true
|
|
log "restore of ${SETNAME} complete"
|