Files
esports/scripts/deploy-remote.sh
Stan Runge 6ff5fe53c5
Some checks failed
Test and deploy staging / staging (push) Failing after 16s
fix: deploy with only sudo tee and docker
The deploy user has passwordless sudo for tee and docker only, so the
script stopped at its first sudo install. release.env is rewritten with
tee, and the backup container creates the backup directory and sets the
file modes itself. HOST_PORT comes from the target, not release.env.

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

131 lines
4.9 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
host_port=3100 ;;
/opt/esports-staging:https://esports-staging.vash.software)
environment=/etc/esports-staging/staging.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
if [ "${FORCE_DEPLOY:-}" != 1 ] && curl --fail --silent --show-error "http://127.0.0.1:${port}/api/health" > /tmp/esports-health.json; then
python3 - <<'PY'
import json
data = json.load(open("/tmp/esports-health.json"))
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"
sudo docker run --rm --network backend --env-file "$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"