Files
esports/scripts/deploy-remote.sh
Stan Runge 331507ee40 fix: migrate with the owner role and a private health file
The app's database role cannot create tables in public, so migrations
failed with "permission denied for schema public". They now also load
/etc/esports/{prod,staging}-migrations.env, whose DATABASE_URL overrides
the app's. The health pre-check wrote a fixed /tmp file that another user
already owned, which silently skipped the live-match check; it uses mktemp.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 22:47:58 +00:00

136 lines
5.2 KiB
Bash
Executable File

#!/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 <<EOF
services:
app:
image: git.stanrunge.dev/stan/esports:\${IMAGE_TAG}
restart: unless-stopped
stop_grace_period: 30s
env_file:
- path: $environment
format: raw
environment:
PORT: 3000
HOST: 0.0.0.0
BODY_SIZE_LIMIT: 6M
PROTOCOL_HEADER: x-forwarded-proto
HOST_HEADER: x-forwarded-host
ADDRESS_HEADER: CF-Connecting-IP
ports:
- 127.0.0.1:\${HOST_PORT}:3000
networks:
- backend
networks:
backend:
external: true
EOF
switched=0
rollback() {
if [ "$switched" -eq 1 ] && [ -n "$previous_tag" ]; then
echo "Health check failed; restoring prior image tag $previous_tag" >&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"