#!/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. log "dumping database ${MYSQL_DATABASE} from ${MYSQL_HOST}" mysqldump \ --host="${MYSQL_HOST}" \ --user="${MYSQL_USER}" \ --password="${MYSQL_PASSWORD}" \ --single-transaction \ --routines \ --triggers \ --databases "${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')" [ -n "${DATAREV}" ] || log "WARN could not read alembic_version - datarev will be empty" # The same question for every PLUGIN chain. Each plugin owns an # alembic_version_ 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"