#!/usr/bin/env bash # Shared helpers for the container's init, watch and exit scripts. # Sourced, never executed. # # . "$ARENA_HOME/container/lib/common.sh" # Layout inside the image. Overridable so the same scripts run from a checkout # during local development. ARENA_HOME="${ARENA_HOME:-/opt/arena}" ARENA_RUN="${ARENA_RUN:-/run/arena}" # per-match scratch: manifest, config, state ARENA_SPOOL="${ARENA_SPOOL:-$ARENA_RUN/spool}" # turn reports awaiting upload ARENA_STATE="${ARENA_STATE:-$ARENA_RUN/state}" # host.status, result.json, game-over # The page Suramadu serves, staged here by init/40-render-config.sh rather than # served from the image: watch/results.sh writes the result into it when the # game is decided, and the image tree is not writable by the user this runs as. ARENA_WEB="${ARENA_WEB:-$ARENA_RUN/web}" MANIFEST="${MANIFEST:-$ARENA_RUN/manifest.json}" MM_HOME="${MM_HOME:-$ARENA_HOME/megamek}" # shellcheck disable=SC2034 # used by the scripts that source this file PORT_WS="${ARENA_PORT_WS:-8080}" # shellcheck disable=SC2034 PORT_MM="${ARENA_PORT_MM:-8850}" log() { printf '[%s] %s\n' "${ARENA_LOG_TAG:-arena}" "$*" >&2; } warn() { printf '[%s] WARN: %s\n' "${ARENA_LOG_TAG:-arena}" "$*" >&2; } die() { printf '[%s] ERROR: %s\n' "${ARENA_LOG_TAG:-arena}" "$*" >&2; exit 1; } # Anything derived from the manifest may embed a presigned URL, which is a bearer # credential. Log through this, or log field names and digests instead of values. redact() { sed -E \ -e 's#([?&](X-Amz-Signature|Signature|sig|token)=)[^ "&]*#\1REDACTED#Ig' \ -e 's|(Authorization: *).*|\1REDACTED|I' } # curl with retries and no credential leakage into the log. # try_fetch -> non-zero on failure, caller decides try_fetch() { local url="$1" dest="$2" curl -fsSL --retry 4 --retry-delay 2 --max-time "${ARENA_FETCH_TIMEOUT:-120}" \ -o "$dest" "$url" } # fetch -> fatal on failure # # Note this exits the *calling script*, not just the function, so it cannot be # used in an `if ! fetch ...` test. Use try_fetch where failure is recoverable - # camo, for instance, is cosmetic and must not cost anyone a match. fetch() { try_fetch "$1" "$2" || die "download failed for $(printf '%s' "$1" | redact)" } # jq against the manifest. mq [default] mq() { local out out="$(jq -r "$1 // empty" "$MANIFEST" 2>/dev/null || true)" [ -n "$out" ] && { printf '%s' "$out"; return 0; } [ $# -ge 2 ] && printf '%s' "$2" return 0 } # Best-effort status callback. A failed callback never fails the match - # headquarters treats task exit as authoritative. # emit [detail] emit() { local event="$1" detail="${2:-}" url url="$(mq '.callback.url')" [ -n "$url" ] || return 0 local body body="$(jq -nc --arg e "$event" --arg d "$detail" --arg m "$(mq '.matchId')" \ '{matchId:$m, event:$e, detail:$d}')" curl -fsS --max-time 10 --retry 2 -X POST -H 'Content-Type: application/json' \ -d "$body" "$url" >/dev/null 2>&1 \ || warn "callback '$event' failed (ignored)" } # Resolve the signing endpoint for one artifact under manifest .upload.: # a fixed .signUrl, or a .signTemplate whose {name} is the file's basename. # signing_endpoint [name] # # `name` overrides what the template's {name} is filled with, which is how an # artifact lands under a sub-prefix: the key gives `derived/{name}` and the # caller passes `turns/003-r02-FIRING.txt`. Defaults to the file's basename, # which is what every caller wanted before there were sub-prefixes. signing_endpoint() { local key="$1" file="$2" name="${3:-}" url template [ -n "$name" ] || name="$(basename "$file")" url="$(mq ".upload.\"$key\".signUrl")" template="$(mq ".upload.\"$key\".signTemplate")" if [ -z "$url" ] && [ -n "$template" ]; then url="${template//\{name\}/$name}" fi printf '%s' "$url" } # Whether this manifest offers an upload key at all. # # headquarters names the layout, not arena: an image that knows `raw/` still # has to work against a control plane that has not deployed it yet, and the # manifest is where the two find out about each other. See `upload_class`. has_upload_key() { # [ -n "$(mq ".upload.\"$1\".signUrl")" ] || [ -n "$(mq ".upload.\"$1\".signTemplate")" ] } # Ask the control plane to sign a PUT for one artifact, and print the URL. # # Kept separate from upload() so a minting failure is one testable thing. # curl's stderr is dropped rather than logged: its messages quote the URL, and # both the endpoint and the answer are bearer credentials. The caller says what # failed instead. # mint_upload_url mint_upload_url() { local body body="$(curl -fsS --retry 2 --retry-delay 1 --max-time "${ARENA_MINT_TIMEOUT:-20}" \ -X POST -H 'Content-Length: 0' "$1" 2>/dev/null)" || return 1 printf '%s' "$body" | jq -re '.url // empty' } # upload # upload-key indexes manifest .upload., which carries a signing endpoint # (.signUrl or .signTemplate). That is the only route: arena mints a presigned # PUT and sends the bytes to S3 itself. # # There used to be a fallback that PUT the artifact to headquarters' own API and # let it re-send the bytes. It is gone. It read whole artifacts into memory on a # t4g.nano that also carries the API and Caddy, and a match's screenshots, # summary GIF, profiles and logs all arrive within a few seconds of each other # at the end. Keeping it as a fallback meant a broken signing setup degraded to # the slow route silently instead of saying so. Now a minting failure fails the # upload: the artifact stays in the container and the log says which key. # # The signature is minted here, immediately before use, so its life never has to # cover the length of a match. That is not a detail: a presigned URL cannot # outlive the credentials that signed it, headquarters signs with IMDS # instance-profile credentials, and IMDS rotates those on its own schedule and # will not issue a fresh set on request. Signed at launch, every upload URL # would expire on a clock nobody can predict. upload() { local file="$1" key="$2" name="${3:-}" signer target signer="$(signing_endpoint "$key" "$file" "$name")" [ -n "$signer" ] || { log "no upload target for '$key'; keeping $file locally"; return 1; } target="$(mint_upload_url "$signer")" && [ -n "$target" ] \ || { warn "could not mint an upload URL for '$key'; keeping $file locally"; return 1; } # PUT, not the manifest's .method: the signature covers the method. curl -fsS --retry 3 --retry-delay 2 --max-time "${ARENA_UPLOAD_TIMEOUT:-120}" \ -X PUT --upload-file "$file" "$target" >/dev/null \ || { warn "upload of $(basename "$file") to '$key' failed"; return 1; } log "uploaded $(basename "$file") -> $key/${name:-$(basename "$file")}" } # upload_class [ []] # # Send an artifact under the layout this manifest speaks. # # The prefixes name what a file is - what the match was given, what MegaMek # wrote, what we made of it - and headquarters offers them as `launch`, `raw`, # `derived` and `socials`. A control plane that predates them offers only the # four keys named for the script that sent them, so each caller names the # legacy key and the name that layout expected, and gets today's behaviour # against yesterday's manifest. # # No legacy key means an artifact that was never collected before: it is sent # when the manifest knows where to put it and skipped, quietly, when it does # not. upload_class() { local file="$1" key="$2" name="$3" legacy="${4:-}" legacy_name="${5:-}" if ! has_upload_key "$key" && [ -n "$legacy" ] && has_upload_key "$legacy"; then upload "$file" "$legacy" "${legacy_name:-$(basename "$file")}" return fi # Including when neither key is there: `upload` is what says which file was # offered and had nowhere to go, and a dev manifest with no targets at all # is a supported way to run this. upload "$file" "$key" "$name" } require_cmd() { for c in "$@"; do command -v "$c" >/dev/null || die "'$c' not found in the image" done }