#!/usr/bin/env bash set -euo pipefail sha=${1:?commit SHA required} directory=${2:?deployment directory required} origin=${3:?public origin required} case "$sha" in *[!0-9a-f]*|'') echo 'Invalid image tag' >&2; exit 2 ;; esac case "$directory:$origin" in /opt/esports:https://esports.vash.software) environment=/etc/esports/prod.env migration_environment=/etc/esports/prod-migrations.env host_port=3100 ;; /opt/esports-staging:https://esports-staging.vash.software) environment=/etc/esports-staging/staging.env migration_environment=/etc/esports/staging-migrations.env host_port=3101 ;; *) echo 'Unrecognized deployment target' >&2; exit 2 ;; esac image="git.stanrunge.dev/stan/esports:$sha" compose="$directory/compose.prod.yml" if [ "$directory" = /opt/esports-staging ]; then environment_name=staging else environment_name=production fi # The deploy user only has passwordless sudo for tee and docker. if [ ! -d "$directory" ]; then echo "$directory does not exist; create it once on the host" >&2 exit 1 fi previous_tag=$(sed -n 's/^IMAGE_TAG=//p' "$directory/release.env" 2>/dev/null | head -n 1 || true) write_release() { printf 'IMAGE_TAG=%s\nHOST_PORT=%s\n' "$1" "$host_port" | sudo tee "$directory/release.env" >/dev/null } sudo tee "$compose" >/dev/null <&2 write_release "$previous_tag" sudo docker compose --env-file "$directory/release.env" -f "$compose" up -d fi } trap rollback ERR port=$host_port health=$(mktemp) if [ "${FORCE_DEPLOY:-}" != 1 ] && curl --fail --silent --show-error "http://127.0.0.1:${port}/api/health" > "$health"; then HEALTH_FILE=$health python3 - <<'PY' import json, os data = json.load(open(os.environ["HEALTH_FILE"])) active = int(data.get("activeMatches") or data.get("irc", {}).get("activeLobbies") or 0) if active: raise SystemExit(f"{active} matches are still live. Drain them from the admin page, or set FORCE_DEPLOY=1.") PY fi # The old release reports lobbies rather than active matches, and its health # endpoint may be unavailable. The database check covers both releases. if [ "${FORCE_DEPLOY:-}" != 1 ]; then active_count=$(sudo docker run --rm --network backend --env-file "$environment" "$image" \ bun -e 'const sql = new Bun.SQL(process.env.DB_URL || process.env.DATABASE_URL, { max: 1 }); const states = ["LOBBY", "ROLLING", "CHOOSING", "BANNING", "PICKING", "TB_SELECT", "PLAYING", "CLOSING"]; const [row] = await sql`select count(*)::int as count from match where state in ${sql(states)}`; console.log(row.count); await sql.end();') if [ "$active_count" -gt 0 ]; then echo "$active_count matches are live in the database. Drain them before deploying." >&2 exit 1 fi fi db_version=$(sudo docker run --rm --network backend --env-file "$environment" "$image" \ bun -e 'const sql = new Bun.SQL(process.env.DB_URL || process.env.DATABASE_URL, { max: 1 }); const [row] = await sql`show server_version_num`; console.log(row.server_version_num); await sql.end();') case "$db_version" in *[!0-9]*|'') echo 'Could not determine the PostgreSQL server version' >&2; exit 1 ;; esac if [ "$db_version" -ge 100000 ]; then db_major=$((db_version / 10000)) else db_major="$((db_version / 10000)).$(((db_version / 100) % 100))" fi backup_image="postgres:${db_major}-alpine" backup_dir=/var/backups/esports backup_stamp=$(date -u +%Y%m%dT%H%M%SZ) backup_name="${environment_name}-${backup_stamp}-${sha}.dump" sudo docker pull "$backup_image" # The container runs as root, so it creates the directory and sets the modes. sudo docker run --rm --network backend --env-file "$environment" \ --mount "type=bind,source=$(dirname "$backup_dir"),target=/backups" \ --env "BACKUP_FILE=$(basename "$backup_dir")/$backup_name" "$backup_image" \ sh -eu -c 'mkdir -p -m 0700 "/backups/${BACKUP_FILE%/*}" pg_dump --format=custom --no-owner --no-acl --file="/backups/$BACKUP_FILE" "${DB_URL:-$DATABASE_URL}" chmod 0600 "/backups/$BACKUP_FILE" pg_restore --list "/backups/$BACKUP_FILE" >/dev/null' echo "Verified $environment_name database backup at $backup_dir/$backup_name" # The app's database role cannot create tables; the migrations file overrides # DATABASE_URL with the owner role and keeps the rest (ROOT_ADMIN_USER_ID). sudo docker run --rm --network backend --env-file "$environment" \ --env-file "$migration_environment" "$image" bun scripts/migrate.ts write_release "$sha" switched=1 sudo docker compose --env-file "$directory/release.env" -f "$compose" up -d curl --retry 12 --retry-delay 2 --retry-all-errors --fail --silent --show-error \ "http://127.0.0.1:${port}/api/health" >/dev/null trap - ERR echo "Deployed $sha to $origin"