#!/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}" log() { echo "[backup] $*"; } mkdir -p "${SETDIR}" # 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. log "writing manifest" python3 /opt/backup/manifest.py \ --out "${SETDIR}/manifest.json" \ --takenat "${STAMP}" \ --site "${SITE}" \ --imagetag "${SHOPDB_IMAGE_TAG:-source}" \ --migrations /app/migrations \ --plugins /app/plugins # 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 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 log "backup set ${SETNAME} complete"