Three defects that all shared a shape: the restore path looked checked and was not. An unreadable data revision was treated as a legacy set. backup.sh discards the mysql error when reading alembic_version, so a set could be written with an empty datarev; the compatibility check then fell through to the code-to-code head comparison - the very check datarev was introduced to replace - and badged the set restorable on no evidence at all. A manifest that CARRIES the key and left it empty is now refused, while one with no key at all is still the genuine legacy case and keeps the fallback. backup.sh records the failure in run history rather than passing it over in a log line, since the set it just wrote cannot be restored. Dumps were bound to the schema they came from. mysqldump --databases bakes CREATE DATABASE and USE <name> into the file, so restoring onto a site whose MYSQL_DATABASE differs loaded every row into the dump's schema, left the live database untouched, and reported success. New sets are dumped without it and load into the schema named at restore time. Existing sets are still bound, so the gate reads which schema they name and refuses a mismatch instead of restoring beside the live data. Also dropped --no-tablespaces into the main dump: it needs the PROCESS privilege the app user does not have on MySQL 8. The gate's own test carried a copy of the gate. test-restore-datarev.sh re-implemented the predicate in its own shell functions, so it would have stayed green through any drift in restore.sh - including the plugin-chain hole fixed in the previous commit, which it could never have caught. restore.sh now takes RESTORE_CHECK_ONLY, which stops it immediately after the gates having touched nothing, plus overridable paths for the migration and plugin trees. The test drives the shipped script and asserts on its exit code, so the verdict under test IS the verdict that ships. Confirmed non-vacuous by planting a violation: disabling the plugin revision check turns the suite red. Nine cases now, up from five, covering both plugin-chain directions, a plugin the build does not have, and the schema binding. Moving the schema check earlier was prompted by that test: it had been placed after the safety copy, so it needed a live database to reach a verdict it can reach for free. It sits with the other gates now and stops before anything is written. Round-tripped against a real stack after the dump format change: backup, mutate, restore, data back to backup-time state, and the new dumps confirmed to carry no schema name. Suite 2607 passed / 9 skipped. Scope: product
198 lines
7.8 KiB
Bash
Executable File
198 lines
7.8 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# ShopDB backup one-shot.
|
|
#
|
|
# Produces one recoverable set per run and stores it two ways: a plain
|
|
# db.sql.gz + manifest.json on the local /backups volume for a fast, repo-free
|
|
# restore, and a restic snapshot (db dump + instance tree + manifest) at
|
|
# RESTIC_REPOSITORY for a deduped, encrypted, offsite copy.
|
|
#
|
|
# Sequence:
|
|
# 1. mysqldump --single-transaction (InnoDB-consistent, non-blocking)
|
|
# 2. manifest.py stamps core+plugin Alembic revisions + image tag
|
|
# 3. restic backup the dump + /app/instance + manifest as ONE snapshot
|
|
# 4. restic forget --keep-daily 5 --keep-weekly 4 --keep-monthly 6 --prune
|
|
# 5. restic check - fail loudly if the repo is unhealthy, so a bad repo is
|
|
# caught on backup night, not restore night.
|
|
#
|
|
# ASCII only, no emojis - matches the repo style gate.
|
|
set -euo pipefail
|
|
|
|
BACKUPS_ROOT="${SHOPDB_BACKUPS_ROOT:-/backups}"
|
|
SITE="${SHOPDB_SITE:-shopdb}"
|
|
STAMP="$(date -u +%Y-%m-%dT%H%M%SZ)"
|
|
SETNAME="${SITE}-${STAMP}"
|
|
SETDIR="${BACKUPS_ROOT}/${SETNAME}"
|
|
|
|
# shellcheck source=scripts/backup/status.sh
|
|
. /opt/backup/status.sh
|
|
|
|
log() { echo "[backup] $*"; }
|
|
|
|
# Record the run outcome on any exit so a failed backup is visible in the UI
|
|
# history, not just a silent gap.
|
|
BACKUP_DONE=false
|
|
_on_exit() {
|
|
code=$?
|
|
if [ "${BACKUP_DONE}" = true ]; then
|
|
status_write backup done "${SETNAME}" "Backup complete"
|
|
history_append backup success "${SETNAME}" "Backup complete"
|
|
else
|
|
status_write backup failed "${SETNAME}" "Backup failed (exit ${code})"
|
|
history_append backup failed "${SETNAME}" "Backup failed (exit ${code})"
|
|
fi
|
|
}
|
|
trap _on_exit EXIT
|
|
|
|
mkdir -p "${SETDIR}"
|
|
status_write backup database "${SETNAME}" "Dumping database"
|
|
|
|
# 1. Database: whole-DB logical dump, consistent and non-blocking on InnoDB.
|
|
# Dumped WITHOUT --databases on purpose. That flag bakes CREATE DATABASE and USE
|
|
# <name> into the file, which pins the dump to the schema name it was taken
|
|
# from: restoring onto a site whose MYSQL_DATABASE differs loaded the rows into
|
|
# the dump's schema, left the live one untouched, and reported success.
|
|
# --no-tablespaces because dumping tablespace metadata needs the PROCESS
|
|
# privilege, which the app user does not have on MySQL 8.
|
|
log "dumping database ${MYSQL_DATABASE} from ${MYSQL_HOST}"
|
|
mysqldump \
|
|
--host="${MYSQL_HOST}" \
|
|
--user="${MYSQL_USER}" \
|
|
--password="${MYSQL_PASSWORD}" \
|
|
--single-transaction \
|
|
--routines \
|
|
--triggers \
|
|
--no-tablespaces \
|
|
"${MYSQL_DATABASE}" \
|
|
| gzip -c > "${SETDIR}/db.sql.gz"
|
|
log "database dump written ($(du -h "${SETDIR}/db.sql.gz" | cut -f1))"
|
|
|
|
# 2. Manifest: the schema stamp that makes a restore a checked operation.
|
|
# Capture the revision actually applied to the DATABASE (its alembic_version),
|
|
# not just the code head. A restore must gate on the DATA revision; stamping
|
|
# only the code head hides an image that has outrun its own database and lets a
|
|
# behind-schema dump restore under ahead-schema code (1054 Unknown column on
|
|
# every affected table). Query it straight from the dumped DB.
|
|
log "reading applied database revision (alembic_version)"
|
|
DATAREV="$(mysql \
|
|
--host="${MYSQL_HOST}" \
|
|
--user="${MYSQL_USER}" \
|
|
--password="${MYSQL_PASSWORD}" \
|
|
--skip-column-names --batch \
|
|
-e "SELECT version_num FROM alembic_version LIMIT 1;" \
|
|
"${MYSQL_DATABASE}" 2>/dev/null | tr -d '\r')"
|
|
if [ -z "${DATAREV}" ]; then
|
|
# Recorded loudly rather than shrugged off: a set with no data revision
|
|
# cannot be gated on restore, and the gate treats an empty stamp as "no
|
|
# evidence" and refuses it. Better a visible warning now than a set that
|
|
# looks restorable and is not.
|
|
log "WARN could not read alembic_version - this set will NOT be restorable"
|
|
history_append backup warning "${SETNAME}" \
|
|
"Could not read alembic_version; set has no data revision and cannot be restored"
|
|
fi
|
|
|
|
# The same question for every PLUGIN chain. Each plugin owns an
|
|
# alembic_version_<plugin> table, and those move independently of the core one:
|
|
# code rolled back to an older plugin revision can sit at an identical core
|
|
# head while the dump carries plugin tables it has never heard of. Stamping only
|
|
# the core revision let that combination pass the restore gate, load, and then
|
|
# fail during the plugin upgrade with the data already replaced. Read straight
|
|
# from the dumped DB, same as the core revision above.
|
|
log "reading applied plugin revisions (alembic_version_*)"
|
|
DATAPLUGINS="$(mysql \
|
|
--host="${MYSQL_HOST}" \
|
|
--user="${MYSQL_USER}" \
|
|
--password="${MYSQL_PASSWORD}" \
|
|
--skip-column-names --batch \
|
|
-e "SELECT table_name FROM information_schema.tables
|
|
WHERE table_schema = '${MYSQL_DATABASE}'
|
|
AND table_name LIKE 'alembic\\_version\\_%';" 2>/dev/null \
|
|
| tr -d '\r' \
|
|
| while read -r table; do
|
|
[ -n "${table}" ] || continue
|
|
plugin="${table#alembic_version_}"
|
|
rev="$(mysql \
|
|
--host="${MYSQL_HOST}" \
|
|
--user="${MYSQL_USER}" \
|
|
--password="${MYSQL_PASSWORD}" \
|
|
--skip-column-names --batch \
|
|
-e "SELECT version_num FROM \`${table}\` LIMIT 1;" \
|
|
"${MYSQL_DATABASE}" 2>/dev/null | tr -d '\r')"
|
|
[ -n "${rev}" ] && printf '%s\t%s\n' "${plugin}" "${rev}"
|
|
done \
|
|
| python3 -c 'import json,sys; print(json.dumps(dict(l.rstrip("\n").split("\t",1) for l in sys.stdin if "\t" in l)))')"
|
|
[ -n "${DATAPLUGINS}" ] || DATAPLUGINS='{}'
|
|
log "plugin revisions: ${DATAPLUGINS}"
|
|
|
|
log "writing manifest"
|
|
python3 /opt/backup/manifest.py \
|
|
--out "${SETDIR}/manifest.json" \
|
|
--takenat "${STAMP}" \
|
|
--site "${SITE}" \
|
|
--imagetag "${SHOPDB_IMAGE_TAG:-source}" \
|
|
--datarev "${DATAREV}" \
|
|
--dataplugins "${DATAPLUGINS}" \
|
|
--migrations /app/migrations \
|
|
--plugins /app/plugins
|
|
|
|
# Consistency guard: if the DATA revision does not match the CODE head, this
|
|
# backup captures a database the running image has outrun. It is still stored
|
|
# (losing it would be worse) but flagged loudly so a restore of it is a
|
|
# deliberate choice, not a surprise.
|
|
CODEREV="$(python3 -c \
|
|
'import json,sys;print(",".join(sorted(json.load(open(sys.argv[1]))["schemarev"]["core"])))' \
|
|
"${SETDIR}/manifest.json" 2>/dev/null || echo "")"
|
|
if [ -n "${DATAREV}" ] && [ "${DATAREV}" != "${CODEREV}" ]; then
|
|
log "WARN schema drift: database is at '${DATAREV}' but code head is '${CODEREV}'"
|
|
log "WARN this DB has not run 'flask db upgrade' to the running image; restore will upgrade it"
|
|
history_append backup warning "${SETNAME}" "Schema drift: data ${DATAREV} vs code ${CODEREV}"
|
|
fi
|
|
|
|
# 3. restic snapshot: db dump + instance tree + manifest, one point in time.
|
|
# Skips gracefully when restic/repo are not configured (local-only sites).
|
|
IS_CHECK_PASSED=false
|
|
status_write backup snapshot "${SETNAME}" "Snapshotting to backup store"
|
|
if command -v restic >/dev/null 2>&1 && [ -n "${RESTIC_REPOSITORY:-}" ]; then
|
|
if ! restic snapshots >/dev/null 2>&1; then
|
|
log "initializing restic repository at ${RESTIC_REPOSITORY}"
|
|
restic init
|
|
fi
|
|
log "restic backup (db dump + instance tree + manifest)"
|
|
restic backup \
|
|
--tag "${SITE}" \
|
|
--tag "${SETNAME}" \
|
|
"${SETDIR}/db.sql.gz" \
|
|
"${SETDIR}/manifest.json" \
|
|
/app/instance
|
|
|
|
# 4. Retention: any day in the last 5, plus weekly/monthly tail.
|
|
log "pruning per retention policy"
|
|
restic forget \
|
|
--tag "${SITE}" \
|
|
--keep-daily 5 \
|
|
--keep-weekly 4 \
|
|
--keep-monthly 6 \
|
|
--prune
|
|
|
|
# 5. Integrity: learn about corruption now, not at restore time.
|
|
log "verifying repository integrity"
|
|
if restic check; then
|
|
IS_CHECK_PASSED=true
|
|
log "restic check passed"
|
|
else
|
|
log "restic check FAILED - repository may be corrupt"
|
|
fi
|
|
else
|
|
log "restic not configured - local db.sql.gz + manifest only"
|
|
fi
|
|
|
|
# Record the verification result into the manifest so the UI can badge the set.
|
|
python3 /opt/backup/manifest.py \
|
|
--amend "${SETDIR}/manifest.json" \
|
|
--ischeckpassed "${IS_CHECK_PASSED}"
|
|
|
|
# Clear the request sentinel the UI drops when an operator clicks "Back up now".
|
|
rm -f "${BACKUPS_ROOT}/.backup-requested" 2>/dev/null || true
|
|
|
|
BACKUP_DONE=true
|
|
log "backup set ${SETNAME} complete"
|