A restore could load a database dump whose schema was older than the running code. The manifest stamped schemarev from the code image on disk, so the compatibility check compared the code against itself and always passed. A set captured from a database at 7d37_import_batches_and_journal restored under code at 7d41_drop_guest_tools_secrets, leaving the data behind the code: every query on a table the newer migrations touched failed with 1054 Unknown column, which surfaced as empty configuration-policy dropdowns and a failure to load data on edit PC. The authoritative signal is the alembic_version of the database that was dumped, so manifests now carry datarev alongside schemarev, backup.sh reads it from the database and warns when it has drifted from the image, and the gate asks whether the running code knows that revision. Data behind the code is allowed because it can be rolled forward; data ahead of the code is refused because it cannot. Rolling it forward has to happen somewhere with an app context. The backup sidecar is mysql plus restic plus alembic only and has no Flask, so restore.sh loads the dump and parks the run in an upgrading phase behind an .upgrade-requested sentinel rather than declaring a success it cannot verify. The api container owns the Flask CLI and already runs these exact idempotent commands on every deploy, so a finalize endpoint there runs db upgrade plus plugin upgrade-all and stamps the terminal phase. The UI drives it on seeing the upgrading phase, so recovery needs no operator intervention. The live simulation runs one codebase against itself, so its dump is always at the same revision as the code restoring it and it can never reproduce this drift. The gate therefore gets a standalone tester that builds throwaway migration chains and dumps offline, invoked as a simulation preflight. It caught a real bug in the legacy dump-parse fallback, where the revision was matched loosely enough to return the alembic_version table name instead of the value it holds.
134 lines
6.0 KiB
Bash
134 lines
6.0 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). A restore
|
|
# is only "done" once the DATA has been upgraded to the running CODE, and that
|
|
# upgrade runs in the Flask-capable api container, not here (this sidecar has no
|
|
# app). So the happy path ends by handing off to the api: this script stamps an
|
|
# 'upgrading' phase and drops an .upgrade-requested sentinel, and the api runs
|
|
# 'flask db upgrade' + 'flask plugin upgrade-all' and writes the final 'done'.
|
|
RESTORE_LOADED=false
|
|
_on_exit() {
|
|
code=$?
|
|
if [ "${RESTORE_LOADED}" = true ]; then
|
|
# Data is loaded; the api takes it from 'upgrading' to 'done'. Do not
|
|
# stamp success here or the UI would call the restore complete before
|
|
# the schema is actually brought up to code.
|
|
status_write restore upgrading "${SETNAME}" "Upgrading restored schema to code"
|
|
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 restore must gate on the revision the DUMP'S DATA is
|
|
# at (datarev), not on a code-to-code comparison. Loading data that sits at an
|
|
# OLDER revision than the running code is fine - we upgrade it afterwards - but
|
|
# loading data NEWER than the code (a revision the code has never heard of)
|
|
# would leave rows referencing columns the code cannot migrate to, so that is
|
|
# refused.
|
|
log "checking schema compatibility"
|
|
|
|
# The set's data revision: prefer the manifest field; for older manifests that
|
|
# predate datarev, parse the real alembic_version straight out of the dump so a
|
|
# legacy set is still gated on its true data, not on a code-derived stamp.
|
|
SET_DATAREV="$(python3 -c \
|
|
'import json,sys;print(json.load(open(sys.argv[1])).get("datarev",""))' \
|
|
"${SETDIR}/manifest.json" 2>/dev/null || echo "")"
|
|
if [ -z "${SET_DATAREV}" ]; then
|
|
log "manifest has no datarev (legacy set) - reading it from the dump"
|
|
SET_DATAREV="$(gunzip -c "${DBDUMP}" \
|
|
| grep -A2 -i 'INSERT INTO `alembic_version`' \
|
|
| grep -oE "'[0-9a-zA-Z_]+'" | tr -d "'" | head -1)"
|
|
fi
|
|
|
|
if [ -z "${SET_DATAREV}" ]; then
|
|
die "cannot determine the set's data revision - refusing an unverified restore"
|
|
fi
|
|
|
|
# Does the running code know this revision at all? If a migration file with that
|
|
# revision id exists in the core chain, the code is at-or-past it (the head is
|
|
# the tip of the same chain), so a post-load upgrade can carry the data forward.
|
|
# If it is absent, the dump is NEWER than the code - refuse.
|
|
if ! grep -rqE "^revision = ['\"]${SET_DATAREV}['\"]" /app/migrations/versions 2>/dev/null; then
|
|
log "set datarev : ${SET_DATAREV}"
|
|
die "the backup's data is at a revision this code does not have - deploy code at or past '${SET_DATAREV}' before restoring"
|
|
fi
|
|
log "data revision ${SET_DATAREV} is known to this code - restore may proceed"
|
|
|
|
# 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"
|
|
|
|
# Hand off to the api container for the schema upgrade. The dump may sit at an
|
|
# older revision than this image (allowed and expected); without the upgrade the
|
|
# data stays behind the code and every query on a table the newer migrations
|
|
# changed 500s with 1054 Unknown column. This sidecar has no Flask, so it cannot
|
|
# run 'flask db upgrade' itself - it records the intent and lets the api (which
|
|
# owns the app context and already runs these exact idempotent commands on every
|
|
# deploy) finalize. The api clears .upgrade-requested and stamps the final
|
|
# 'done' once the upgrade succeeds.
|
|
status_write restore upgrading "${SETNAME}" "Upgrading restored schema to code"
|
|
printf '%s' "${SETNAME}" > "${BACKUPS_ROOT}/.upgrade-requested"
|
|
log "database loaded at revision ${SET_DATAREV}; handed off to api for schema upgrade"
|
|
|
|
rm -f "${BACKUPS_ROOT}/.restore-requested" 2>/dev/null || true
|
|
RESTORE_LOADED=true
|