#!/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=1000 ARENA_MAX_CPUS_MILLI=8000 ARENA_MIN_MEMORY_MB=2048 ARENA_MAX_MEMORY_MB=12288 ARENA_MIN_PIDS=128 ARENA_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 -> 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 -> the string docker is given arena_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 -> 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 -> the value, held inside the range arena_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 # Refuses to go on when a match is already up, and says what to run. # arena_require_capacity # # 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 # 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 # # The first free port at or above , 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 # # 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 # / 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 }