#!/usr/bin/env bash # ============================================================================= # Experiments DB — incremental backup script # # Usage: # backup.sh daily – backup only if DB changed since last run; keep 5 # backup.sh weekly – unconditional backup; keep 2 # # Cron (added by setup_cron.sh): # 0 2 * * * /home/sam/docker-images/experiments-database/scripts/backup.sh daily # 0 3 * * 0 /home/sam/docker-images/experiments-database/scripts/backup.sh weekly # ============================================================================= set -euo pipefail # ── Config ──────────────────────────────────────────────────────────────────── MODE="${1:-daily}" BACKUP_ROOT="/home/sam/synology/Backups/Experiments-DB-Backup" DAILY_DIR="$BACKUP_ROOT/daily" WEEKLY_DIR="$BACKUP_ROOT/weekly" STATE_FILE="$BACKUP_ROOT/.last_backup_state" LOG_FILE="$BACKUP_ROOT/backup.log" KEEP_DAILY=5 KEEP_WEEKLY=2 PG_CONTAINER="experiments_postgres" PG_USER="expuser" PG_DB="experiments_db" # ── Helpers ─────────────────────────────────────────────────────────────────── log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "$LOG_FILE"; } die() { log "ERROR: $*"; exit 1; } # Ensure directories exist mkdir -p "$DAILY_DIR" "$WEEKLY_DIR" # Check postgres container is running if ! docker inspect --format '{{.State.Running}}' "$PG_CONTAINER" 2>/dev/null | grep -q true; then die "Container $PG_CONTAINER is not running." fi # ── DB state fingerprint ────────────────────────────────────────────────────── # Combines row counts from all key tables + latest audit_log timestamp. # Changes in any table (insert, update, delete) alter this string. get_db_state() { docker exec "$PG_CONTAINER" psql -U "$PG_USER" -d "$PG_DB" -t -A -c " SELECT (SELECT COUNT(*) FROM experiments) || ',' || (SELECT COUNT(*) FROM animals) || ',' || (SELECT COUNT(*) FROM daily_statuses) || ',' || (SELECT COUNT(*) FROM daily_analyses) || ',' || (SELECT COUNT(*) FROM session_files) || ',' || COALESCE( (SELECT MAX(timestamp)::text FROM audit_logs), 'no-audit' ) AS fingerprint; " 2>/dev/null | tr -d '[:space:]' } # ── Dump function ───────────────────────────────────────────────────────────── run_dump() { local dest="$1" local tmp="${dest}.tmp" log "Dumping → $dest" docker exec "$PG_CONTAINER" pg_dump \ -U "$PG_USER" \ --format=custom \ --compress=9 \ "$PG_DB" > "$tmp" \ && mv "$tmp" "$dest" \ || { rm -f "$tmp"; die "pg_dump failed."; } local size size=$(du -sh "$dest" | cut -f1) log "Backup written ($size): $(basename "$dest")" } # ── Prune old backups ───────────────────────────────────────────────────────── prune() { local dir="$1" local keep="$2" local count count=$(ls -1 "$dir"/*.pgdump 2>/dev/null | wc -l) if (( count > keep )); then ls -1t "$dir"/*.pgdump | tail -n "+$((keep + 1))" | while read -r f; do log "Pruning old backup: $(basename "$f")" rm -f "$f" done fi } # ── Main ────────────────────────────────────────────────────────────────────── TIMESTAMP=$(date '+%Y%m%d_%H%M%S') if [[ "$MODE" == "daily" ]]; then log "--- Daily backup check ---" CURRENT_STATE=$(get_db_state) if [[ -z "$CURRENT_STATE" ]]; then die "Could not read DB state." fi LAST_STATE=$(cat "$STATE_FILE" 2>/dev/null || echo "") if [[ "$CURRENT_STATE" == "$LAST_STATE" ]]; then log "No changes detected since last backup. Skipping." exit 0 fi DEST="$DAILY_DIR/experiments_db_daily_${TIMESTAMP}.pgdump" run_dump "$DEST" # Save new state only after successful dump echo "$CURRENT_STATE" > "$STATE_FILE" log "State fingerprint updated." prune "$DAILY_DIR" "$KEEP_DAILY" log "Daily backup complete." elif [[ "$MODE" == "weekly" ]]; then log "--- Weekly backup (unconditional) ---" DEST="$WEEKLY_DIR/experiments_db_weekly_${TIMESTAMP}.pgdump" run_dump "$DEST" prune "$WEEKLY_DIR" "$KEEP_WEEKLY" log "Weekly backup complete." else die "Unknown mode '$MODE'. Use 'daily' or 'weekly'." fi