Something went wrong. Try again.
Backend environment for match hosting for lance.blue
Something went wrong. Try again.
Shell
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475#!/usr/bin/env bash# Shared helpers for the local match scripts, scripts/run.sh and scripts/arena.sh.# Sourced, never executed.## . "$ROOT/scripts/lib/common.sh"## Both scripts start the same image with the same generated manifest. What# differs is who is waiting: run.sh holds a terminal for a person, arena.sh# returns to a caller. Everything they agree on - the resource caps, the# singleton guard, the manifest, what "ready" means - lives here so the two# cannot drift apart.
ARENA_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
# versions.env names the image. Sourced here rather than by each caller so a# script that only wants the caps does not have to know that.if [ -z "${IMAGE_NAME:-}" ]; then # shellcheck source=../../versions.env . "$ARENA_ROOT/versions.env"fi
say() { printf '%s\n' "$*"; }warn() { printf 'WARN: %s\n' "$*" >&2; }hint() { printf ' %s\n' "$*" >&2; }die() { printf 'ERROR: %s\n' "$*" >&2; exit 1; }
require_cmd() { local c for c in "$@"; do command -v "$c" >/dev/null || die "'$c' is required and is not installed" done}
# --- resource caps ----------------------------------------------------------## Production runs the match task at 2 vCPU and 8GB, so that is what a local run# gets. Two reasons, and the second is the one people forget: an uncapped# container is a hazard - three JVMs will take a whole laptop and the machine# stops responding - and it is also a useless measurement, because nothing is# ever deployed to sixteen cores.## There is no way to turn the caps off. The environment variables move them# within bounds; they do not remove them.ARENA_DEFAULT_CPUS="2"ARENA_DEFAULT_MEMORY="8g"ARENA_DEFAULT_PIDS="512"
# Bounds, not suggestions. Under the minimum the JVMs thrash or fail to start,# which reads as a broken image rather than a bad cap. Over the maximum the host# is the thing at risk, and this repo has one host.ARENA_MIN_CPUS_MILLI=1000ARENA_MAX_CPUS_MILLI=8000ARENA_MIN_MEMORY_MB=2048ARENA_MAX_MEMORY_MB=12288ARENA_MIN_PIDS=128ARENA_MAX_PIDS=4096
# The image's own title label, which every container started from an arena image# inherits. See arena_filter_containers.ARENA_IMAGE_TITLE="lance.blue arena"
# Our own label, added to containers these scripts start. The title label above# should be enough on its own; this one also catches a container whose image was# rebuilt without labels.ARENA_LOCAL_LABEL="blue.lance.arena.local"ARENA_SESSION_LABEL="blue.lance.arena.session"
# Who owns a container. More than one session works on this machine at a time -# several agents, and jmm - and the first version of this guard assumed one,# so `down` removed every arena container it could find and tore down other# people's matches mid-run. Their finalize never wrote, which read from the# outside like a bug in the collection.## Defaults to the checkout's directory name, because a session usually has its# own worktree, and it has to be stable across invocations: `up` and a later# `down` must agree on it. ARENA_SESSION overrides when two sessions share a# checkout.ARENA_SESSION="${ARENA_SESSION:-$(basename "$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)")}"# Container names allow [a-zA-Z0-9][a-zA-Z0-9_.-]*; a worktree can be called# anything.ARENA_SESSION="$(printf '%s' "$ARENA_SESSION" | tr -c 'a-zA-Z0-9_.-' '-')"
# arena_cpus_to_milli <value> -> thousandths of a CPU# Docker accepts fractional CPUs. Integer millicpus keep the comparisons in# bash, with no dependency on awk or bc.arena_cpus_to_milli() { local v="${1:-}" [[ "$v" =~ ^([0-9]+)(\.([0-9]{1,3}))?$ ]] || return 1 local whole="${BASH_REMATCH[1]}" frac="${BASH_REMATCH[3]:-}" frac="${frac}000" printf '%s\n' "$(( 10#$whole * 1000 + 10#${frac:0:3} ))"}
# arena_milli_to_cpus <millicpus> -> the string docker is givenarena_milli_to_cpus() { local m="$1" if [ $(( m % 1000 )) -eq 0 ]; then printf '%s\n' "$(( m / 1000 ))" else printf '%d.%03d\n' "$(( m / 1000 ))" "$(( m % 1000 ))" fi}
# arena_memory_to_mb <value> -> mebibytes# Docker's own units: b, k, m, g, or a bare byte count. A bare number is bytes# here too, exactly as docker reads it, so `ARENA_MEMORY=4096` meaning "4096MB"# lands under the floor and gets clamped loudly instead of quietly running at# four kilobytes.arena_memory_to_mb() { local v="${1:-}" [[ "$v" =~ ^([0-9]+)([bkmgBKMG]?)$ ]] || return 1 local n="${BASH_REMATCH[1]}" u="${BASH_REMATCH[2],,}" case "$u" in g) printf '%s\n' "$(( n * 1024 ))" ;; m) printf '%s\n' "$n" ;; k) printf '%s\n' "$(( n / 1024 ))" ;; b|"") printf '%s\n' "$(( n / 1048576 ))" ;; esac}
# arena_clamp <value> <min> <max> -> the value, held inside the rangearena_clamp() { local v="$1" lo="$2" hi="$3" if [ "$v" -lt "$lo" ]; then v="$lo"; fi if [ "$v" -gt "$hi" ]; then v="$hi"; fi printf '%s\n' "$v"}
# arena_resolve_caps## Reads ARENA_CPUS, ARENA_MEMORY and ARENA_PIDS; sets ARENA_CAP_CPUS,# ARENA_CAP_MEMORY, ARENA_CAP_PIDS and the ARENA_CAP_ARGS array docker is given.## A value that does not parse is fatal rather than clamped: a typo in an# override must not silently become the default, because the whole point of# setting one is that the default was not what you wanted.arena_resolve_caps() { local cpus_in="${ARENA_CPUS:-$ARENA_DEFAULT_CPUS}" local mem_in="${ARENA_MEMORY:-$ARENA_DEFAULT_MEMORY}" local pids_in="${ARENA_PIDS:-$ARENA_DEFAULT_PIDS}" local milli mb pids
milli="$(arena_cpus_to_milli "$cpus_in")" \ || die "ARENA_CPUS='$cpus_in' is not a number of CPUs (try 2 or 1.5)" mb="$(arena_memory_to_mb "$mem_in")" \ || die "ARENA_MEMORY='$mem_in' is not a docker size (try 8g or 4096m)" [[ "$pids_in" =~ ^[0-9]+$ ]] \ || die "ARENA_PIDS='$pids_in' is not a number" pids="$pids_in"
# Clamping is announced. A run that is capped harder than the caller asked # for and does not say so is a measurement nobody can trust. local c m p c="$(arena_clamp "$milli" "$ARENA_MIN_CPUS_MILLI" "$ARENA_MAX_CPUS_MILLI")" m="$(arena_clamp "$mb" "$ARENA_MIN_MEMORY_MB" "$ARENA_MAX_MEMORY_MB")" p="$(arena_clamp "$pids" "$ARENA_MIN_PIDS" "$ARENA_MAX_PIDS")" [ "$c" = "$milli" ] || warn "ARENA_CPUS=$cpus_in is out of range; using $(arena_milli_to_cpus "$c")" [ "$m" = "$mb" ] || warn "ARENA_MEMORY=$mem_in is out of range; using ${m}m" [ "$p" = "$pids" ] || warn "ARENA_PIDS=$pids_in is out of range; using $p"
ARENA_CAP_CPUS="$(arena_milli_to_cpus "$c")" ARENA_CAP_MEMORY="${m}m" ARENA_CAP_PIDS="$p"
# --memory-swap equal to --memory means no swap at all. Fargate has none, and # a container that swaps instead of failing takes the host down with it, which # is the failure these caps exist to prevent. # shellcheck disable=SC2034 # used by the scripts that source this file ARENA_CAP_ARGS=( --cpus "$ARENA_CAP_CPUS" --memory "$ARENA_CAP_MEMORY" --memory-swap "$ARENA_CAP_MEMORY" --pids-limit "$ARENA_CAP_PIDS" )}
# arena_caps_line -> the one line printed at every start## Printed even when the caps are the defaults. A run that feels uncapped but is# not confuses a measurement as badly as one that is capped and says nothing.arena_caps_line() { printf 'caps: %s CPUs, %s memory (no swap), %s pids\n' \ "$ARENA_CAP_CPUS" "$ARENA_CAP_MEMORY" "$ARENA_CAP_PIDS"}
# --- image selection --------------------------------------------------------
# arena_resolve_image [selector] -> one concrete image reference## scripts/image.sh resolves a selector - a tag, a short git sha, or the newest# local build - and lands on its own branch. Delegate to it when it is there.# With no selector, or with no helper, this is the versions.env composition# run.sh has always used, so nothing changes for someone who passes nothing.arena_resolve_image() { local sel="${1:-}" ref="" helper="$ARENA_ROOT/scripts/image.sh"
if [ -n "$sel" ] && [ -x "$helper" ]; then # Two spellings because the helper's argument surface is not settled yet. # Whichever answers, the reference is verified below regardless, so a # wrong guess fails as "no such image" rather than starting something else. ref="$("$helper" resolve "$sel" 2>/dev/null | tail -n 1)" || ref="" [ -n "$ref" ] || ref="$("$helper" "$sel" 2>/dev/null | tail -n 1)" || ref="" fi
if [ -z "$ref" ]; then case "$sel" in "") ref="${IMAGE_NAME}:${IMAGE_TAG}" ;; *:*) ref="$sel" ;; # already a full reference, tag or digest *) ref="${IMAGE_NAME}:${sel}" ;; esac fi
docker image inspect "$ref" >/dev/null 2>&1 || { printf 'ERROR: no local image %s\n' "$ref" >&2 hint "./scripts/build.sh builds one" hint "docker images ${IMAGE_NAME} lists what is here" return 1 } printf '%s\n' "$ref"}
# --- the singleton guard ----------------------------------------------------
# arena_filter_containers (stdin -> stdout)## Reads the rows arena_containers asks docker for and keeps the arena ones.# Split out from the docker call on purpose: this is the decision, and it is# the part worth testing, which cannot be done with a daemon in the way.## Three signals, any one of which is enough:# - the image's own title label, which a container inherits from the image,# so a re-tagged or renamed copy is still caught# - the label these scripts add# - the image repository, for a container someone started by hand## The name is deliberately not one of them. run.sh already refuses a name# collision; the failure that hurts is two or three matches at once under# different names, three JVMs each, on one laptop.## The field separator is a pipe, not a tab. A label that is not set comes back# empty, and bash's read collapses runs of tabs - two empty labels in a row# would shift every field along and the guard would read a container's state as# its name. A pipe cannot appear in a container name or an image reference.arena_filter_containers() { local scope="${1:-mine}" local id name image state title mine owner repo while IFS='|' read -r id name image state title mine owner; do [ -n "$id" ] || continue
repo="${image%@sha256:*}" # Strip the tag, but only when the colon is not a registry port. case "${repo##*:}" in */*) ;; "$repo") ;; *) repo="${repo%:*}" ;; esac
# Is this an arena container at all? Three signals, any one is enough: the # image's own title label, a label these scripts added, or the image name. if [ "$title" != "$ARENA_IMAGE_TITLE" ] && [ -z "$mine" ]; then case "$repo" in "$IMAGE_NAME" | arena | */arena) ;; *) continue ;; esac fi
# Whose is it? One check, after all three signals, because there is more # than one way in and an ownership test on only one of them is the same as # no test at all. An unlabelled container predates session scoping and is # nobody's, so only "any" claims it - guessing it is ours is exactly how # one session removed another's match mid-run. if [ "$scope" = mine ] && [ "$owner" != "$ARENA_SESSION" ]; then continue fi printf '%s|%s|%s|%s|%s\n' "$id" "$name" "$image" "$state" "$owner" done}
# arena_containers [all] -> ID|NAME|IMAGE|STATE, one line per container# arena_containers [running|all] [mine|any]## Scope defaults to "mine": this session's containers only. "any" is every# arena container on the machine, whoever started it, which is what the# laptop-protection count and `down --all` need.arena_containers() { local args=( --no-trunc ) scope="${2:-mine}" if [ "${1:-running}" = all ]; then args+=( -a ); fi docker ps "${args[@]}" --format \ "{{.ID}}|{{.Names}}|{{.Image}}|{{.State}}|{{.Label \"org.opencontainers.image.title\"}}|{{.Label \"$ARENA_LOCAL_LABEL\"}}|{{.Label \"$ARENA_SESSION_LABEL\"}}" \ | arena_filter_containers "$scope"}
# arena_require_none_running <how-to-stop># Refuses to go on when a match is already up, and says what to run.# arena_require_capacity <how-to-stop>## Two separate questions, which the old one-at-a-time rule ran together.## This session may only have one match up: a second would fight the first for# the port and mean nothing.## Other sessions may have their own, and refusing because of them was the bug -# it made cooperating agents, and jmm, unable to work at the same time. What# still matters is the laptop, so the total is capped instead. Every match is 2# CPUs and three JVMs, and enough of them at once is what takes the machine# down.arena_require_capacity() { local mine others total cap="${ARENA_MAX_MATCHES:-2}" mine="$(arena_containers running mine)" if [ -n "$mine" ]; then printf 'ERROR: this session already has a match running\n' >&2 while IFS='|' read -r _ line image _ _; do [ -n "$line" ] && hint "$line ($image)" done <<<"$mine" hint "$1" return 1 fi
others="$(arena_containers running any)" [ -n "$others" ] || return 0 total="$(grep -c . <<<"$others")"
if [ "$total" -ge "$cap" ]; then printf "ERROR: %s match(es) already running on this machine, cap is %s\n" \ "$total" "$cap" >&2 while IFS='|' read -r _ line image _ owner; do [ -n "$line" ] && hint "$line ($image) owned by ${owner:-an older run}" done <<<"$others" hint "these are not this session's; ask before stopping them" hint "ARENA_MAX_MATCHES raises the cap if the machine can take it" return 1 fi
# Room to coexist. Say who else is here, so a slow run is not a mystery. say "note: $total other match(es) running on this machine; sharing the box" return 0}
# --- the dev launch manifest ------------------------------------------------
# arena_write_manifest <image> <scenario> <human-or-empty> <dest># Sets ARENA_HUMAN to the faction the human actually got.## The manifest needs the scenario's faction list, and the scenario lives inside# the image, so it is read out of the image rather than assuming a copy on this# machine.# A human of "-" means nobody plays: every faction goes to Princess and the# browser watches. See container/init/20-identity.sh for what the container# makes of a manifest with no humans.arena_write_manifest() { local image="$1" scenario="$2" human="${3:-}" dest="$4" local in_image="/opt/arena/megamek/data/scenarios/$scenario" local factions players
# One throwaway container, capped like everything else here. It reads a line # out of a file; 1 CPU and 256m is generous. # # MMSVersion=1 puts every faction on one line - "Factions=TraineeA,TraineeB". factions="$( docker run --rm --cpus 1 --memory 256m --pids-limit 128 \ --entrypoint /bin/bash "$image" -c " set -eu [ -f '$in_image' ] || exit 1 sed -n 's/^Factions=//p' '$in_image' | head -1 | tr -d ' \r' " )" || die "no scenario at $scenario inside the image"
if [ -z "${factions:-}" ]; then printf "ERROR: no 'Factions=' line in %s.\n" "$scenario" >&2 hint "MMSVersion=2 scenarios are YAML and are not handled here yet." exit 1 fi
if [ "$human" = - ]; then ARENA_HUMAN="nobody (spectating)" else human="${human:-${factions%%,*}}" case ",$factions," in *",$human,"*) ;; *) die "'$human' is not a faction in $scenario (has: $factions)" ;; esac # shellcheck disable=SC2034 # used by the scripts that source this file ARENA_HUMAN="$human" fi
# Every faction gets a slot, not just the human: a slot missing from the # manifest is a faction nobody plays, and init rejects a manifest with fewer # than two players. Whatever is not the human is a bot - and in spectator # mode that is all of them. players="$(jq -nc --arg all "$factions" --arg human "$human" ' $all | split(",") | map( if . == $human then { slot: ., control: "human", did: "did:plc:localdev", handle: "local.dev" } else { slot: ., control: "bot" } end)')"
# Seconds before the host readies the human seats itself, or 0 to leave them # to a person. MatchHost has always taken this - it calls it diagnostic mode, # and synthesises the same packet the Done button sends - but the local # manifest pinned it at 0, so a local match with a human seat sat in the # lounge until somebody clicked. # # That is the wrong shape for anything being measured. A profile or a latency # run wants a seat whose input the server answers, and a lobby is exactly # what made the first client profile misleading (see PERFORMANCE.md). 0 is # still the default, so nothing changes for a person playing. case "${ARENA_AUTO_READY:-0}" in ''|*[!0-9]*) die "ARENA_AUTO_READY must be a whole number of seconds, got '${ARENA_AUTO_READY}'" ;; esac
jq -n \ --arg name "$scenario" \ --argjson players "$players" \ --argjson autoready "${ARENA_AUTO_READY:-0}" \ '{ version: 1, matchId: "local-dev", scenario: { name: $name }, players: $players, observer: { enabled: true }, limits: { lingerSeconds: 60, autoReadySeconds: $autoready } }' > "$dest"
# A bind mount keeps the host's ownership, and the container runs as uid 10001 # while this file is written by you. mktemp makes it 0600, so the container # cannot read it. # # World-readable is fine *for this file only*: a dev manifest points at a # scenario already inside the image and carries no upload targets, so there is # no credential in it. A real manifest arrives over ARENA_MANIFEST_URL and is # written 0600 inside the container, never through a mount. chmod 644 "$dest"}
# arena_free_port <start>## The first free port at or above <start>, so two sessions can each have a# match. Checked by trying to connect: something answering means taken. Racy in# principle - two sessions could pick the same port in the same instant - but# docker refuses the second bind and `up` fails loudly rather than quietly# sharing.arena_free_port() { local p="${1:-8080}" limit=$(( ${1:-8080} + 20 )) while [ "$p" -lt "$limit" ]; do if ! (exec 3<>"/dev/tcp/127.0.0.1/$p") 2>/dev/null; then printf '%s\n' "$p" return 0 fi exec 3<&- 2>/dev/null || true p=$(( p + 1 )) done return 1}
# --- readiness --------------------------------------------------------------
# arena_probe <port>## Ready means the HTTP endpoint answers. The process existing is not the same# thing: the entrypoint runs init, unpacks and starts three JVMs long before# Suramadu binds, and a caller that measures from "docker run returned" has# measured nothing.## Silent, including on failure. This is a poll: every attempt before the last# one is expected to fail, and -S printed each of them, so a normal start# spat "curl: (56) Recv failure: Connection reset by peer" at the user twice# before saying "ready". A caller that needs to explain a failure has the# container's logs, which is a better answer than the last curl error.arena_probe() { # The container root, not a client path: clients are rendered per seat at # /<did> now, so the root is the one URL every match serves. curl -fs -o /dev/null --max-time "${ARENA_PROBE_TIMEOUT:-3}" \ "http://localhost:${1}/" 2>/dev/null}