#!/usr/bin/env bash set -Eeuo pipefail APP_DIR="${APP_DIR:-/opt/proxpanel}" BACKUP_ROOT="${BACKUP_ROOT:-/opt/proxpanel-backups}" GREEN_DIR="${GREEN_DIR:-/opt/proxpanel-green}" GREEN_PROJECT="${GREEN_PROJECT:-proxpanel_green}" GREEN_FRONTEND_PORT="${GREEN_FRONTEND_PORT:-18081}" GREEN_BACKEND_PORT="${GREEN_BACKEND_PORT:-18080}" NGINX_SITE="${NGINX_SITE:-/etc/nginx/sites-available/proxpanel.conf}" usage() { cat <<'EOF' Usage: sudo bash infra/deploy/rollback-blue-green.sh --backup-dir /opt/proxpanel-backups/ [--cutover] Behavior: 1) Stages backup app/env into /opt/proxpanel-green 2) Boots green stack on ports 18081/18080 3) Restores DB dump from selected backup into green postgres 4) Verifies green API health 5) If --cutover is provided, switches Nginx upstream to green frontend port Notes: - This script requires backup directory format created by deployment steps: /app/ /env.production.bak (or .env.production.bak) /db_pre_*.sql EOF } log() { printf '\n[%s] %s\n' "$(date -u +'%Y-%m-%d %H:%M:%S UTC')" "$*" } die() { printf '\n[ERROR] %s\n' "$*" >&2 exit 1 } require_root() { [[ "${EUID:-$(id -u)}" -eq 0 ]] || die "Run as root (or with sudo)." } parse_args() { BACKUP_DIR="" CUTOVER="false" while [[ $# -gt 0 ]]; do case "$1" in --backup-dir) BACKUP_DIR="${2:-}" shift 2 ;; --cutover) CUTOVER="true" shift ;; -h|--help) usage exit 0 ;; *) die "Unknown argument: $1" ;; esac done [[ -n "$BACKUP_DIR" ]] || die "--backup-dir is required" } pick_db_dump() { find "$BACKUP_DIR" -maxdepth 1 -type f -name 'db_pre*.sql' | sort | tail -n 1 } pick_env_backup() { if [[ -f "$BACKUP_DIR/env.production.bak" ]]; then printf '%s' "$BACKUP_DIR/env.production.bak" return fi if [[ -f "$BACKUP_DIR/.env.production.bak" ]]; then printf '%s' "$BACKUP_DIR/.env.production.bak" return fi printf '' } wait_for_green_health() { local i for i in $(seq 1 90); do if curl -fsS "http://127.0.0.1:${GREEN_BACKEND_PORT}/api/health" >/dev/null 2>&1; then return fi sleep 2 done die "Green backend health check failed." } main() { require_root parse_args "$@" [[ -d "$BACKUP_DIR/app" ]] || die "Missing backup app directory: $BACKUP_DIR/app" local env_backup env_backup="$(pick_env_backup)" [[ -n "$env_backup" ]] || die "Missing env backup in $BACKUP_DIR (.env.production.bak or env.production.bak)" local db_dump db_dump="$(pick_db_dump)" [[ -n "$db_dump" ]] || die "Missing DB dump in backup dir: $BACKUP_DIR" log "Preparing green staging directory at $GREEN_DIR" rm -rf "$GREEN_DIR" mkdir -p "$GREEN_DIR" rsync -a "$BACKUP_DIR/app/" "$GREEN_DIR/" cp "$env_backup" "$GREEN_DIR/.env.production" # Override ports for green stack and keep CORS aligned to production hostname. if grep -q '^FRONTEND_PORT=' "$GREEN_DIR/.env.production"; then sed -i "s/^FRONTEND_PORT=.*/FRONTEND_PORT=${GREEN_FRONTEND_PORT}/" "$GREEN_DIR/.env.production" else echo "FRONTEND_PORT=${GREEN_FRONTEND_PORT}" >> "$GREEN_DIR/.env.production" fi if grep -q '^BACKEND_PORT=' "$GREEN_DIR/.env.production"; then sed -i "s/^BACKEND_PORT=.*/BACKEND_PORT=${GREEN_BACKEND_PORT}/" "$GREEN_DIR/.env.production" else echo "BACKEND_PORT=${GREEN_BACKEND_PORT}" >> "$GREEN_DIR/.env.production" fi # Remove fixed container_name lines for project-isolated blue/green operation. local compose_green compose_green="$GREEN_DIR/infra/deploy/docker-compose.green.yml" awk '!/^[[:space:]]*container_name:/' "$GREEN_DIR/infra/deploy/docker-compose.production.yml" > "$compose_green" log "Stopping previous green project (if any)" ( cd "$GREEN_DIR" docker compose -p "$GREEN_PROJECT" --env-file .env.production -f "$compose_green" down -v || true ) log "Starting green stack on ${GREEN_FRONTEND_PORT}/${GREEN_BACKEND_PORT}" ( cd "$GREEN_DIR" docker compose -p "$GREEN_PROJECT" --env-file .env.production -f "$compose_green" up -d --build ) log "Restoring backup DB into green postgres" local green_postgres_id green_pg_user green_pg_db green_postgres_id="$( cd "$GREEN_DIR" && docker compose -p "$GREEN_PROJECT" --env-file .env.production -f "$compose_green" ps -q postgres )" [[ -n "$green_postgres_id" ]] || die "Could not locate green postgres container." # shellcheck disable=SC1090 source "$GREEN_DIR/.env.production" green_pg_user="${POSTGRES_USER:-proxpanel}" green_pg_db="${POSTGRES_DB:-proxpanel}" docker exec "$green_postgres_id" psql -U "$green_pg_user" -d "$green_pg_db" -v ON_ERROR_STOP=1 \ -c 'DROP SCHEMA public CASCADE; CREATE SCHEMA public;' cat "$db_dump" | docker exec -i "$green_postgres_id" psql -U "$green_pg_user" -d "$green_pg_db" -v ON_ERROR_STOP=1 >/dev/null log "Validating green health" wait_for_green_health curl -fsS "http://127.0.0.1:${GREEN_BACKEND_PORT}/api/health" >/dev/null if [[ "$CUTOVER" == "true" ]]; then log "Cutting over Nginx upstream to green frontend port ${GREEN_FRONTEND_PORT}" [[ -f "$NGINX_SITE" ]] || die "Missing nginx site config: $NGINX_SITE" cp "$NGINX_SITE" "${NGINX_SITE}.pre-green.$(date -u +%Y%m%d-%H%M%S).bak" sed -i -E "s#proxy_pass http://127\\.0\\.0\\.1:[0-9]+;#proxy_pass http://127.0.0.1:${GREEN_FRONTEND_PORT};#g" "$NGINX_SITE" nginx -t systemctl reload nginx log "Nginx cutover complete." fi log "Blue/green rollback prep complete." printf '\nGreen frontend: http://127.0.0.1:%s\n' "$GREEN_FRONTEND_PORT" printf 'Green backend : http://127.0.0.1:%s/api/health\n' "$GREEN_BACKEND_PORT" if [[ "$CUTOVER" == "true" ]]; then printf 'Traffic switched to green.\n' else printf 'Traffic still on blue. Re-run with --cutover when ready.\n' fi } main "$@"