Admin console shows all games, auto-install s3cmd, restart nginx properly (#5238)

* Fix: Move S3 backup to BEFORE stopping old server

Critical fix: The old server may wipe games.e0es on shutdown if it
doesn't have the save() merge fix. The backup must happen BEFORE
stopping to capture valid data.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Admin console shows all games, auto-install s3cmd, restart nginx properly

1. Admin console now shows all games from games.e0es, not just loaded ones:
   - Added getAllRunningGamesSummary() to GamesManager
   - Updated getRunningGames() to include unloaded games with "[Not loaded]" status
   - Clicking into a game triggers lazy loading via getGameHistory()

2. Deploy script improvements:
   - Auto-install s3cmd if not present (via pip or apt)
   - Auto-configure s3cmd for DigitalOcean Spaces from .env
   - Use nginx restart instead of reload to ensure all workers pick up new config

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-12 17:04:33 -08:00
committed by GitHub
co-authored by Claude Opus 4.5
parent 0361464db1
commit 55ad8a8278
3 changed files with 128 additions and 24 deletions
+60 -18
View File
@@ -228,21 +228,6 @@ main() {
log_info "Stopping eagle-${active} (flushing state to storage)..."
docker compose -f "${COMPOSE_FILE}" stop "eagle-${active}"
# Backup games.e0es to S3 before switching traffic
# This creates a point-in-time backup we can recover from if something goes wrong
local backup_timestamp
backup_timestamp=$(date +%Y%m%d_%H%M%S)
log_info "Creating S3 backup of games.e0es (backup_${backup_timestamp})..."
if command -v s3cmd &> /dev/null; then
if s3cmd put "${APP_DIR}/saves/games.e0es" "s3://eagle0/eagle/save/backups/games.e0es.${backup_timestamp}" 2>/dev/null; then
log_info "Backup created: s3://eagle0/eagle/save/backups/games.e0es.${backup_timestamp}"
else
log_warn "S3 backup failed (s3cmd put), continuing deployment"
fi
else
log_warn "s3cmd not found, skipping S3 backup"
fi
# Note: No need to explicitly reload games - Eagle uses lazy loading.
# Games are loaded on-demand when users reconnect after nginx switches traffic.
# This ensures games are always loaded from the freshest storage state.
@@ -255,9 +240,10 @@ main() {
sed -i.bak 's/eagle-green:40032/eagle-blue:40032/g' "${NGINX_CONF}"
fi
# Reload nginx
log_info "Reloading nginx..."
docker compose -f "${COMPOSE_FILE}" exec nginx nginx -s reload
# Restart nginx to ensure fresh config is loaded
# Note: We use restart instead of reload to ensure all workers pick up the new config
log_info "Restarting nginx..."
docker compose -f "${COMPOSE_FILE}" restart nginx
# Clean up old instance and its jfr-sidecar
log_info "Removing old eagle-${active} container..."
@@ -290,6 +276,59 @@ main() {
log_info "Active instance: eagle-${staging}"
}
# Ensure s3cmd is installed and configured for S3 backups
ensure_s3cmd() {
# Install s3cmd if not present
if ! command -v s3cmd &> /dev/null; then
log_info "Installing s3cmd for S3 backups..."
if command -v pip3 &> /dev/null; then
pip3 install --quiet s3cmd
elif command -v pip &> /dev/null; then
pip install --quiet s3cmd
elif command -v apt-get &> /dev/null; then
sudo apt-get update -qq && sudo apt-get install -y -qq s3cmd
else
log_warn "Cannot install s3cmd (no pip or apt-get), S3 backups will be skipped"
return 1
fi
if ! command -v s3cmd &> /dev/null; then
log_warn "s3cmd installation failed, S3 backups will be skipped"
return 1
fi
log_info "s3cmd installed successfully"
fi
# Configure s3cmd for DigitalOcean Spaces if not already configured
local s3cfg="${HOME}/.s3cfg"
if [ ! -f "${s3cfg}" ]; then
# Load credentials from .env
local env_file="${APP_DIR}/.env"
if [ -f "${env_file}" ]; then
# shellcheck source=/dev/null
source "${env_file}"
fi
if [ -z "${DO_SPACES_ACCESS_KEY:-}" ] || [ -z "${DO_SPACES_SECRET_KEY:-}" ]; then
log_warn "DO_SPACES_ACCESS_KEY or DO_SPACES_SECRET_KEY not set, S3 backups will be skipped"
return 1
fi
log_info "Configuring s3cmd for DigitalOcean Spaces..."
cat > "${s3cfg}" << EOF
[default]
access_key = ${DO_SPACES_ACCESS_KEY}
secret_key = ${DO_SPACES_SECRET_KEY}
host_base = sfo3.digitaloceanspaces.com
host_bucket = %(bucket)s.sfo3.digitaloceanspaces.com
use_https = True
EOF
log_info "s3cmd configured successfully"
fi
return 0
}
# Check for required tools
check_requirements() {
if ! command -v docker &> /dev/null; then
@@ -311,6 +350,9 @@ check_requirements() {
log_error "docker-compose file not found at ${COMPOSE_FILE}"
exit 1
fi
# Ensure s3cmd is available for backups (non-fatal if it fails)
ensure_s3cmd || true
}
# Run
@@ -619,10 +619,15 @@ class EagleServiceImpl(
override def getRunningGames(
request: GetRunningGamesRequest
): Future[GetRunningGamesResponse] = Future {
val games = gamesManager.gameControllerInfos.map {
case (gameId, controllerInfo) =>
val controller = controllerInfo.controller
val gameState = controller.engine.currentState
// Get all games from storage (both loaded and unloaded)
val allGamesSummary = gamesManager.getAllRunningGamesSummary
val games = allGamesSummary.map { summary =>
if summary.isLoaded then {
// Game is loaded - return full info
val controllerInfo = gamesManager.gameControllerInfos(summary.gameId)
val controller = controllerInfo.controller
val gameState = controller.engine.currentState
val players = gameState.factions.map {
case (factionId, faction) =>
@@ -640,13 +645,34 @@ class EagleServiceImpl(
}.toSeq
RunningGameInfo(
gameId = gameId,
gameId = summary.gameId,
currentRound = gameState.currentRoundId,
actionCount = controller.engine.history.count,
players = players,
runStatus = gameState.runStatus.toString
)
}.toSeq
} else {
// Game is not loaded - return limited info from games.e0es
val players = summary.userToPid.map {
case (userName, factionId) =>
RunningGamePlayerInfo(
factionId = factionId,
factionName = "[Not loaded]",
leaderName = "[Not loaded]",
isHuman = true,
userName = userName
)
}.toSeq
RunningGameInfo(
gameId = summary.gameId,
currentRound = 0, // Unknown until loaded
actionCount = 0, // Unknown until loaded
players = players,
runStatus = "[Not loaded - click to load]"
)
}
}
GetRunningGamesResponse(games = games)
}
@@ -654,6 +680,9 @@ class EagleServiceImpl(
override def getGameHistory(
request: GetGameHistoryRequest
): Future[GetGameHistoryResponse] = Future {
// Try to load the game if not already loaded
gamesManager.loadGame(request.gameId)
gamesManager.gameControllerInfos.get(request.gameId) match {
case Some(controllerInfo) =>
val history = controllerInfo.controller.engine.history
@@ -1285,4 +1285,37 @@ class GamesManager(
// explicit flush calls here if needed
SimpleTimedLogger.printLogger.logLine("Flush complete")
}
/**
* Returns summary info for all running games from storage, including both loaded and unloaded games. Used by admin
* console to show all games without requiring them to be loaded into memory.
*/
def getAllRunningGamesSummary: Vector[RunningGameSummary] = this.synchronized {
val runningGames = readRunningGamesFromStorage()
runningGames.map { rg =>
val isLoaded = gameControllerInfos.contains(rg.gameId)
RunningGameSummary(
gameId = rg.gameId,
userToPid = rg.userToPid,
isLoaded = isLoaded
)
}
}
/**
* Loads a game into memory if not already loaded. Used by admin console when clicking into a game.
*
* @return
* true if game is now loaded (or was already loaded), false if game doesn't exist
*/
def loadGame(gameId: GameId): Boolean = this.synchronized {
ensureGameLoaded(gameId)
}
}
case class RunningGameSummary(
gameId: GameId,
userToPid: Map[String, FactionId],
isLoaded: Boolean
)