#!/usr/bin/env bash # Postgres backup — Tier 1 (irreplaceable) data per docs/BACKUP_DR.md. Runs via cron on # rn-platform-01 (deployed by the `backup` Ansible role), and locally testable against ANY # reachable Postgres (that's exactly what infra/backup/test-backup-restore.sh does). # # Produces one gzip-compressed pg_dump per run, encrypts it, prunes local copies past # BACKUP_RETENTION_DAYS, and (if BACKUP_OFFSITE_TARGET is set) rsyncs the encrypted copy # off-host. Writes a Prometheus textfile-collector metric so infra/monitoring/'s BackupFailed/ # BackupAging alerts have real data to fire on. # # Required env: PLATFORM_DB_URL, BACKUP_DIR, BACKUP_ENCRYPTION_PASSPHRASE # Optional env: BACKUP_RETENTION_DAYS (default 30), BACKUP_OFFSITE_TARGET, METRICS_TEXTFILE_DIR set -euo pipefail : "${PLATFORM_DB_URL:?PLATFORM_DB_URL is required}" : "${BACKUP_DIR:?BACKUP_DIR is required}" : "${BACKUP_ENCRYPTION_PASSPHRASE:?BACKUP_ENCRYPTION_PASSPHRASE is required — see docs/BACKUP_DR.md}" BACKUP_RETENTION_DAYS="${BACKUP_RETENTION_DAYS:-30}" METRICS_TEXTFILE_DIR="${METRICS_TEXTFILE_DIR:-}" TS="$(date -u +%Y%m%dT%H%M%SZ)" mkdir -p "$BACKUP_DIR" DUMP_FILE="$BACKUP_DIR/rootnode-platform-$TS.sql.gz" ENC_FILE="$DUMP_FILE.gpg" write_metric() { # $1 = metric line. Written atomically (tmp+rename) so Prometheus's node-exporter # textfile-collector never scrapes a half-written file. [ -n "$METRICS_TEXTFILE_DIR" ] || return 0 mkdir -p "$METRICS_TEXTFILE_DIR" local tmp; tmp="$(mktemp "$METRICS_TEXTFILE_DIR/.rootnode_backup.XXXXXX")" echo "$1" > "$tmp" mv -f "$tmp" "$METRICS_TEXTFILE_DIR/rootnode_backup.prom" } fail() { echo "BACKUP FAILED: $1" >&2 write_metric "rootnode_backup_last_run_success 0" exit 1 } trap 'fail "unexpected error at line $LINENO"' ERR echo "==> Dumping $PLATFORM_DB_URL to $DUMP_FILE" pg_dump --format=plain --no-owner --no-privileges "$PLATFORM_DB_URL" | gzip -9 > "$DUMP_FILE" # Fail loudly on a suspiciously small dump (empty/truncated) rather than "successfully" # backing up nothing — a real, if crude, sanity check. DUMP_SIZE=$(stat -c%s "$DUMP_FILE" 2>/dev/null || stat -f%z "$DUMP_FILE") [ "$DUMP_SIZE" -gt 1024 ] || fail "dump is suspiciously small ($DUMP_SIZE bytes) — refusing to treat this as a real backup" echo "==> Encrypting (AES-256, symmetric passphrase — see docs/BACKUP_DR.md for the recovery-key handling policy)" gpg --batch --yes --passphrase "$BACKUP_ENCRYPTION_PASSPHRASE" --symmetric --cipher-algo AES256 \ --output "$ENC_FILE" "$DUMP_FILE" rm -f "$DUMP_FILE" # never leave the plaintext dump on disk once the encrypted copy exists echo "==> Local backup complete: $ENC_FILE ($(stat -c%s "$ENC_FILE" 2>/dev/null || stat -f%z "$ENC_FILE") bytes)" if [ -n "${BACKUP_OFFSITE_TARGET:-}" ]; then echo "==> Copying off-host to $BACKUP_OFFSITE_TARGET" # rsync over SSH works for both a Hetzner Storage Box (SSH/SFTP-native) and any other # SSH-reachable target — the adapter point the Phase 4 spec asked for is this one variable, # not a vendor-specific script. An S3-compatible target uses `aws s3 cp` / `rclone` instead; # see docs/BACKUP_DR.md for both forms. rsync -az --timeout=120 "$ENC_FILE" "$BACKUP_OFFSITE_TARGET/" \ || fail "off-host copy to $BACKUP_OFFSITE_TARGET failed" else echo "==> BACKUP_OFFSITE_TARGET not set — local-only backup (fine for a local test run, NOT fine for production, see docs/BACKUP_DR.md)" fi echo "==> Pruning local backups older than $BACKUP_RETENTION_DAYS days" find "$BACKUP_DIR" -name 'rootnode-platform-*.sql.gz.gpg' -mtime "+$BACKUP_RETENTION_DAYS" -delete write_metric "rootnode_backup_last_run_success 1 rootnode_backup_last_success_timestamp_seconds $(date +%s) rootnode_backup_size_bytes $(stat -c%s "$ENC_FILE" 2>/dev/null || stat -f%z "$ENC_FILE")" echo "==> Backup succeeded"