#!/usr/bin/env bash set -euo pipefail # ── Ensure sops + age are installed ─────────────────────── APP_DIR="$(cd "$(dirname "$0")" && pwd)" BIN_DIR="$APP_DIR/bin" mkdir -p "$BIN_DIR" export PATH="$BIN_DIR:$PATH" ARCH=$(uname -m) case "$ARCH" in x86_64) ARCH_SOPS="amd64"; ARCH_AGE="amd64" ;; aarch64) ARCH_SOPS="arm64"; ARCH_AGE="arm64" ;; *) echo "Unsupported architecture: $ARCH"; exit 1 ;; esac if ! command -v age &>/dev/null; then echo "==> Installing age to $BIN_DIR..." AGE_VERSION="v1.3.1" curl -fsSL "https://dl.filippo.io/age/${AGE_VERSION}?for=linux/${ARCH_AGE}" -o /tmp/age.tar.gz tar -xzf /tmp/age.tar.gz -C "$BIN_DIR" --strip-components=1 age/age age/age-keygen chmod +x "$BIN_DIR/age" "$BIN_DIR/age-keygen" rm /tmp/age.tar.gz fi if ! command -v sops &>/dev/null; then echo "==> Installing sops to $BIN_DIR..." SOPS_VERSION="v3.12.1" curl -fsSL "https://github.com/getsops/sops/releases/download/${SOPS_VERSION}/sops-${SOPS_VERSION}.linux.${ARCH_SOPS}" -o "$BIN_DIR/sops" chmod +x "$BIN_DIR/sops" fi # ── Ensure age keypair exists ───────────────────────────── AGE_KEY_FILE="${SOPS_AGE_KEY_FILE:-$APP_DIR/age-key.txt}" export SOPS_AGE_KEY_FILE="$AGE_KEY_FILE" if [ ! -f "$AGE_KEY_FILE" ]; then echo "==> Generating age keypair..." age-keygen -o "$AGE_KEY_FILE" 2>&1 chmod 600 "$AGE_KEY_FILE" AGE_PUB=$(grep "public key:" "$AGE_KEY_FILE" | awk '{print $NF}') echo "" echo "!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!" echo "!! NEW SERVER — add this public key to .sops.yaml: !!" echo "!! !!" echo "!! $AGE_PUB !!" echo "!! !!" echo "!! Then run: sops updatekeys .env.prod.sops !!" echo "!! Commit, push, and re-deploy. !!" echo "!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!" echo "" exit 1 fi # ── Decrypt secrets ─────────────────────────────────────── sops --input-type dotenv --output-type dotenv -d .env.prod.sops > .env chmod 600 .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 padelnomics.migrations.migrate # ── Ensure router is healthy before waiting ────────────────── # nginx -t resolves upstream hostnames — if the config points to a stopped # slot, the health check fails. Write config for the CURRENT live slot # (which is still running) so the router stays healthy during --wait. _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 "--- litestream logs ---" $COMPOSE logs --tail=10 litestream 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!"