diff --git a/scripts/serve-frank-archon.sh b/scripts/serve-frank-archon.sh index 7d5f0cc..98b87f5 100755 --- a/scripts/serve-frank-archon.sh +++ b/scripts/serve-frank-archon.sh @@ -26,6 +26,13 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# The artifact gates are the shared library: the serve script and the +# deployer must refuse the same missing/truncated payload with the same +# words (the fleet law from the 2026-10-01 crash-loop). +export GATE_PREFIX=frank-archon +# shellcheck disable=SC1091 +source "$SCRIPT_DIR/lib/llm-gates.sh" ENV_FILE="${FRANK_ARCHON_ENV_FILE:-$SCRIPT_DIR/frank-archon.env}" if [ ! -f "$ENV_FILE" ]; then @@ -65,38 +72,22 @@ set +a # The image gate is the digest-pinned ref itself: a name@sha256 ref exists # in storage only if THAT manifest was pulled — a retagged look-alike or a -# newer latest does not satisfy it. `podman image exists` takes a -# name@digest ref in one command — no pipefail/grep race (the 2026-09-30 -# lesson). Runs as dank: the unit's process uses dank's ROOTLESS podman -# storage (root unit, User=dank — the fleet's frank-archon shape). -if ! podman image exists "$FRANK_ARCHON_IMAGE"; then - echo "ERROR: radiance image absent: $FRANK_ARCHON_IMAGE" >&2 - echo "frank-archon: podman pull docker://stilldeadcode/radiance:latest (as dank)" >&2 - exit 1 -fi +# newer latest does not satisfy it. One command, no pipefail/grep race (the +# 2026-09-30 lesson). Runs as dank: the unit's process uses dank's ROOTLESS +# podman storage (root unit, User=dank — the fleet's frank-archon shape). +require_image "$FRANK_ARCHON_IMAGE" \ + "podman pull docker://stilldeadcode/radiance:latest (as dank)" || exit 1 # The payload gate: the .rad container file, sized. A truncated convert # would otherwise buy a guaranteed long-timeout boot instead of this fast # failure. The staging dir / raw checkpoint are NOT this script's concern -# (scripts/build-frank-archon-rad.sh built the file; its rad-info output -# is the record). -RAD="$FRANK_ARCHON_MODEL_DIR/$FRANK_ARCHON_MODEL_FILE" -if [ ! -f "$RAD" ]; then - echo "ERROR: .rad container missing: $RAD" >&2 - echo "frank-archon: run scripts/build-frank-archon-rad.sh" >&2 - exit 1 -fi -RAD_MIB=$(( $(stat -c %s "$RAD") / 1048576 )) -if [ "$RAD_MIB" -lt "$FRANK_ARCHON_RAD_MIN_MIB" ]; then - echo "ERROR: .rad container too small: $RAD is ${RAD_MIB} MiB, want >= $FRANK_ARCHON_RAD_MIN_MIB" >&2 - echo "frank-archon: the convert was truncated — rebuild it" >&2 - exit 1 -fi +# (the convert script built the file; its rad-info output is the record). +require_min_mib "$FRANK_ARCHON_MODEL_DIR/$FRANK_ARCHON_MODEL_FILE" \ + container "$FRANK_ARCHON_RAD_MIN_MIB" \ + "build it: scripts/convert-swift15-w4dn8.sh (rad-info is the record)" || exit 1 -if [ ! -f "$FRANK_ARCHON_TEMPLATE" ]; then - echo "ERROR: chat template missing: $FRANK_ARCHON_TEMPLATE" >&2 - exit 1 -fi +require_file "$FRANK_ARCHON_TEMPLATE" template \ + "the chat template is vendored: scripts/qwen38-froggeric-v22.5.jinja" || exit 1 mkdir -p "$FRANK_ARCHON_STATE_DIR/kvcache/frank-archon" # The engine's create is NOT recursive: with only the state dir mounted, its @@ -112,8 +103,12 @@ mkdir -p "$FRANK_ARCHON_STATE_DIR/tune" # that wrote it. The dir is pre-created for the same reason as kvcache # (the engine's create is not recursive). -if [ ! -e /dev/kfd ] || [ ! -d /dev/dri ]; then - echo "ERROR: GPU device nodes absent (/dev/kfd, /dev/dri)" >&2 +# GPU access is mandatory; the node paths are knobs so the seam can drive +# this gate on a box without AMD GPUs (production defaults). +DEV_KFD="${FRANK_ARCHON_DEV_KFD:-/dev/kfd}" +DEV_DRI="${FRANK_ARCHON_DEV_DRI:-/dev/dri}" +if [ ! -e "$DEV_KFD" ] || [ ! -d "$DEV_DRI" ]; then + echo "ERROR: GPU device nodes absent ($DEV_KFD, $DEV_DRI)" >&2 exit 1 fi @@ -129,8 +124,9 @@ ttm_cap_mib() { # min over the knobs THIS kernel exposes — the current kernel has # pages_limit only (no pages_max); a knob that is absent is skipped, # not a zero (a zero would refuse a healthy cap). - local v cur="" - for p in /sys/module/ttm/parameters/pages_limit /sys/module/ttm/parameters/pages_max; do + local v cur="" dir + dir="${FRANK_ARCHON_TTM_SYSFS:-/sys/module/ttm/parameters}" + for p in "$dir/pages_limit" "$dir/pages_max"; do [ -r "$p" ] || continue v="$(cat "$p" 2>/dev/null)" || continue [ -n "$v" ] || continue @@ -155,7 +151,7 @@ for CTR in $FRANK_ARCHON_LEGACY_CONTAINERS "$FRANK_ARCHON_CONTAINER"; do podman rm -f "$CTR" >/dev/null 2>&1 || true done -LOG=/home/dank/logs/frank_archon.log +LOG="${FRANK_ARCHON_LOG:-/home/dank/logs/frank_archon.log}" mkdir -p "$(dirname "$LOG")" # Publisher-vetted container contract (docs/DOCKER.md, compose/common.yaml): diff --git a/tests/frank_archon_unit.bats b/tests/frank_archon_unit.bats index f1891ae..02a4feb 100755 --- a/tests/frank_archon_unit.bats +++ b/tests/frank_archon_unit.bats @@ -19,7 +19,11 @@ # the vendored hfget fetch path (no token for this ungated repo), and the # client contract (reasoning auto -> reasoning_content; models.yml maps the # field on the harness side, not here). - +# The serve script is EXECUTED against fixtures (stub podman, stub device +# nodes, stub ttm sysfs knobs, a temp payload dir) so the gates are observed +# as the operator observes them: exit status and the words on stderr. The +# knobs that make that possible (FRANK_ARCHON_DEV_KFD, _DEV_DRI, +# _TTM_SYSFS_DIR, _LOG) default to the production paths. load helpers UNIT="$REPO_ROOT/scripts/frank-archon.service" @@ -30,6 +34,133 @@ DOWNLOAD="$REPO_ROOT/scripts/download-frank-archon-swift15.sh" BUILD="$REPO_ROOT/scripts/convert-swift15-w4dn8.sh" HFGET="$REPO_ROOT/scripts/hfget.sh" TEMPLATE="$REPO_ROOT/scripts/qwen38-froggeric-v22.5.jinja" +# +# Fixtures: a full env derived from the SHIPPED frank-archon.env with only +# the host paths redirected, so a payload rename moves the env, not this file. +setup() { + STAGE="$(mktemp -d)" + MODELS="$STAGE/models" + STATE="$STAGE/state" + TTM="$STAGE/ttm" + mkdir -p "$MODELS" "$STATE" "$TTM" "$STAGE/bin" "$STAGE/dri" + touch "$STAGE/kfd" "$STAGE/template.jinja" + # A 3 MiB .rad clears the 2 MiB floor the fixture env pins. + dd if=/dev/zero of="$MODELS/payload.rad" bs=1048576 count=3 status=none + # 200 GiB of pages, the shipped cap's value, in 4 KiB pages. + printf '52428800\n' > "$TTM/pages_limit" + cat > "$STAGE/bin/podman" <<'STUB' +#!/usr/bin/env bash +case "$1" in + image) + case " ${STUB_IMAGE:-} " in + *" $3 "*) exit 0 ;; + *) exit 1 ;; + esac ;; + rm) exit 0 ;; + run) echo "PODMAN $*" >> "${STUB_ARGV:-/dev/null}"; exit 0 ;; + *) exit 0 ;; +esac +STUB + chmod +x "$STAGE/bin/podman" + IMAGE="$(sed -nE 's/^FRANK_ARCHON_IMAGE=(.+)$/\1/p' "$ENV_FILE")" + # The fixture env IS the shipped env with only the host paths and the + # size floor redirected, so a payload rename moves frank-archon.env and + # this file follows with it. + local re="s#^FRANK_ARCHON_MODEL_DIR=.*#FRANK_ARCHON_MODEL_DIR=$MODELS#;" + re+=" s#^FRANK_ARCHON_MODEL_FILE=.*#FRANK_ARCHON_MODEL_FILE=payload.rad#;" + re+=" s#^FRANK_ARCHON_RAD_MIN_MIB=.*#FRANK_ARCHON_RAD_MIN_MIB=2#;" + re+=" s#^FRANK_ARCHON_TEMPLATE=.*#FRANK_ARCHON_TEMPLATE=$STAGE/template.jinja#;" + re+=" s#^FRANK_ARCHON_STATE_DIR=.*#FRANK_ARCHON_STATE_DIR=$STATE#;" + re+=" s#^FRANK_ARCHON_TTM_MIN_MIB=.*#FRANK_ARCHON_TTM_MIN_MIB=150000#" + sed -E "$re" "$ENV_FILE" > "$STAGE/serve.env" + { + echo "FRANK_ARCHON_TTM_SYSFS=$TTM" + echo "FRANK_ARCHON_DEV_KFD=$STAGE/kfd" + echo "FRANK_ARCHON_DEV_DRI=$STAGE/dri" + echo "FRANK_ARCHON_LOG=$STAGE/serve.log" + echo "STUB_IMAGE=$IMAGE" + echo "STUB_ARGV=$STAGE/argv.txt" + } >> "$STAGE/serve.env" +} + +teardown() { + rm -rf "$STAGE" +} + +# serve_run — execute the serve script against the fixtures. +serve_run() { + run env PATH="$STAGE/bin:$PATH" FRANK_ARCHON_ENV_FILE="$STAGE/serve.env" \ + bash "$SERVE" +} + +@test "the serve script refuses when the image is not in storage" { + # The env file wins over the ambient environment (the serve script + # sources it with `set -a`), so the stub's storage is emptied in the file. + sed -i 's#^STUB_IMAGE=.*#STUB_IMAGE=#' "$STAGE/serve.env" + serve_run + [ "$status" -ne 0 ] + [[ "$output" == *"not in podman storage"* ]] + [[ "$output" == *"podman pull"* ]] +} + +@test "the serve script refuses a missing .rad and names its builder" { + rm -f "$MODELS/payload.rad" + serve_run + [ "$status" -ne 0 ] + [[ "$output" == *"container: missing"*"$MODELS/payload.rad"* ]] + [[ "$output" == *"scripts/convert-swift15-w4dn8.sh"* ]] +} + +@test "the serve script refuses a .rad below the size floor, naming both sizes" { + # A truncated rad-convert is a guaranteed long-timeout boot; the floor is + # the fast failure (FRANK_ARCHON_RAD_MIN_MIB). + dd if=/dev/zero of="$MODELS/payload.rad" bs=1048576 count=1 status=none + serve_run + [ "$status" -ne 0 ] + [[ "$output" == *"1 MiB"* ]] + [[ "$output" == *"2 MiB"* ]] +} + +@test "the serve script refuses an absent chat template" { + # The engine is started with --override-chat-template; a missing file is + # a container that dies at argv parse, not a fast refusal. + rm -f "$STAGE/template.jinja" + serve_run + [ "$status" -ne 0 ] + [[ "$output" == *"template: missing"*"$STAGE/template.jinja"* ]] +} + +@test "the serve script refuses when the ttm pages cap is below the pinned ledger" { + printf '1048576\n' > "$TTM/pages_limit" # 4 GiB, under the ~147 GiB ledger + serve_run + [ "$status" -ne 0 ] + [[ "$output" == *"ttm pages cap is 4096 MiB"* ]] + [[ "$output" == *"150000"* ]] +} + +@test "the serve script refuses when the GPU device nodes are absent" { + rm -f "$STAGE/kfd" + serve_run + [ "$status" -ne 0 ] + [[ "$output" == *"GPU device nodes absent"*"$STAGE/kfd"* ]] +} + +@test "the serve script runs the engine with the .rad, the template and TP=2" { + serve_run + [ "$status" -eq 0 ] + argv="$(cat "$STAGE/argv.txt")" + grep -q -- "--model /models/payload.rad" <<<"$argv" + grep -q -- "-v $MODELS:/models:ro" <<<"$argv" + grep -q -- "--override-chat-template /templates/template.jinja" <<<"$argv" + grep -q -- "--tp $(sed -nE 's/^FRANK_ARCHON_TP=([0-9]+)$/\1/p' "$ENV_FILE")" <<<"$argv" + grep -q -- "--tp-wire $(sed -nE 's/^FRANK_ARCHON_TP_WIRE=(.+)$/\1/p' "$ENV_FILE")" <<<"$argv" + grep -q -- "--kv-cache-dtype $(sed -nE 's/^FRANK_ARCHON_KV_DTYPE=(.+)$/\1/p' "$ENV_FILE")" <<<"$argv" + # The container name is the unit's ExecStop target, and the host-network + # contract publishes no port. + grep -q -- "--name $(sed -nE 's/^FRANK_ARCHON_CONTAINER=(.+)$/\1/p' "$ENV_FILE")" <<<"$argv" + grep -q -- "--network host" <<<"$argv" + ! grep -q -- "--publish" <<<"$argv" +} @test "frank-archon unit, serve script, env, fetch and build scripts and template exist" { [ -f "$UNIT" ] @@ -255,22 +386,6 @@ TEMPLATE="$REPO_ROOT/scripts/qwen38-froggeric-v22.5.jinja" ! grep -q 'FRANK_ARCHON_TOOL_PARSER' "$ENV_FILE" } -@test "the serve script gates the image, the .rad payload, the template and the GPU nodes" { - # The image gate is the digest ref itself; the payload gate checks the - # .rad file and its size floor — a truncated convert would otherwise be - # a guaranteed long-timeout boot instead of a fast failure. The ttm - # preflight refuses to start under a pages cap below the pinned ledger. - grep -q 'FRANK_ARCHON_IMAGE' "$SERVE" - grep -q 'podman image exists' "$SERVE" - grep -q 'FRANK_ARCHON_MODEL_FILE' "$SERVE" - grep -q 'FRANK_ARCHON_RAD_MIN_MIB' "$SERVE" - grep -q 'FRANK_ARCHON_TEMPLATE' "$SERVE" - grep -q 'FRANK_ARCHON_TTM_MIN_MIB' "$SERVE" - grep -q 'pages_limit' "$SERVE" - grep -q 'pages_max' "$SERVE" - grep -q '/dev/kfd' "$SERVE" - grep -q -- '--device /dev/dri' "$SERVE" -} @test "the serve script clears stale containers and self-heals via podman wait" { # A crashed unit can leave the container name registered forever after;