From 704f29362a7dd49049f67fb40ef426aa7a51ffab Mon Sep 17 00:00:00 2001 From: olsch01 Date: Mon, 2 Mar 2026 14:40:51 -0500 Subject: [PATCH] Add database backup/restore script with auto-pruning scripts/db-backup.sh provides three commands: - backup: creates a timestamped, gzipped pg_dump (custom format) in ./backups/ - restore: drops, recreates, and loads a backup with confirmation prompt - list: shows available backups with sizes and dates Supports --keep N flag for automatic pruning of backups older than N days, making it cron-friendly for daily automated backups. Also adds backups/ and *.dump.gz to .gitignore. Co-Authored-By: Claude Opus 4.6 --- .gitignore | 5 + scripts/db-backup.sh | 296 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 301 insertions(+) create mode 100755 scripts/db-backup.sh diff --git a/.gitignore b/.gitignore index 0397805..c71871d 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,11 @@ postgres_data/ redis_data/ pgdata/ +# Database backups +backups/ +*.dump +*.dump.gz + # SSL letsencrypt/ diff --git a/scripts/db-backup.sh b/scripts/db-backup.sh new file mode 100755 index 0000000..84e097f --- /dev/null +++ b/scripts/db-backup.sh @@ -0,0 +1,296 @@ +#!/usr/bin/env bash +# --------------------------------------------------------------------------- +# db-backup.sh — Backup & restore the HOA LedgerIQ PostgreSQL database +# +# Usage: +# ./scripts/db-backup.sh backup [--dir /path/to/backups] [--keep N] +# ./scripts/db-backup.sh restore +# ./scripts/db-backup.sh list [--dir /path/to/backups] +# +# Backup produces a gzipped custom-format dump with a timestamped filename: +# backups/hoafinance_2026-03-02_140530.dump.gz +# +# Cron example (daily at 2 AM, keep 30 days): +# 0 2 * * * cd /opt/hoa-ledgeriq && ./scripts/db-backup.sh backup --keep 30 +# --------------------------------------------------------------------------- + +set -euo pipefail + +# ---- Defaults ---- +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +BACKUP_DIR="$PROJECT_DIR/backups" +KEEP_DAYS=0 # 0 = keep forever +DB_USER="${POSTGRES_USER:-hoafinance}" +DB_NAME="${POSTGRES_DB:-hoafinance}" +COMPOSE_CMD="docker compose" + +# If running with the SSL override, detect it +if [ -f "$PROJECT_DIR/docker-compose.ssl.yml" ] && \ + docker compose -f "$PROJECT_DIR/docker-compose.yml" \ + -f "$PROJECT_DIR/docker-compose.ssl.yml" ps --quiet 2>/dev/null | head -1 | grep -q .; then + COMPOSE_CMD="docker compose -f $PROJECT_DIR/docker-compose.yml -f $PROJECT_DIR/docker-compose.ssl.yml" +fi + +# ---- Colors ---- +RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; NC='\033[0m' + +info() { echo -e "${CYAN}[INFO]${NC} $*"; } +ok() { echo -e "${GREEN}[OK]${NC} $*"; } +warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } +err() { echo -e "${RED}[ERROR]${NC} $*" >&2; } +die() { err "$@"; exit 1; } + +# ---- Helpers ---- + +ensure_postgres_running() { + if ! $COMPOSE_CMD ps postgres 2>/dev/null | grep -q "running\|Up"; then + die "PostgreSQL container is not running. Start it with: docker compose up -d postgres" + fi +} + +format_size() { + local bytes=$1 + if (( bytes >= 1073741824 )); then printf "%.1f GB" "$(echo "$bytes / 1073741824" | bc -l)" + elif (( bytes >= 1048576 )); then printf "%.1f MB" "$(echo "$bytes / 1048576" | bc -l)" + elif (( bytes >= 1024 )); then printf "%.1f KB" "$(echo "$bytes / 1024" | bc -l)" + else printf "%d B" "$bytes" + fi +} + +# ---- BACKUP ---- + +do_backup() { + ensure_postgres_running + mkdir -p "$BACKUP_DIR" + + local timestamp + timestamp="$(date +%Y-%m-%d_%H%M%S)" + local filename="${DB_NAME}_${timestamp}.dump.gz" + local filepath="$BACKUP_DIR/$filename" + + info "Starting backup of database '${DB_NAME}' ..." + + # pg_dump inside the container, stream through gzip on the host + $COMPOSE_CMD exec -T postgres pg_dump \ + -U "$DB_USER" \ + -d "$DB_NAME" \ + --no-owner \ + --no-privileges \ + --format=custom \ + | gzip -9 > "$filepath" + + local size + size=$(wc -c < "$filepath" | tr -d ' ') + + if [ "$size" -lt 100 ]; then + rm -f "$filepath" + die "Backup file is suspiciously small — something went wrong. Check docker compose logs postgres." + fi + + ok "Backup complete: ${filepath} ($(format_size "$size"))" + + # ---- Prune old backups ---- + if [ "$KEEP_DAYS" -gt 0 ]; then + local pruned=0 + while IFS= read -r old_file; do + rm -f "$old_file" + ((pruned++)) + done < <(find "$BACKUP_DIR" -name "${DB_NAME}_*.dump.gz" -mtime +"$KEEP_DAYS" -type f 2>/dev/null) + if [ "$pruned" -gt 0 ]; then + info "Pruned $pruned backup(s) older than $KEEP_DAYS days" + fi + fi +} + +# ---- RESTORE ---- + +do_restore() { + local file="$1" + + # Resolve relative path + if [[ "$file" != /* ]]; then + file="$(pwd)/$file" + fi + + [ -f "$file" ] || die "File not found: $file" + + ensure_postgres_running + + echo "" + warn "This will DESTROY the current '${DB_NAME}' database and replace it" + warn "with the contents of: $(basename "$file")" + echo "" + read -rp "Type 'yes' to continue: " confirm + [ "$confirm" = "yes" ] || { info "Aborted."; exit 0; } + + echo "" + info "Step 1/4 — Terminating active connections ..." + $COMPOSE_CMD exec -T postgres psql -U "$DB_USER" -d postgres -c " + SELECT pg_terminate_backend(pid) + FROM pg_stat_activity + WHERE datname = '${DB_NAME}' AND pid <> pg_backend_pid(); + " > /dev/null 2>&1 || true + + info "Step 2/4 — Dropping and recreating database ..." + $COMPOSE_CMD exec -T postgres dropdb -U "$DB_USER" --if-exists "$DB_NAME" + $COMPOSE_CMD exec -T postgres createdb -U "$DB_USER" "$DB_NAME" + + info "Step 3/4 — Restoring from $(basename "$file") ..." + + if [[ "$file" == *.dump.gz ]]; then + # Custom-format dump, gzipped → decompress and pipe to pg_restore + gunzip -c "$file" | $COMPOSE_CMD exec -T postgres pg_restore \ + -U "$DB_USER" \ + -d "$DB_NAME" \ + --no-owner \ + --no-privileges \ + --exit-on-error 2>&1 | tail -5 || true + + elif [[ "$file" == *.sql.gz ]]; then + # Plain SQL dump, gzipped → decompress and pipe to psql + gunzip -c "$file" | $COMPOSE_CMD exec -T postgres psql \ + -U "$DB_USER" \ + -d "$DB_NAME" \ + --quiet 2>&1 | tail -5 || true + + elif [[ "$file" == *.dump ]]; then + # Custom-format dump, not compressed + $COMPOSE_CMD exec -T postgres pg_restore \ + -U "$DB_USER" \ + -d "$DB_NAME" \ + --no-owner \ + --no-privileges \ + --exit-on-error < "$file" 2>&1 | tail -5 || true + + elif [[ "$file" == *.sql ]]; then + # Plain SQL dump, not compressed + $COMPOSE_CMD exec -T postgres psql \ + -U "$DB_USER" \ + -d "$DB_NAME" \ + --quiet < "$file" 2>&1 | tail -5 || true + + else + die "Unsupported file format. Expected .dump.gz, .sql.gz, .dump, or .sql" + fi + + info "Step 4/4 — Restarting backend ..." + $COMPOSE_CMD restart backend > /dev/null 2>&1 + + echo "" + ok "Restore complete. Backend restarted." + + # Quick sanity check + local tenant_count + tenant_count=$($COMPOSE_CMD exec -T postgres psql -U "$DB_USER" -d "$DB_NAME" \ + -t -c "SELECT count(*) FROM shared.organizations WHERE status = 'active';" 2>/dev/null | tr -d ' ') + info "Active tenants found: ${tenant_count:-0}" +} + +# ---- LIST ---- + +do_list() { + mkdir -p "$BACKUP_DIR" + + local count=0 + echo "" + printf " %-42s %10s %s\n" "FILENAME" "SIZE" "DATE" + printf " %-42s %10s %s\n" "--------" "----" "----" + + while IFS= read -r f; do + [ -z "$f" ] && continue + local size + size=$(wc -c < "$f" | tr -d ' ') + local mod_date + mod_date=$(date -r "$f" "+%Y-%m-%d %H:%M" 2>/dev/null || stat -c '%y' "$f" 2>/dev/null | cut -d. -f1) + printf " %-42s %10s %s\n" "$(basename "$f")" "$(format_size "$size")" "$mod_date" + ((count++)) + done < <(find "$BACKUP_DIR" -name "${DB_NAME}_*" -type f 2>/dev/null | sort) + + echo "" + if [ "$count" -eq 0 ]; then + info "No backups found in $BACKUP_DIR" + else + info "$count backup(s) in $BACKUP_DIR" + fi +} + +# ---- CLI ---- + +usage() { + cat </dev/null || true + +[ -z "$COMMAND" ] && usage + +# Parse flags +RESTORE_FILE="" +while [ $# -gt 0 ]; do + case "$1" in + --dir) BACKUP_DIR="$2"; shift 2 ;; + --keep) KEEP_DAYS="$2"; shift 2 ;; + --help) usage ;; + *) + if [ "$COMMAND" = "restore" ] && [ -z "$RESTORE_FILE" ]; then + RESTORE_FILE="$1"; shift + else + die "Unknown argument: $1" + fi + ;; + esac +done + +# Load .env if present (for POSTGRES_USER / POSTGRES_DB) +if [ -f "$PROJECT_DIR/.env" ]; then + set -a + # shellcheck disable=SC1091 + source "$PROJECT_DIR/.env" + set +a + DB_USER="${POSTGRES_USER:-hoafinance}" + DB_NAME="${POSTGRES_DB:-hoafinance}" +fi + +case "$COMMAND" in + backup) + do_backup + ;; + restore) + [ -z "$RESTORE_FILE" ] && die "Usage: $(basename "$0") restore " + do_restore "$RESTORE_FILE" + ;; + list) + do_list + ;; + -h|--help|help) + usage + ;; + *) + die "Unknown command: $COMMAND (try: backup, restore, list)" + ;; +esac