Backups are per-site and not part of the base stack, so they ship as a separate compose overlay with an isolated restic REST server the app can only reach through the backup utility (no shell or db creds). The sidecar image trusts the corporate proxy root CA at build time so its Oracle yum, github, and PyPI fetches succeed behind TLS interception; shell scripts are pinned to LF via .gitattributes so the container entrypoints do not die on a CRLF shebang.
99 lines
3.2 KiB
Bash
99 lines
3.2 KiB
Bash
#!/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"
|