#!/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 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// # 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 ──────────────────────────────────────────────────────────── 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" 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 info "Resetting sequences..." PGPASSWORD="$TARGET_DB_PASS" psql \ -h "$TARGET_DB_HOST" \ -p "$TARGET_DB_PORT" \ -U "$TARGET_DB_USER" \ -d "$TARGET_DB_NAME" \ -c "SELECT setval(pg_get_serial_sequence(t.table_name, c.column_name), (SELECT COALESCE(MAX(c.column_name::text::bigint), 1) FROM information_schema.columns c JOIN information_schema.tables t ON c.table_name = t.table_name WHERE c.column_name = 'id')) FROM information_schema.columns c JOIN information_schema.tables t ON c.table_name = t.table_name WHERE c.column_name = 'id' AND c.column_default LIKE 'nextval%' AND t.table_schema = 'public';" 2>/dev/null || true 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 "$@"