#!/usr/bin/env bash set -euo pipefail APP_DIR="$(cd "$(dirname "$0")" && pwd)" # ── Verify sops is installed (setup_server.sh installs it to /usr/local/bin) ── if ! command -v sops &>/dev/null; then echo "ERROR: sops not found — run infra/setup_server.sh first" exit 1 fi # ── Decrypt secrets (SOPS auto-discovers age key from ~/.config/sops/age/) ──── echo "==> Decrypting secrets from .env.prod.sops..." sops --input-type dotenv --output-type dotenv -d "$APP_DIR/../.env.prod.sops" > "$APP_DIR/.env" chmod 600 "$APP_DIR/.env" COMPOSE="docker compose -f docker-compose.prod.yml" LIVE_FILE=".live-slot" ROUTER_CONF="router/default.conf" # ── Determine slots ───────────────────────────────────────── CURRENT=$(cat "$LIVE_FILE" 2>/dev/null || echo "none") if [ "$CURRENT" = "blue" ]; then TARGET="green" else TARGET="blue" fi echo "==> Current: $CURRENT → Deploying: $TARGET" # ── Build ─────────────────────────────────────────────────── echo "==> Building $TARGET..." $COMPOSE --profile "$TARGET" build # ── Backup DB before migration ──────────────────────────────── BACKUP_TAG="pre-deploy-$(date +%Y%m%d-%H%M%S)" echo "==> Backing up database (${BACKUP_TAG})..." $COMPOSE run --rm --entrypoint "" "${TARGET}-app" \ sh -c "cp /app/data/app.db /app/data/app.db.${BACKUP_TAG} 2>/dev/null || true" # ── Migrate ───────────────────────────────────────────────── echo "==> Running migrations..." $COMPOSE --profile "$TARGET" run --rm "${TARGET}-app" \ python -m beanflows.migrations.migrate # ── Ensure router points to current live slot before --wait ── # nginx resolves upstream hostnames — if config points to a stopped slot, # the health check fails. Reset router to current slot while target starts. _write_router_conf() { local SLOT="$1" mkdir -p "$(dirname "$ROUTER_CONF")" cat > "$ROUTER_CONF" < Resetting router to current slot ($CURRENT)..." _write_router_conf "$CURRENT" $COMPOSE restart router fi # ── Start & health check ─────────────────────────────────── echo "==> Starting $TARGET (waiting for health check)..." if ! $COMPOSE --profile "$TARGET" up -d --wait; then echo "!!! Health check failed — dumping logs" echo "--- ${TARGET}-app logs ---" $COMPOSE --profile "$TARGET" logs --tail=60 "${TARGET}-app" 2>&1 || true echo "--- router logs ---" $COMPOSE logs --tail=10 router 2>&1 || true echo "!!! Rolling back" $COMPOSE stop "${TARGET}-app" "${TARGET}-worker" "${TARGET}-scheduler" LATEST=$($COMPOSE run --rm --entrypoint "" "${TARGET}-app" \ sh -c "ls -t /app/data/app.db.pre-deploy-* 2>/dev/null | head -1") if [ -n "$LATEST" ]; then echo "==> Restoring database from ${LATEST}..." $COMPOSE run --rm --entrypoint "" "${TARGET}-app" \ sh -c "cp '${LATEST}' /app/data/app.db" fi exit 1 fi # ── Write router config and reload (new slot is healthy) ──── echo "==> Switching router to $TARGET..." _write_router_conf "$TARGET" $COMPOSE exec router nginx -s reload # ── Cleanup old pre-deploy backups (keep last 3) ───────────── $COMPOSE run --rm --entrypoint "" "${TARGET}-app" \ sh -c "ls -t /app/data/app.db.pre-deploy-* 2>/dev/null | tail -n +4 | xargs rm -f" || true # ── Stop old slot ─────────────────────────────────────────── if [ "$CURRENT" != "none" ]; then echo "==> Stopping $CURRENT..." $COMPOSE stop "${CURRENT}-app" "${CURRENT}-worker" "${CURRENT}-scheduler" fi # ── Record live slot ──────────────────────────────────────── echo "$TARGET" > "$LIVE_FILE" echo "==> Deployed $TARGET successfully!"