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 <noreply@anthropic.com>
297 lines
8.5 KiB
Bash
Executable File
297 lines
8.5 KiB
Bash
Executable File
#!/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 <file.sql.gz | file.dump.gz>
|
|
# ./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 <<EOF
|
|
HOA LedgerIQ Database Backup & Restore
|
|
|
|
Usage:
|
|
$(basename "$0") backup [--dir DIR] [--keep DAYS] Create a timestamped gzipped backup
|
|
$(basename "$0") restore FILE Restore from a backup file
|
|
$(basename "$0") list [--dir DIR] List available backups
|
|
|
|
Options:
|
|
--dir DIR Backup directory (default: ./backups)
|
|
--keep DAYS Auto-delete backups older than DAYS (default: keep all)
|
|
|
|
Supported restore formats:
|
|
.dump.gz Custom-format pg_dump, gzipped (default backup format)
|
|
.sql.gz Plain SQL dump, gzipped
|
|
.dump Custom-format pg_dump, uncompressed
|
|
.sql Plain SQL dump, uncompressed
|
|
|
|
Cron example (daily at 2 AM, retain 30 days):
|
|
0 2 * * * cd /opt/hoa-ledgeriq && ./scripts/db-backup.sh backup --keep 30
|
|
|
|
EOF
|
|
exit 0
|
|
}
|
|
|
|
# Parse command
|
|
COMMAND="${1:-}"
|
|
shift 2>/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 <file>"
|
|
do_restore "$RESTORE_FILE"
|
|
;;
|
|
list)
|
|
do_list
|
|
;;
|
|
-h|--help|help)
|
|
usage
|
|
;;
|
|
*)
|
|
die "Unknown command: $COMMAND (try: backup, restore, list)"
|
|
;;
|
|
esac
|