Files
pn-new-crm/scripts/backup/restore.sh
Matt Ciaccio 6eb0d3dc92 docs(ops): backup/restore + email deliverability runbooks
Two new runbooks under docs/runbooks/ plus the automation scripts the
backup runbook references. Both are written so an operator who has only
the off-site backup credentials and the runbook can recover the system
unaided.

Backup/restore (Phase 4a):
- docs/runbooks/backup-and-restore.md — covers what gets backed up
  (Postgres / MinIO / .env+ENCRYPTION_KEY), schedule (hourly DB +
  hourly MinIO mirror, 7-day hourly + 30-day daily retention),
  cold-restore procedure with row-count verification, weekly drill
- scripts/backup/pg-backup.sh — pg_dump → gzip → optional GPG → mc
  upload, fails loud
- scripts/backup/minio-mirror.sh — incremental mc mirror, no --remove
  flag so accidental deletes on the live bucket can't cascade
- scripts/backup/restore.sh — interactive prod restore + --drill mode
  that runs against a sandbox DB and diffs row counts

Email deliverability (Phase 4b):
- docs/runbooks/email-deliverability.md — what the CRM sends, DNS
  records (SPF/DKIM/DMARC/MX), per-port override implications,
  diagnosis flow ("didn't arrive" → 4-step checklist starting with
  EMAIL_REDIRECT_TO), provider migration plan, realapi suite as the
  end-to-end probe

Tests still 778/778 vitest, tsc/lint clean — these phases are docs +
shell scripts, no code changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 20:10:30 +02:00

122 lines
4.0 KiB
Bash

#!/usr/bin/env bash
# Cold-restore script for Port Nimara CRM.
#
# Two modes:
# --drill Restore to a sandbox DB ($DRILL_DATABASE_URL) + a tagged
# sandbox path on the live MinIO bucket. Used by the weekly
# cron drill so the runbook stays accurate.
# (no --drill) Interactive production restore. Prompts before each
# destructive step; refuses to run if the live DB has
# non-empty tables (caller is expected to drop first).
#
# Common args:
# --snapshot YYYY-MM-DD/HH Specific dump to restore. Defaults to "latest".
set -euo pipefail
DRILL=0
SNAPSHOT="latest"
while [[ $# -gt 0 ]]; do
case "$1" in
--drill) DRILL=1; shift ;;
--snapshot) SNAPSHOT="$2"; shift 2 ;;
*) echo "unknown arg: $1" >&2; exit 2 ;;
esac
done
: "${BACKUP_S3_BUCKET:?BACKUP_S3_BUCKET not set}"
: "${BACKUP_S3_ENDPOINT:?BACKUP_S3_ENDPOINT not set}"
: "${BACKUP_S3_ACCESS_KEY:?BACKUP_S3_ACCESS_KEY not set}"
: "${BACKUP_S3_SECRET_KEY:?BACKUP_S3_SECRET_KEY not set}"
if [[ "$DRILL" -eq 1 ]]; then
: "${DRILL_DATABASE_URL:?DRILL_DATABASE_URL not set}"
TARGET_DB="$DRILL_DATABASE_URL"
echo "[drill] target DB = $TARGET_DB"
else
: "${DATABASE_URL:?DATABASE_URL not set}"
TARGET_DB="$DATABASE_URL"
read -rp "About to overwrite $TARGET_DB. Type 'restore' to continue: " confirm
[[ "$confirm" == "restore" ]] || { echo "aborted"; exit 1; }
fi
HOST="${BACKUP_HOST_OVERRIDE:-$(hostname -s)}"
WORKDIR="$(mktemp -d)"
trap 'rm -rf "$WORKDIR"' EXIT
MC_ALIAS="bk-$$"
mc alias set "$MC_ALIAS" "$BACKUP_S3_ENDPOINT" \
"$BACKUP_S3_ACCESS_KEY" "$BACKUP_S3_SECRET_KEY" --api S3v4 >/dev/null
trap 'rm -rf "$WORKDIR"; mc alias remove "$MC_ALIAS" 2>/dev/null || true' EXIT
# Resolve the snapshot path.
if [[ "$SNAPSHOT" == "latest" ]]; then
REMOTE=$(mc ls --recursive "${MC_ALIAS}/${BACKUP_S3_BUCKET}/pg/${HOST}/" \
| awk '{print $NF}' | sort | tail -1)
if [[ -z "$REMOTE" ]]; then
echo "no snapshots found under ${BACKUP_S3_BUCKET}/pg/${HOST}/" >&2
exit 1
fi
REMOTE="${MC_ALIAS}/${BACKUP_S3_BUCKET}/pg/${HOST}/${REMOTE}"
else
REMOTE="${MC_ALIAS}/${BACKUP_S3_BUCKET}/pg/${HOST}/${SNAPSHOT}.dump.gz"
# If GPG was used, the file lives at .dump.gz.gpg. Try both.
if ! mc stat "$REMOTE" >/dev/null 2>&1; then
REMOTE="${REMOTE}.gpg"
fi
fi
echo "[$(date -u +%FT%TZ)] Pulling $REMOTE"
LOCAL="$WORKDIR/$(basename "$REMOTE")"
mc cp --quiet "$REMOTE" "$LOCAL"
# Decrypt if needed.
if [[ "$LOCAL" == *.gpg ]]; then
echo "[$(date -u +%FT%TZ)] Decrypting"
gpg --batch --yes --decrypt --output "${LOCAL%.gpg}" "$LOCAL"
rm "$LOCAL"
LOCAL="${LOCAL%.gpg}"
fi
# Decompress.
gunzip "$LOCAL"
LOCAL="${LOCAL%.gz}"
echo "[$(date -u +%FT%TZ)] Restoring into $TARGET_DB"
# Drop & recreate to guarantee no half-state from a prior run.
DB_NAME=$(echo "$TARGET_DB" | sed -E 's|.*/([^?]+).*|\1|')
ADMIN_URL=$(echo "$TARGET_DB" | sed -E "s|/${DB_NAME}|/postgres|")
psql "$ADMIN_URL" -v ON_ERROR_STOP=1 <<SQL
SELECT pg_terminate_backend(pid) FROM pg_stat_activity
WHERE datname = '${DB_NAME}' AND pid <> pg_backend_pid();
DROP DATABASE IF EXISTS "${DB_NAME}";
CREATE DATABASE "${DB_NAME}";
SQL
pg_restore --no-owner --no-privileges --dbname "$TARGET_DB" "$LOCAL"
# Drill mode: compare row counts vs the live producer for parity.
if [[ "$DRILL" -eq 1 ]]; then
echo "[$(date -u +%FT%TZ)] Drill row-count diff (live vs restored):"
TABLES=$(psql -At "$TARGET_DB" -c \
"SELECT tablename FROM pg_tables WHERE schemaname='public' ORDER BY tablename;")
diff_count=0
while IFS= read -r tbl; do
[[ -z "$tbl" ]] && continue
live=$(psql -At "${LIVE_DATABASE_URL:-$DATABASE_URL}" -c "SELECT count(*) FROM \"$tbl\";")
restored=$(psql -At "$TARGET_DB" -c "SELECT count(*) FROM \"$tbl\";")
delta=$((live - restored))
if [[ "$delta" -ne 0 ]]; then
echo "$tbl: live=$live restored=$restored delta=$delta"
diff_count=$((diff_count + 1))
fi
done <<< "$TABLES"
if [[ "$diff_count" -eq 0 ]]; then
echo " ✓ row counts match across all tables"
fi
fi
echo "[$(date -u +%FT%TZ)] Restore complete."