- materia-supervisor.service: User=root → User=beanflows_service, add PATH so uv (~/.local/bin) is found without a login shell - setup_server.sh: full rewrite — creates beanflows_service (nologin), generates SSH deploy key + age keypair as service user at XDG path (~/.config/sops/age/keys.txt), installs age/sops/rclone as root, prints both public keys + numbered next-step instructions - bootstrap_supervisor.sh: full rewrite — removes GITLAB_READ_TOKEN requirement, clones via SSH as service user, installs uv as service user, decrypts with SOPS auto-discovery, uv sync as service user, systemctl as root - web/deploy.sh: remove self-contained sops/age install + keypair generation; replace with simple sops check (exit if missing) and SOPS auto-discovery decrypt (no explicit key file needed) - infra/readme.md: update architecture diagram for beanflows_service paths, update setup steps to match new scripts Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
126 lines
4.6 KiB
Bash
126 lines
4.6 KiB
Bash
#!/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" <<NGINX
|
|
upstream app {
|
|
server ${SLOT}-app:5000;
|
|
}
|
|
|
|
server {
|
|
listen 80;
|
|
|
|
location / {
|
|
proxy_pass http://app;
|
|
proxy_set_header Host \$host;
|
|
proxy_set_header X-Real-IP \$remote_addr;
|
|
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
|
proxy_set_header X-Forwarded-Proto \$scheme;
|
|
}
|
|
}
|
|
NGINX
|
|
}
|
|
|
|
if [ "$CURRENT" != "none" ]; then
|
|
echo "==> 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!"
|