refact(frank-archon): serve gates run on the shared library; the serve seam is behavioural
The image/.rad/template gates are require_image/require_min_mib/require_file, and the gates an operator can only reach on the host (GPU nodes, the ttm pages cap, the run line) are now drivable: FRANK_ARCHON_DEV_KFD, FRANK_ARCHON_DEV_DRI, FRANK_ARCHON_TTM_SYSFS and FRANK_ARCHON_LOG default to the production paths. Seven new seam tests EXECUTE the serve script against fixtures (stub podman, stub nodes, stub sysfs, temp payload) and assert exit status, the refusal words and the engine argv. The old text-grep gate test is deleted: it pinned the words of the source, not the behaviour.
This commit is contained in:
@@ -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):
|
||||
|
||||
+132
-17
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user