Payload config seeds AI Compose instructions statically so wiped prompt tables rebuild without LLM calls; sync-prod pairs with that per the config comment. Adds lexical deps and changelog entries. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
608 lines
24 KiB
Bash
Executable file
608 lines
24 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
|
|
|
|
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('${AI_INSTRUCTIONS_TABLE}', 'id'), GREATEST(COALESCE((SELECT MAX(id) FROM ${AI_INSTRUCTIONS_TABLE}), 1), 1));" >/dev/null 2>&1 \
|
|
|| true
|
|
|
|
ok "AI Compose prompts preserved (no regeneration needed on next boot)"
|
|
}
|
|
|
|
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
|
|
|
|
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
|
|
|
|
restore_ai_instructions
|
|
|
|
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 "$@"
|