1
0
Fork 0
polaris-task-force/scripts/sync-prod.sh
Z8MB1E d9253e6725 chore(payload): regenerate types and schema, harden sequence reset
- Regenerate payload-types and the drizzle schema for the new collections and
  fields (promotion nominations, leadership transfers, the accessory asset
  type, the Ornith model settings) and register the new migrations.
- Fix the reset-sequences SQL function to skip empty tables, and update the
  prod sync script to match.
2026-10-03 03:30:43 -04:00

622 lines
23 KiB
Bash
Executable file

#!/usr/bin/env bash
#
# sync-prod.sh — Sync production data to dev or staging.
#
# Dumps the production database (ptf_app_prd) and restores it into the target
# environment's database. Optionally syncs media uploads via SSH. Backs up the
# target database and media before overwriting (one backup retained).
#
# Usage:
# ./scripts/sync-prod.sh [OPTIONS]
#
# Examples:
# ./scripts/sync-prod.sh # sync DB + media → dev
# ./scripts/sync-prod.sh --target stg # sync DB + media → staging
# ./scripts/sync-prod.sh --dry-run # preview without writing
# ./scripts/sync-prod.sh --skip-media # DB only, no media sync
# ./scripts/sync-prod.sh --backup-only # backup current target state
# ./scripts/sync-prod.sh --list-backups # show available backups
set -euo pipefail
# ─── Configuration ────────────────────────────────────────────────────────────
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
BACKUP_DIR="${PROJECT_ROOT}/backups"
# Environment targets
declare -A ENV_CONFIG=(
[dev]=".env"
[stg]=".env.stg"
)
# Defaults
TARGET="dev"
DRY_RUN=false
AUTO_YES=false
SKIP_MEDIA=false
BACKUP_ONLY=false
LIST_BACKUPS=false
NO_MIGRATE=false
SOURCE_FILE="${PROJECT_ROOT}/.env.prod"
# Media sync defaults — override via env or CLI flags
MEDIA_HOST="${MEDIA_HOST:-192.168.1.4}"
MEDIA_USER="${MEDIA_USER:-root}"
MEDIA_PATH="${MEDIA_PATH:-/app/media}"
PROD_CONTAINER="${PROD_CONTAINER:-polaris-task-force-main-website}"
# ─── Helpers ──────────────────────────────────────────────────────────────────
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'
info() { echo -e "${BLUE}[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; }
usage() {
cat <<'EOF'
sync-prod.sh — Sync production data to dev or staging databases.
USAGE
./scripts/sync-prod.sh [OPTIONS]
OPTIONS
--target, -t DEV|STG Target environment (default: dev)
--dry-run, -n Show what would happen without making changes
--yes, -y Skip confirmation prompts
--skip-media Sync database only, skip media uploads
--no-migrate Skip running pending Payload migrations after restore
--backup-only Backup target state without syncing
--list-backups List available backups
--source FILE Production env file (default: .env.prod)
--media-host HOST Media server hostname (default: 192.168.1.4)
--media-user USER SSH user for media server (default: root)
--media-path PATH Remote media directory (default: /app/media)
--prod-container NAME Production Docker container name
--help, -h Show this help
ENVIRONMENT VARIABLES
These can be set in .env.prod or exported in your shell:
PROD_DATABASE_URI Production PostgreSQL connection string (required)
PROD_HOST Production server hostname (for SSH media sync)
PROD_USER SSH user for production server
MEDIA_HOST Media server hostname (default: 192.168.1.4)
MEDIA_USER SSH user for media server (default: root)
MEDIA_PATH Remote media path (default: /app/media)
SETUP
1. Copy .env.prod.example to .env.prod and fill in production credentials.
2. Ensure SSH key access to the production server (for media sync).
3. Run: ./scripts/sync-prod.sh
WHAT IT DOES
Database:
- Dumps production DB (ptf_app_prd) via pg_dump
- Backs up the target database before restore
- Restores into the target environment's database
- Runs pending Payload migrations (keeps schema in sync with dev code)
- Resets all sequences to prevent ID collisions
- Preserves the target's AI Compose prompts (plugin_ai_instructions)
so the AI plugin does not regenerate them on the next boot
Media (unless --skip-media):
- Backs up current media directory
- Streams /app/media from the production Docker container via SSH
- Extracts to local media directory
Safety:
- Confirmation prompt before any changes (skip with --yes)
- Creates timestamped backups before overwriting
- --dry-run shows what would happen without writing
- Backups stored in backups/ (gitignored)
BACKUPS
./scripts/sync-prod.sh --list-backups # show available backups
backups/<timestamp>/ # DB dump + media archive
backups/current-media-backup -> latest # symlink to most recent
EOF
}
# ─── Parse arguments ──────────────────────────────────────────────────────────
while [[ $# -gt 0 ]]; do
case "$1" in
--target|-t) TARGET="$2"; shift 2 ;;
--dry-run|-n) DRY_RUN=true; shift ;;
--yes|-y) AUTO_YES=true; shift ;;
--skip-media) SKIP_MEDIA=true; shift ;;
--no-migrate) NO_MIGRATE=true; shift ;;
--backup-only) BACKUP_ONLY=true; shift ;;
--list-backups) LIST_BACKUPS=true; shift ;;
--source) SOURCE_FILE="$2"; shift 2 ;;
--media-host) MEDIA_HOST="$2"; shift 2 ;;
--media-user) MEDIA_USER="$2"; shift 2 ;;
--media-path) MEDIA_PATH="$2"; shift 2 ;;
--prod-container) PROD_CONTAINER="$2"; shift 2 ;;
--help|-h) usage; exit 0 ;;
*) die "Unknown option: $1 (use --help for usage)" ;;
esac
done
# ─── Load environment ─────────────────────────────────────────────────────────
load_env() {
local src="$1"
[[ -f "$src" ]] || die "Source file not found: $src
Create it from the template:
cp .env.prod.example .env.prod
# Edit .env.prod with your production credentials"
set -a
# shellcheck disable=SC1090
source "$src"
set +a
}
# ─── Backup helpers ───────────────────────────────────────────────────────────
TS=""
BACKUP_PATH=""
MEDIA_BACKUP_PATH=""
setup_backup_dirs() {
BACKUP_PATH="${BACKUP_DIR}/${TS}"
mkdir -p "${BACKUP_PATH}/db"
mkdir -p "${BACKUP_PATH}/media"
ok "Backup directory: ${BACKUP_PATH}"
}
backup_target_db() {
info "Backing up target database (${TARGET_DB_NAME})..."
if $DRY_RUN; then
info "[DRY RUN] Would dump target database to ${BACKUP_PATH}/db/"
return 0
fi
if ! PGPASSWORD="$TARGET_DB_PASS" pg_dump \
-h "$TARGET_DB_HOST" \
-p "$TARGET_DB_PORT" \
-U "$TARGET_DB_USER" \
-d "$TARGET_DB_NAME" \
--no-owner --no-privileges --no-acl \
-Fc \
-f "${BACKUP_PATH}/db/${TARGET}.dump" 2>&1; then
warn "Target database backup failed — continuing anyway (target may be empty)"
return 0
fi
ok "Target database backed up → ${BACKUP_PATH}/db/${TARGET}.dump"
}
backup_media() {
info "Backing up current media..."
if $DRY_RUN; then
info "[DRY RUN] Would backup media to ${BACKUP_PATH}/media/"
return 0
fi
if [[ ! -d "$PROJECT_ROOT/media" ]]; then
warn "No local media directory to backup"
return 0
fi
if ! tar -czf "${BACKUP_PATH}/media/media.tar.gz" \
-C "$PROJECT_ROOT" media 2>&1; then
warn "Media backup failed — continuing anyway"
return 0
fi
MEDIA_BACKUP_PATH="${BACKUP_PATH}/media/media.tar.gz"
# Update the "current" symlink
rm -f "${BACKUP_DIR}/current-media-backup"
ln -s "$BACKUP_PATH" "${BACKUP_DIR}/current-media-backup"
ok "Media backed up → ${BACKUP_PATH}/media/media.tar.gz"
}
# ─── Database sync ────────────────────────────────────────────────────────────
# The AI plugin (@ai-stack/payloadcms) stores its Compose prompts in the
# plugin_ai_instructions table. They are environment-independent config-ish
# data, so preserve the target's rows across the restore instead of inheriting
# prod's (possibly empty/stale) copy, which would force a full prompt
# regeneration on the next dev boot.
AI_INSTRUCTIONS_TABLE="plugin_ai_instructions"
AI_INSTRUCTIONS_IMAGES_TABLE="plugin_ai_instructions_images"
table_exists() {
local db="$1" table="$2"
PGPASSWORD="$TARGET_DB_PASS" psql \
-h "$TARGET_DB_HOST" -p "$TARGET_DB_PORT" -U "$TARGET_DB_USER" -d "$db" \
-tAc "SELECT to_regclass('public.${table}') IS NOT NULL;" 2>/dev/null
}
preserve_ai_instructions() {
if $DRY_RUN; then
info "[DRY RUN] Would preserve ${AI_INSTRUCTIONS_TABLE} rows before restore"
return 0
fi
rm -f "${BACKUP_PATH}/db/${TARGET}-ai-instructions.sql" \
"${BACKUP_PATH}/db/${TARGET}-ai-instructions-images.sql"
if [[ "$(table_exists "$TARGET_DB_NAME" "$AI_INSTRUCTIONS_TABLE")" != "t" ]]; then
info "No ${AI_INSTRUCTIONS_TABLE} table in target, nothing to preserve"
return 0
fi
if PGPASSWORD="$TARGET_DB_PASS" pg_dump \
-h "$TARGET_DB_HOST" -p "$TARGET_DB_PORT" -U "$TARGET_DB_USER" \
-d "$TARGET_DB_NAME" \
--table="$AI_INSTRUCTIONS_TABLE" --data-only \
--no-owner --no-privileges \
-f "${BACKUP_PATH}/db/${TARGET}-ai-instructions.sql" 2>&1; then
ok "Preserved AI Compose prompts (${AI_INSTRUCTIONS_TABLE})"
else
warn "Could not dump ${AI_INSTRUCTIONS_TABLE}; prompts will be re-seeded on next boot"
rm -f "${BACKUP_PATH}/db/${TARGET}-ai-instructions.sql"
fi
if [[ "$(table_exists "$TARGET_DB_NAME" "$AI_INSTRUCTIONS_IMAGES_TABLE")" == "t" ]]; then
PGPASSWORD="$TARGET_DB_PASS" pg_dump \
-h "$TARGET_DB_HOST" -p "$TARGET_DB_PORT" -U "$TARGET_DB_USER" \
-d "$TARGET_DB_NAME" \
--table="$AI_INSTRUCTIONS_IMAGES_TABLE" --data-only \
--no-owner --no-privileges \
-f "${BACKUP_PATH}/db/${TARGET}-ai-instructions-images.sql" 2>/dev/null \
|| rm -f "${BACKUP_PATH}/db/${TARGET}-ai-instructions-images.sql"
fi
}
restore_ai_instructions() {
local prompts_sql="${BACKUP_PATH}/db/${TARGET}-ai-instructions.sql"
[[ -s "$prompts_sql" ]] || return 0
if $DRY_RUN; then
info "[DRY RUN] Would reload ${AI_INSTRUCTIONS_TABLE} rows after restore"
return 0
fi
if [[ "$(table_exists "$TARGET_DB_NAME" "$AI_INSTRUCTIONS_TABLE")" != "t" ]]; then
warn "${AI_INSTRUCTIONS_TABLE} missing after restore; AI prompts will be re-seeded on next boot"
return 0
fi
info "Reloading preserved AI Compose prompts..."
if ! PGPASSWORD="$TARGET_DB_PASS" psql \
-h "$TARGET_DB_HOST" -p "$TARGET_DB_PORT" -U "$TARGET_DB_USER" \
-d "$TARGET_DB_NAME" \
-v ON_ERROR_STOP=1 \
-c "TRUNCATE TABLE \"${AI_INSTRUCTIONS_TABLE}\" CASCADE;" \
-f "$prompts_sql" 2>&1; then
warn "Failed to reload ${AI_INSTRUCTIONS_TABLE}; prompts will be re-seeded on next boot"
return 0
fi
local images_sql="${BACKUP_PATH}/db/${TARGET}-ai-instructions-images.sql"
if [[ -s "$images_sql" ]] && [[ "$(table_exists "$TARGET_DB_NAME" "$AI_INSTRUCTIONS_IMAGES_TABLE")" == "t" ]]; then
PGPASSWORD="$TARGET_DB_PASS" psql \
-h "$TARGET_DB_HOST" -p "$TARGET_DB_PORT" -U "$TARGET_DB_USER" \
-d "$TARGET_DB_NAME" \
-v ON_ERROR_STOP=1 \
-f "$images_sql" >/dev/null 2>&1 \
|| warn "Failed to reload ${AI_INSTRUCTIONS_IMAGES_TABLE} (non-fatal)"
fi
ok "AI Compose prompts preserved (no regeneration needed on next boot)"
}
reset_sequences() {
info "Resetting sequences..."
local has_function
has_function=$(PGPASSWORD="$TARGET_DB_PASS" psql \
-h "$TARGET_DB_HOST" -p "$TARGET_DB_PORT" -U "$TARGET_DB_USER" \
-d "$TARGET_DB_NAME" \
-tAc "SELECT to_regclass('maintenance.reset_owned_sequences(text)') IS NOT NULL;" 2>/dev/null)
if [[ "$has_function" != "t" ]]; then
warn "maintenance.reset_owned_sequences not available (migrations skipped or not applied); skipping sequence reset"
return 0
fi
if ! PGPASSWORD="$TARGET_DB_PASS" psql \
-h "$TARGET_DB_HOST" \
-p "$TARGET_DB_PORT" \
-U "$TARGET_DB_USER" \
-d "$TARGET_DB_NAME" \
-v ON_ERROR_STOP=1 \
-c "SELECT * FROM maintenance.reset_owned_sequences('public');"; then
die "Failed to reset sequences"
fi
ok "Sequences reset"
}
sync_database() {
info "Syncing database: ${PROD_DB_NAME} → ${TARGET_DB_NAME}"
info " Source: ${PROD_DB_HOST}:${PROD_DB_PORT}"
info " Target: ${TARGET_DB_HOST}:${TARGET_DB_PORT}"
if $DRY_RUN; then
info "[DRY RUN] Would dump production database and restore to target"
return 0
fi
info "Dumping production database..."
local dump_file="${BACKUP_PATH}/db/prod.dump"
preserve_ai_instructions
if ! PGPASSWORD="$PROD_DB_PASS" pg_dump \
-h "$PROD_DB_HOST" \
-p "$PROD_DB_PORT" \
-U "$PROD_DB_USER" \
-d "$PROD_DB_NAME" \
--no-owner --no-privileges --no-acl \
-Fc \
-f "$dump_file" 2>&1; then
die "pg_dump failed. Check production database credentials and connectivity."
fi
local dump_size
dump_size=$(du -h "$dump_file" | cut -f1)
ok "Production dump complete (${dump_size})"
info "Recreating target database..."
if ! PGPASSWORD="$TARGET_DB_PASS" psql \
-h "$TARGET_DB_HOST" \
-p "$TARGET_DB_PORT" \
-U "$TARGET_DB_USER" \
-d postgres \
-c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '${TARGET_DB_NAME}' AND pid <> pg_backend_pid();" \
-c "DROP DATABASE IF EXISTS \"${TARGET_DB_NAME}\" WITH (FORCE);" \
-c "CREATE DATABASE \"${TARGET_DB_NAME}\" OWNER \"${TARGET_DB_USER}\";" 2>&1; then
die "Failed to drop/recreate target database. Check target database permissions."
fi
info "Restoring into target database..."
local restore_exit=0
PGPASSWORD="$TARGET_DB_PASS" pg_restore \
-h "$TARGET_DB_HOST" \
-p "$TARGET_DB_PORT" \
-U "$TARGET_DB_USER" \
-d "$TARGET_DB_NAME" \
--no-owner --no-privileges --no-acl \
--if-exists --clean \
"$dump_file" 2>&1 || restore_exit=$?
if [[ $restore_exit -gt 1 ]]; then
die "pg_restore failed (exit code ${restore_exit}). Check error output above."
fi
if [[ $restore_exit -eq 1 ]]; then
warn "pg_restore completed with warnings (exit code 1) — this is usually fine."
fi
if ! $NO_MIGRATE; then
info "Running pending migrations..."
if ! DATABASE_URI="$TARGET_DB_URI" bun run payload migrate 2>&1; then
warn "Migration step failed — data is restored but schema may be behind dev"
else
ok "Migrations applied"
fi
fi
restore_ai_instructions
reset_sequences
ok "Database sync complete: ${TARGET_DB_NAME}"
}
# ─── Media sync ───────────────────────────────────────────────────────────────
sync_media() {
if $SKIP_MEDIA; then
info "Skipping media sync (--skip-media)"
return 0
fi
# Determine SSH target
local ssh_host="${MEDIA_HOST:-${PROD_HOST:-}}"
local ssh_user="${MEDIA_USER:-${PROD_USER:-root}}"
local container="$PROD_CONTAINER"
[[ -n "$ssh_host" ]] || die "No media server host. Set MEDIA_HOST or PROD_HOST."
info "Syncing media from ${ssh_user}@${ssh_host}:${MEDIA_PATH}"
if $DRY_RUN; then
info "[DRY RUN] Would stream /app/media from ${container} via SSH"
return 0
fi
# Create local media directory if it doesn't exist
mkdir -p "$PROJECT_ROOT/media"
# Stream media from production container via SSH
# Using docker exec + tar to read from inside the container (works with
# Docker volumes regardless of mount path on the host)
info "Streaming media from production container..."
if ! ssh -o ConnectTimeout=10 "${ssh_user}@${ssh_host}" \
"docker exec ${container} tar -cf - -C /app media" \
| tar -xf - -C "$PROJECT_ROOT"; then
die "Media sync failed. Check SSH access to ${ssh_host} and container '${container}' exists."
fi
ok "Media sync complete → ${PROJECT_ROOT}/media/"
}
# ─── Summary ──────────────────────────────────────────────────────────────────
print_summary() {
local elapsed=$1
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
if $DRY_RUN; then
echo -e " ${YELLOW}DRY RUN COMPLETE${NC} — nothing was changed"
else
echo -e " ${GREEN}SYNC COMPLETE${NC}"
fi
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo " Target: ${TARGET} (${TARGET_DB_NAME})"
echo " Source: production (${PROD_DB_NAME})"
if [[ -n "$BACKUP_PATH" ]] && [[ -d "$BACKUP_PATH" ]]; then
echo " Backup: ${BACKUP_PATH}"
fi
echo " Duration: ${elapsed}s"
echo ""
echo " To rollback:"
echo " Database: pg_restore -h $TARGET_DB_HOST -p $TARGET_DB_PORT -U $TARGET_DB_USER -d $TARGET_DB_NAME --clean --if-exists ${BACKUP_PATH}/db/${TARGET}.dump"
echo " Media: tar -xf ${BACKUP_PATH}/media/media.tar.gz -C ${PROJECT_ROOT}"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
}
# ─── Backup listing ───────────────────────────────────────────────────────────
list_backups() {
echo "Available backups:"
echo ""
if [[ ! -d "$BACKUP_DIR" ]] || [[ -z "$(ls -A "$BACKUP_DIR" 2>/dev/null)" ]]; then
echo " No backups found."
return 0
fi
for dir in "${BACKUP_DIR}"/*/; do
[[ -d "$dir" ]] || continue
local name
name=$(basename "$dir")
local db_size="—"
local media_size="—"
if [[ -f "${dir}db/${TARGET:-dev}.dump" ]]; then
db_size=$(du -h "${dir}db/${TARGET:-dev}.dump" | cut -f1)
fi
if [[ -f "${dir}media/media.tar.gz" ]]; then
media_size=$(du -h "${dir}media/media.tar.gz" | cut -f1)
fi
printf " %-20s DB: %-8s Media: %s\n" "$name" "$db_size" "$media_size"
done
}
# ─── Main ─────────────────────────────────────────────────────────────────────
main() {
if $LIST_BACKUPS; then
list_backups
exit 0
fi
# Load environment (not needed for --list-backups or --help)
load_env "$SOURCE_FILE"
# Parse production database URI
PROD_DB_USER=""
PROD_DB_PASS=""
PROD_DB_HOST=""
PROD_DB_PORT=""
PROD_DB_NAME=""
if [[ -n "${PROD_DATABASE_URI:-}" ]]; then
PROD_DB_USER=$(echo "$PROD_DATABASE_URI" | sed -n 's|.*://\([^:]*\):.*|\1|p')
PROD_DB_PASS=$(echo "$PROD_DATABASE_URI" | sed -n 's|.*://[^:]*:\([^@]*\)@.*|\1|p')
PROD_DB_HOST=$(echo "$PROD_DATABASE_URI" | sed -n 's|.*@\([^:]*\):.*|\1|p')
PROD_DB_PORT=$(echo "$PROD_DATABASE_URI" | sed -n 's|.*:\([0-9]*\)/.*|\1|p')
PROD_DB_NAME=$(echo "$PROD_DATABASE_URI" | sed -n 's|.*/\([^?]*\).*|\1|p')
fi
# Validate required production config
[[ -n "$PROD_DB_HOST" ]] || die "PROD_DATABASE_URI not set or missing host in $SOURCE_FILE"
[[ -n "$PROD_DB_PORT" ]] || die "PROD_DATABASE_URI not set or missing port in $SOURCE_FILE"
[[ -n "$PROD_DB_NAME" ]] || die "PROD_DATABASE_URI not set or missing database name in $SOURCE_FILE"
[[ -n "$PROD_DB_USER" ]] || die "PROD_DATABASE_URI not set or missing user in $SOURCE_FILE"
[[ -n "$PROD_DB_PASS" ]] || die "PROD_DATABASE_URI not set or missing password in $SOURCE_FILE"
# Validate target
[[ -n "${ENV_CONFIG[$TARGET]:-}" ]] || die "Invalid target: $TARGET (must be: dev, stg)"
# Check PostgreSQL tools
for cmd in pg_dump pg_restore psql; do
command -v "$cmd" >/dev/null 2>&1 || die "$cmd not found. Install postgresql-client."
done
# Resolve target database URI
TARGET_ENV_FILE="${PROJECT_ROOT}/${ENV_CONFIG[$TARGET]}"
[[ -f "$TARGET_ENV_FILE" ]] || die "Target env file not found: $TARGET_ENV_FILE"
TARGET_DB_URI=$(grep -E '^DATABASE_URI=' "$TARGET_ENV_FILE" | head -1 | cut -d'=' -f2-)
[[ -n "$TARGET_DB_URI" ]] || die "DATABASE_URI not found in $TARGET_ENV_FILE"
TARGET_DB_USER=$(echo "$TARGET_DB_URI" | sed -n 's|.*://\([^:]*\):.*|\1|p')
TARGET_DB_PASS=$(echo "$TARGET_DB_URI" | sed -n 's|.*://[^:]*:\([^@]*\)@.*|\1|p')
TARGET_DB_HOST=$(echo "$TARGET_DB_URI" | sed -n 's|.*@\([^:]*\):.*|\1|p')
TARGET_DB_PORT=$(echo "$TARGET_DB_URI" | sed -n 's|.*:\([0-9]*\)/.*|\1|p')
TARGET_DB_NAME=$(echo "$TARGET_DB_URI" | sed -n 's|.*/\([^?]*\).*|\1|p')
TARGET_DB_HOST="${PROD_HOST:-$TARGET_DB_HOST}"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo " Polaris Task Force — Production Data Sync"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
echo " Source: ${PROD_DB_NAME} @ ${PROD_DB_HOST}:${PROD_DB_PORT}"
echo " Target: ${TARGET_DB_NAME} @ ${TARGET_DB_HOST}:${TARGET_DB_PORT}"
if ! $SKIP_MEDIA; then
echo " Media: ${MEDIA_HOST:-${PROD_HOST:-?}} → local media/"
else
echo " Media: skipped"
fi
if $DRY_RUN; then
echo -e " Mode: ${YELLOW}DRY RUN${NC}"
fi
echo ""
# Confirmation prompt
if ! $AUTO_YES && ! $DRY_RUN; then
echo -e " ${RED}WARNING: This will OVERWRITE the ${TARGET} database and media.${NC}"
echo ""
read -rp " Continue? [y/N] " confirm
[[ "$confirm" =~ ^[Yy]$ ]] || { echo "Aborted."; exit 0; }
echo ""
fi
local start_time
start_time=$(date +%s)
TS=$(date +%Y%m%d_%H%M%S)
if $BACKUP_ONLY; then
setup_backup_dirs
backup_target_db
backup_media
else
setup_backup_dirs
backup_target_db
backup_media
sync_database
sync_media
fi
local end_time elapsed
end_time=$(date +%s)
elapsed=$((end_time - start_time))
print_summary "$elapsed"
}
main "$@"