diff --git a/scripts/sync-prod.sh b/scripts/sync-prod.sh new file mode 100755 index 0000000..73aaf3d --- /dev/null +++ b/scripts/sync-prod.sh @@ -0,0 +1,506 @@ +#!/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 "$@"