Some checks failed
Test and deploy staging / staging (push) Failing after 16s
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>
131 lines
4.9 KiB
Bash
Executable File
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"
|