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>
122 lines
4.0 KiB
Bash
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."
|