#!/usr/bin/env bash # ──────────────────────────────────────────────────────────────────────────── # CMS Data Sync Tool (mintel.me) # Safely syncs the Payload CMS PostgreSQL database between environments. # Media is handled via S3 and does NOT need syncing. # # Usage: # npm run cms:push:testing – Push local → testing # npm run cms:push:prod – Push local → production # npm run cms:pull:testing – Pull testing → local # npm run cms:pull:prod – Pull production → local # ──────────────────────────────────────────────────────────────────────────── set -euo pipefail SYNC_SUCCESS="false" LOCAL_BACKUP_FILE="" REMOTE_BACKUP_FILE="" cleanup_on_exit() { local exit_code=$? if [ "$SYNC_SUCCESS" != "true" ] && [ $exit_code -ne 0 ]; then echo "" echo "❌ Sync aborted or failed! (Exit code: $exit_code)" if [ "${DIRECTION:-}" = "push" ] && [ -n "${REMOTE_BACKUP_FILE:-}" ]; then echo "🔄 Rolling back $TARGET database..." ssh "$SSH_HOST" "gunzip -c $REMOTE_BACKUP_FILE | docker exec -i $REMOTE_DB_CONTAINER psql -U $REMOTE_DB_USER -d $REMOTE_DB_NAME --quiet" || echo "⚠️ Rollback failed" echo "✅ Rollback complete." elif [ "${DIRECTION:-}" = "pull" ] && [ -n "${LOCAL_BACKUP_FILE:-}" ]; then echo "🔄 Rolling back local database..." gunzip -c "$LOCAL_BACKUP_FILE" | docker exec -i "$LOCAL_DB_CONTAINER" psql -U "$LOCAL_DB_USER" -d "$LOCAL_DB_NAME" --quiet || echo "⚠️ Rollback failed" echo "✅ Rollback complete." fi fi } trap 'cleanup_on_exit' EXIT # Load environment variables if [ -f ../../.env ]; then set -a; source ../../.env; set +a fi if [ -f .env ]; then set -a; source .env; set +a fi # ── Configuration ────────────────────────────────────────────────────────── DIRECTION="${1:-}" # push | pull TARGET="${2:-}" # testing | prod SSH_HOST="root@alpha.mintel.me" LOCAL_DB_USER="${postgres_DB_USER:-payload}" LOCAL_DB_NAME="${postgres_DB_NAME:-payload}" LOCAL_DB_CONTAINER="mintel-me-postgres-db-1" # Resolve directories SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" BACKUP_DIR="${SCRIPT_DIR}/../../../../backups" TIMESTAMP=$(date +"%Y%m%d_%H%M%S") # Remote credentials (resolved per-target from server env files) REMOTE_DB_USER="" REMOTE_DB_NAME="" # Auto-detect migrations from apps/web/src/migrations/*.ts MIGRATIONS=() BATCH=1 for migration_file in $(ls "${SCRIPT_DIR}/../src/migrations"/*.ts 2>/dev/null | sort); do name=$(basename "$migration_file" .ts) MIGRATIONS+=("$name:$BATCH") ((BATCH++)) done if [ ${#MIGRATIONS[@]} -eq 0 ]; then echo "⚠️ No migration files found in src/migrations/" fi # ── Resolve target environment ───────────────────────────────────────────── resolve_target() { case "$TARGET" in testing) REMOTE_PROJECT="mintel-me-testing" REMOTE_DB_CONTAINER="mintel-me-testing-postgres-db-1" REMOTE_APP_CONTAINER="mintel-me-testing-mintel-me-app-1" REMOTE_SITE_DIR="/home/deploy/sites/testing.mintel.me" ;; staging) REMOTE_PROJECT="mintel-me-staging" REMOTE_DB_CONTAINER="mintel-me-staging-postgres-db-1" REMOTE_APP_CONTAINER="mintel-me-staging-app-1" REMOTE_SITE_DIR="/home/deploy/sites/staging.mintel.me" ;; prod|production) REMOTE_PROJECT="mintel-me-production" REMOTE_DB_CONTAINER="mintel-me-production-postgres-db-1" REMOTE_APP_CONTAINER="mintel-me-production-mintel-me-app-1" REMOTE_SITE_DIR="/home/deploy/sites/mintel.me" ;; branch-*) local SLUG=${TARGET#branch-} REMOTE_PROJECT="mintel-me-branch-$SLUG" REMOTE_DB_CONTAINER="${REMOTE_PROJECT}-postgres-db-1" REMOTE_APP_CONTAINER="${REMOTE_PROJECT}-mintel-me-app-1" REMOTE_SITE_DIR="/home/deploy/sites/branch.mintel.me/$SLUG" ;; *) echo "❌ Unknown target: $TARGET" echo " Valid targets: testing, staging, prod, branch-" exit 1 ;; esac # Auto-detect remote DB credentials from the env file on the server echo "🔍 Detecting $TARGET database credentials..." # Try specific environment file first, then fallback to .env and .env.* REMOTE_DB_USER=$(ssh "$SSH_HOST" "grep -h '^\(POSTGRES_USER\|postgres_DB_USER\)=' $REMOTE_SITE_DIR/.env.$TARGET $REMOTE_SITE_DIR/.env 2>/dev/null | head -1 | cut -d= -f2" || echo "") REMOTE_DB_NAME=$(ssh "$SSH_HOST" "grep -h '^\(POSTGRES_DB\|postgres_DB_NAME\)=' $REMOTE_SITE_DIR/.env.$TARGET $REMOTE_SITE_DIR/.env 2>/dev/null | head -1 | cut -d= -f2" || echo "") # Fallback if empty REMOTE_DB_USER="${REMOTE_DB_USER:-payload}" REMOTE_DB_NAME="${REMOTE_DB_NAME:-payload}" echo " User: $REMOTE_DB_USER | DB: $REMOTE_DB_NAME" } # ── Ensure local DB is running ───────────────────────────────────────────── ensure_local_db() { if ! docker ps --format '{{.Names}}' | grep -q "$LOCAL_DB_CONTAINER"; then echo "❌ Local DB container not running: $LOCAL_DB_CONTAINER" echo " Please start the local dev environment first via 'pnpm dev:docker'." exit 1 fi } # ── Sanitize migrations table ────────────────────────────────────────────── sanitize_migrations() { local container="$1" local db_user="$2" local db_name="$3" local is_remote="$4" # "true" or "false" echo "🔧 Sanitizing payload_migrations table..." local SQL="DELETE FROM payload_migrations WHERE batch = -1;" for entry in "${MIGRATIONS[@]}"; do local name="${entry%%:*}" local batch="${entry##*:}" SQL="$SQL INSERT INTO payload_migrations (name, batch) SELECT '$name', $batch WHERE NOT EXISTS (SELECT 1 FROM payload_migrations WHERE name = '$name');" done if [ "$is_remote" = "true" ]; then ssh "$SSH_HOST" "docker exec $container psql -U $db_user -d $db_name -c \"$SQL\"" else docker exec "$container" psql -U "$db_user" -d "$db_name" -c "$SQL" fi } # ── Safety: Create backup before overwriting ─────────────────────────────── backup_local_db() { mkdir -p "$BACKUP_DIR" local file="$BACKUP_DIR/mintel_pre_sync_${TIMESTAMP}.sql.gz" echo "📦 Creating safety backup of local DB → $file" docker exec "$LOCAL_DB_CONTAINER" pg_dump -U "$LOCAL_DB_USER" -d "$LOCAL_DB_NAME" --clean --if-exists | gzip > "$file" echo "✅ Backup: $file ($(du -h "$file" | cut -f1))" LOCAL_BACKUP_FILE="$file" } backup_remote_db() { local file="/tmp/mintel_pre_sync_${TIMESTAMP}.sql.gz" echo "📦 Creating safety backup of $TARGET DB → $SSH_HOST:$file" ssh "$SSH_HOST" "docker exec $REMOTE_DB_CONTAINER pg_dump -U $REMOTE_DB_USER -d $REMOTE_DB_NAME --clean --if-exists | gzip > $file" echo "✅ Remote backup: $file" REMOTE_BACKUP_FILE="$file" } # ── Pre-flight: Verify remote containers exist ───────────────────────────── check_remote_containers() { echo "🔍 Checking $TARGET containers..." local missing=0 if ! ssh "$SSH_HOST" "docker ps -q -f name=$REMOTE_DB_CONTAINER" | grep -q .; then echo "❌ Database container '$REMOTE_DB_CONTAINER' not found on $SSH_HOST" echo " → Deploy $TARGET first: push to trigger pipeline, or manually up." missing=1 fi if ! ssh "$SSH_HOST" "docker ps -q -f name=$REMOTE_APP_CONTAINER" | grep -q .; then echo "❌ App container '$REMOTE_APP_CONTAINER' not found on $SSH_HOST" missing=1 fi if [ $missing -eq 1 ]; then echo "" echo "💡 The $TARGET environment hasn't been deployed yet." echo " Push to the branch or run the pipeline first." exit 1 fi echo "✅ All $TARGET containers running." } # ── PUSH: local → remote ────────────────────────────────────────────────── do_push() { echo "" echo "┌──────────────────────────────────────────────────┐" echo "│ 📤 PUSH: local → $TARGET " echo "│ This will OVERWRITE the $TARGET database! " echo "│ A safety backup will be created first. " echo "└──────────────────────────────────────────────────┘" echo "" read -p "Are you sure? (y/N) " -n 1 -r echo "" [[ ! $REPLY =~ ^[Yy]$ ]] && { echo "Cancelled."; exit 0; } ensure_local_db check_remote_containers backup_remote_db echo "📤 Dumping local database..." local dump="/tmp/mintel_push_${TIMESTAMP}.sql.gz" docker exec "$LOCAL_DB_CONTAINER" pg_dump -U "$LOCAL_DB_USER" -d "$LOCAL_DB_NAME" --clean --if-exists | gzip > "$dump" echo "📤 Transferring to $SSH_HOST..." scp "$dump" "$SSH_HOST:/tmp/mintel_push.sql.gz" echo "🔄 Restoring database on $TARGET..." ssh "$SSH_HOST" "gunzip -c /tmp/mintel_push.sql.gz | docker exec -i $REMOTE_DB_CONTAINER psql -U $REMOTE_DB_USER -d $REMOTE_DB_NAME --quiet" sanitize_migrations "$REMOTE_DB_CONTAINER" "$REMOTE_DB_USER" "$REMOTE_DB_NAME" "true" echo "🔄 Restarting $TARGET app container..." ssh "$SSH_HOST" "docker restart $REMOTE_APP_CONTAINER" rm -f "$dump" ssh "$SSH_HOST" "rm -f /tmp/mintel_push.sql.gz" SYNC_SUCCESS="true" echo "" echo "✅ DB Push to $TARGET complete!" } # ── PULL: remote → local ────────────────────────────────────────────────── do_pull() { echo "" echo "┌──────────────────────────────────────────────────┐" echo "│ 📥 PULL: $TARGET → local " echo "│ This will OVERWRITE your local database! " echo "│ A safety backup will be created first. " echo "└──────────────────────────────────────────────────┘" echo "" read -p "Are you sure? (y/N) " -n 1 -r echo "" [[ ! $REPLY =~ ^[Yy]$ ]] && { echo "Cancelled."; exit 0; } ensure_local_db check_remote_containers backup_local_db echo "📥 Dumping $TARGET database..." ssh "$SSH_HOST" "docker exec $REMOTE_DB_CONTAINER pg_dump -U $REMOTE_DB_USER -d $REMOTE_DB_NAME --clean --if-exists | gzip > /tmp/mintel_pull.sql.gz" echo "📥 Downloading from $SSH_HOST..." scp "$SSH_HOST:/tmp/mintel_pull.sql.gz" "/tmp/mintel_pull.sql.gz" echo "🔄 Restoring database locally..." gunzip -c "/tmp/mintel_pull.sql.gz" | docker exec -i "$LOCAL_DB_CONTAINER" psql -U "$LOCAL_DB_USER" -d "$LOCAL_DB_NAME" --quiet sanitize_migrations "$LOCAL_DB_CONTAINER" "$LOCAL_DB_USER" "$LOCAL_DB_NAME" "false" rm -f "/tmp/mintel_pull.sql.gz" ssh "$SSH_HOST" "rm -f /tmp/mintel_pull.sql.gz" SYNC_SUCCESS="true" echo "" echo "✅ DB Pull from $TARGET complete! Restart dev server to see changes." } # ── Main ─────────────────────────────────────────────────────────────────── if [ -z "$DIRECTION" ] || [ -z "$TARGET" ]; then echo "📦 CMS Data Sync Tool (mintel.me)" echo "" echo "Usage:" echo " npm run cms:push:testing Push local DB → testing" echo " npm run cms:push:staging Push local DB → staging" echo " npm run cms:push:prod Push local DB → production" echo " npm run cms:pull:testing Pull testing DB → local" echo " npm run cms:pull:staging Pull staging DB → local" echo " npm run cms:pull:prod Pull production DB → local" echo "" echo "Safety: A backup is always created before overwriting." exit 1 fi resolve_target case "$DIRECTION" in push) do_push ;; pull) do_pull ;; *) echo "❌ Unknown direction: $DIRECTION (use 'push' or 'pull')" exit 1 ;; esac