#!/usr/bin/env bash # INFRA:DEAD rds -- AWS RDS is gone. Any file referencing rds_connect.py or this hostname is stale and must be ported. # backup.sh — unified backup entrypoint # # Orchestrates restic + Garage + rclone in their correct roles: # # restic → deduplicated encrypted snapshots → Garage (primary repo) # rclone → cold copy of restic repo chunks → gdrive:restic-mirror/ # rclone → sync gdrive:research-stack → Garage:gdrive-mirror # # Commands: # backup.sh snap [tag] — restic snapshot of repo + config (default tag: manual) # backup.sh snap-db [dir] — restic snapshot of SQLite scratch DBs # backup.sh snap-rds — pg_dump | zstd → restic backup → Garage:rds-overflow # backup.sh cold-copy — rclone copy Garage restic chunks → gdrive cold mirror # backup.sh sync-gdrive — rclone sync gdrive:research-stack → Garage:gdrive-mirror # backup.sh verify — restic check --read-data-subset=5% # backup.sh snapshots — list all restic snapshots # backup.sh restore — restic restore snapshot to directory # backup.sh forget — apply retention policy (keep 7 daily, 4 weekly, 6 monthly) # backup.sh full — snap + cold-copy + sync-gdrive + forget set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "${SCRIPT_DIR}/../../../" && pwd)" # ── Load credentials ─────────────────────────────────────────────────────────── if [ -f "${SCRIPT_DIR}/restic.env" ]; then set -a; source "${SCRIPT_DIR}/restic.env"; set +a elif [ -f /etc/garage/garage.env ]; then set -a; source /etc/garage/garage.env; set +a AWS_ACCESS_KEY_ID="${GARAGE_ACCESS_KEY_ID:-}" AWS_SECRET_ACCESS_KEY="${GARAGE_SECRET_ACCESS_KEY:-}" fi : "${RESTIC_REPOSITORY:?RESTIC_REPOSITORY not set — source restic.env first}" : "${RESTIC_PASSWORD_FILE:?RESTIC_PASSWORD_FILE not set}" export AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_DEFAULT_REGION export RESTIC_REPOSITORY RESTIC_PASSWORD_FILE RDS_HOST="${RDS_HOST:-100.92.88.64}" RDS_PORT="${RDS_PORT:-5432}" RDS_USER="${RDS_USER:-postgres}" RDS_DB="${RDS_DB:-postgres}" LOG="${HOME}/.cache/restic-backup.log" mkdir -p "$(dirname "$LOG")" log() { echo "[backup] $(date +%H:%M:%S) $*" | tee -a "$LOG" >&2; } # ── snap: restic backup of the repo tree ────────────────────────────────────── cmd_snap() { local tag="${1:-manual}" log "Snapshotting repo → ${RESTIC_REPOSITORY} [tag:${tag}]" restic backup \ "${REPO_ROOT}" \ --tag "${tag}" \ --exclude ".git/objects" \ --exclude ".git/lfs" \ --exclude ".lake/" \ --exclude "lake-packages/" \ --exclude "__pycache__/" \ --exclude "*.pyc" \ --exclude "node_modules/" \ --exclude ".venv/" \ --exclude "venv/" \ --exclude "*.iso" \ --exclude "containers/" \ --exclude ".local/share/containers/" \ --one-file-system \ 2>&1 | tee -a "$LOG" log "Snapshot complete." } # ── snap-db: SQLite scratch DBs ─────────────────────────────────────────────── cmd_snap_db() { local src_dir="${1:-${REPO_ROOT}}" log "Snapshotting SQLite DBs in ${src_dir}..." # Collect all SQLite files into a temp manifest local tmpdir tmpdir="$(mktemp -d /tmp/restic-db-XXXX)" find "${src_dir}" -maxdepth 5 \ \( -name "*.db" -o -name "*.sqlite" -o -name "*.sqlite3" \) \ -not -path "*/.git/*" -not -path "*/.lake/*" \ -not -path "*/node_modules/*" \ -print0 2>/dev/null \ | while IFS= read -r -d '' f; do # Hard-link into tmpdir to give restic a stable tree local dest="${tmpdir}${f}" mkdir -p "$(dirname "$dest")" ln "$f" "$dest" 2>/dev/null || cp "$f" "$dest" done local count count=$(find "$tmpdir" -type f | wc -l) log " ${count} SQLite file(s) found" if [ "$count" -gt 0 ]; then restic backup "${tmpdir}" \ --tag "db-scratch" \ --host "$(hostname -s)" \ 2>&1 | tee -a "$LOG" fi rm -rf "$tmpdir" log "SQLite snapshot done." } # ── snap-rds: stream pg_dump through restic (no temp file on disk) ──────────── cmd_snap_rds() { local table="${1:?Usage: $0 snap-rds
[schema]}" local schema="${2:-public}" local ts ts="$(date +%Y%m%dT%H%M%S)" local tag="rds-${schema}.${table}" log "Streaming pg_dump ${schema}.${table} → restic [tag:${tag}]..." # restic backup --stdin reads from stdin and stores as a virtual file PGPASSWORD="${RDS_PASSWORD:-}" pg_dump \ -h "${RDS_HOST}" -p "${RDS_PORT}" -U "${RDS_USER}" -d "${RDS_DB}" \ --table="${schema}.${table}" \ --no-owner --no-privileges \ | zstd -3 -T0 \ | restic backup \ --stdin \ --stdin-filename "${schema}.${table}.${ts}.sql.zst" \ --tag "${tag}" \ 2>&1 | tee -a "$LOG" log "RDS snapshot done." } # ── cold-copy: rclone mirrors restic repo chunks to gdrive ──────────────────── cmd_cold_copy() { log "Cold-copying restic repo chunks: Garage → gdrive:restic-mirror/" # Build a temporary rclone config that speaks to Garage local tmpconf tmpconf="$(mktemp /tmp/rclone-garage-XXXX.conf)" cat > "$tmpconf" << RCONF [garage_s3] type = s3 provider = Other endpoint = ${AWS_ENDPOINT_URL:-http://localhost:3900} access_key_id = ${AWS_ACCESS_KEY_ID} secret_access_key = ${AWS_SECRET_ACCESS_KEY} region = ${AWS_DEFAULT_REGION:-garage} force_path_style = true no_check_bucket = true RCONF # rclone copies Garage:research-stack → gdrive:restic-mirror # Uses rclone's own rate-limiting for the gdrive side rclone copy \ "garage_s3:research-stack" \ "gdrive:restic-mirror" \ --config "${tmpconf}" \ --transfers 4 \ --checkers 8 \ --drive-pacer-min-sleep 200ms \ --drive-pacer-burst 10 \ --retries 10 \ --retries-sleep 1s \ --stats 30s \ --log-level INFO \ 2>&1 | tee -a "$LOG" rm -f "$tmpconf" log "Cold copy complete — restic repo mirrored to gdrive:restic-mirror/" } # ── sync-gdrive: rclone mirrors gdrive project folder → Garage ─────────────── cmd_sync_gdrive() { log "Syncing gdrive:research-stack → Garage:gdrive-mirror..." local tmpconf tmpconf="$(mktemp /tmp/rclone-garage-XXXX.conf)" cat > "$tmpconf" << RCONF [garage_s3] type = s3 provider = Other endpoint = ${AWS_ENDPOINT_URL:-http://localhost:3900} access_key_id = ${AWS_ACCESS_KEY_ID} secret_access_key = ${AWS_SECRET_ACCESS_KEY} region = ${AWS_DEFAULT_REGION:-garage} force_path_style = true no_check_bucket = true RCONF rclone sync \ "gdrive:research-stack" \ "garage_s3:gdrive-mirror" \ --config "${tmpconf}" \ --transfers 4 \ --drive-pacer-min-sleep 200ms \ --drive-pacer-burst 10 \ --retries 10 \ --retries-sleep 1s \ --exclude "*.iso" \ --exclude "**/node_modules/**" \ --stats 30s \ --log-level INFO \ 2>&1 | tee -a "$LOG" rm -f "$tmpconf" log "gdrive sync complete." } # ── forget: retention policy ────────────────────────────────────────────────── cmd_forget() { log "Applying retention policy..." restic forget \ --keep-last 3 \ --keep-daily 7 \ --keep-weekly 4 \ --keep-monthly 6 \ --prune \ 2>&1 | tee -a "$LOG" log "Retention policy applied." } # ── verify: spot-check repo integrity ───────────────────────────────────────── cmd_verify() { log "Verifying restic repo (5% data sample)..." restic check --read-data-subset=5% 2>&1 | tee -a "$LOG" } # ── snapshots: list ─────────────────────────────────────────────────────────── cmd_snapshots() { restic snapshots --compact 2>&1 } # ── restore ─────────────────────────────────────────────────────────────────── cmd_restore() { local snap_id="${1:?Usage: $0 restore }" local target="${2:?Usage: $0 restore }" log "Restoring snapshot ${snap_id} → ${target}..." restic restore "${snap_id}" --target "${target}" 2>&1 | tee -a "$LOG" log "Restore complete." } # ── full: everything in one pass ───────────────────────────────────────────── cmd_full() { log "=== Full backup pass ===" cmd_snap "scheduled" cmd_snap_db "${REPO_ROOT}" cmd_cold_copy cmd_sync_gdrive cmd_forget log "=== Full backup pass complete ===" } # ── dispatch ─────────────────────────────────────────────────────────────────── CMD="${1:-snapshots}" shift || true case "$CMD" in snap) cmd_snap "$@" ;; snap-db) cmd_snap_db "$@" ;; snap-rds) cmd_snap_rds "$@" ;; cold-copy) cmd_cold_copy ;; sync-gdrive) cmd_sync_gdrive ;; forget) cmd_forget ;; verify) cmd_verify ;; snapshots) cmd_snapshots ;; restore) cmd_restore "$@" ;; full) cmd_full ;; *) echo "Usage: $0 {snap|snap-db|snap-rds|cold-copy|sync-gdrive|forget|verify|snapshots|restore|full}" >&2 exit 1 ;; esac