Runtime swap on the RTX 3090 (port 7771): - vLLM gemma-x86_64-cu129 dev build, digest-pinned, audio extra pip-installed into localhost/vllm/vllm-openai:frank-small (Dockerfile, no COPY lines) - google/gemma-4-12B-it-qat-w4a16-ct (10.26 GiB int4) + Google's bf16 MTP assistant (845 MB) — the w4a16-ct assistant cannot draft in vLLM (drafter path skips Marlin; acceptance 1.00), bf16 measures 2.80-3.56 - root systemd unit -> rootless Quadlet (dankclaw/frank-small/quadlet/); manual CUDA device+lib mounts (no nvidia-container-toolkit on host) - bf16 KV (fp8 hardware-dead on sm_86), 289,647-token pool, 2 concurrent full-context sessions, CUDA graphs on - native text+image+audio: the fleet's multimodal endpoint - measured: pp@67k 1293 t/s, pp@213k 608 t/s, tg@213k ~13.6 t/s, tg@85k ~17.6 t/s - host driver 610.57.04 (Ubuntu 26.04); sm_86 is correct, no arch override - planner/scoping: frank-small now selected by dankclaw/frank-small/ + env/serve/download trio; old unit retired to scripts/.AGENTTRASH/ - docs: new runbook frank-small-vllm-gemma4.md; AGENTS.md, LLM index, top docs index updated; old runbook moved to retired
232 lines
9.6 KiB
Bash
232 lines
9.6 KiB
Bash
#!/usr/bin/env bats
|
|
# Seam: service-scoped deployment planning.
|
|
# A repository commit can contain changes for several workloads while only a
|
|
# subset is safe to restart. The planner must keep inference isolated from
|
|
# unrelated service changes and treat docs-only changes as a no-op.
|
|
|
|
load helpers
|
|
|
|
PLANNER="$REPO_ROOT/scripts/deploy-plan.py"
|
|
REMOTE_DEPLOY="$REPO_ROOT/scripts/deploy-remote.sh"
|
|
|
|
@test "deployment planner maps SearXNG changes to SearXNG only" {
|
|
run python3 "$PLANNER" --target dankclaw --paths \
|
|
shared/searxng/settings.yml serverAppConfigs-docs/Dankclaw/SearXNG.md
|
|
[ "$status" -eq 0 ]
|
|
[ "$output" = "searxng" ]
|
|
}
|
|
|
|
@test "deployment planner maps large-model changes to the large model only" {
|
|
run python3 "$PLANNER" --target dankclaw --paths \
|
|
dankclaw/frank-large/quadlet/frank-large.container
|
|
[ "$status" -eq 0 ]
|
|
[ "$output" = "frank-large" ]
|
|
}
|
|
|
|
@test "the retired NPU endpoint is absent from the deployment inventory" {
|
|
! grep -q '"frank-npu"' "$PLANNER"
|
|
! grep -q 'frank_npu\|frank-npu' "$REMOTE_DEPLOY"
|
|
}
|
|
|
|
@test "the retired ik chat template plans nothing, and its archive is still here" {
|
|
# scripts/chat-template.jinja existed only for the ik_llama.cpp runtime
|
|
# (retired 2026-09-16 in favour of halogen-flash-server again). The
|
|
# llama.cpp frank-small runtime serves the template the GGUF itself
|
|
# carries, which is the one with reasoning_effort, preserve_thinking and
|
|
# the tool-call grammar. Editing the archive must deploy nothing -- and the
|
|
# archive must still be present, so this cannot pass by the file having
|
|
# vanished.
|
|
[ -f "$REPO_ROOT/scripts/.AGENTTRASH/chat-template.jinja.ik-llama-qwen-sharp" ]
|
|
run python3 "$PLANNER" --target dankclaw --paths scripts/chat-template.jinja
|
|
[ "$status" -eq 0 ]
|
|
[ -z "$output" ]
|
|
|
|
run python3 "$PLANNER" --target dankserver --paths scripts/chat-template.jinja
|
|
[ "$status" -eq 0 ]
|
|
[ -z "$output" ]
|
|
}
|
|
|
|
@test "deployment planner maps the frank-medium scripts trio to frank-medium only" {
|
|
run python3 "$PLANNER" --target dankserver --paths \
|
|
scripts/frank-medium.env scripts/frank-medium.service scripts/serve-frank-medium.sh
|
|
[ "$status" -eq 0 ]
|
|
[ "$output" = "frank-medium" ]
|
|
|
|
# The path is not the assertion: with the vllm-radiance service dir
|
|
# retired into the trash, a planner rule still naming dankserver/frank-medium
|
|
# would plan nothing and look identical to a correct one. Assert the
|
|
# invariant (the dir is gone) as well as the mapping above.
|
|
[ ! -d "$REPO_ROOT/dankserver/frank-medium" ]
|
|
run python3 "$PLANNER" --target dankserver --paths dankserver/frank-medium/quadlet/frank-medium-a.container
|
|
[ "$status" -eq 0 ]
|
|
[ -z "$output" ]
|
|
}
|
|
|
|
@test "the small endpoint is Dankclaw's again, and Dankserver's copy stays retired" {
|
|
run python3 "$PLANNER" --target dankclaw --paths \
|
|
dankclaw/frank-small/quadlet/frank-small.container scripts/frank-small.env \
|
|
scripts/serve-frank-small.sh scripts/download-frank-small-vllm.sh
|
|
[ "$status" -eq 0 ]
|
|
[ "$output" = "frank-small" ]
|
|
|
|
# frank-small runs on Dankclaw on the RTX 3090; the Dankserver endpoint of
|
|
# that name was retired with the FreeToken runtime on 2026-09-15 and must
|
|
# not come back through a planner mapping.
|
|
run python3 "$PLANNER" --target dankserver --paths \
|
|
dankclaw/frank-small/quadlet/frank-small.container scripts/frank-small.env \
|
|
scripts/serve-frank-small.sh scripts/download-frank-small-vllm.sh
|
|
[ "$status" -eq 0 ]
|
|
[ -z "$output" ]
|
|
}
|
|
|
|
@test "documentation-only changes plan no runtime deployment" {
|
|
run python3 "$PLANNER" --target dankclaw --paths \
|
|
serverAppConfigs-docs/Architecture/Deployment\ Flow.md serverAppConfigs-docs/Dankclaw/SearXNG.md
|
|
[ "$status" -eq 0 ]
|
|
[ -z "$output" ]
|
|
}
|
|
|
|
@test "deployment planner maps shared config to one service" {
|
|
run python3 "$PLANNER" --target dankclaw --paths \
|
|
shared/prometheus/prometheus.yml
|
|
[ "$status" -eq 0 ]
|
|
[ "$output" = "prometheus" ]
|
|
}
|
|
|
|
@test "deployment planner maps shared observability configs independently" {
|
|
run python3 "$PLANNER" --target dankclaw --paths shared/loki/loki-config.yaml
|
|
[ "$status" -eq 0 ]
|
|
[ "$output" = "loki" ]
|
|
|
|
run python3 "$PLANNER" --target dankclaw --paths shared/alloy/config.alloy
|
|
[ "$status" -eq 0 ]
|
|
[ "$output" = "alloy" ]
|
|
}
|
|
|
|
@test "deployment planner maps service-owned non-compose files" {
|
|
run python3 "$PLANNER" --target dankclaw --paths dankclaw/hermes/config.yaml.example
|
|
[ "$status" -eq 0 ]
|
|
[ "$output" = "hermes" ]
|
|
}
|
|
|
|
@test "homepage is owned by Dankclaw's consolidated proxy" {
|
|
run python3 "$PLANNER" --target dankserver --paths shared/homepage/index.html
|
|
[ "$status" -eq 0 ]
|
|
[ -z "$output" ]
|
|
}
|
|
|
|
@test "compose planner narrows a compose edit to the changed service" {
|
|
local base head
|
|
base=$(git -C "$REPO_ROOT" rev-parse HEAD)
|
|
head=$(git -C "$REPO_ROOT" rev-parse HEAD)
|
|
run python3 "$PLANNER" --target dankclaw --repo "$REPO_ROOT" \
|
|
--base "$base" --head "$head" --paths dankclaw/compose.yaml
|
|
[ "$status" -eq 0 ]
|
|
[ -z "$output" ]
|
|
}
|
|
|
|
@test "network-coupled SearXNG config changes include the required peers" {
|
|
run python3 "$PLANNER" --target dankclaw --paths \
|
|
dankclaw/searxng/quadlet/gluetun.container
|
|
[ "$status" -eq 0 ]
|
|
[ "$output" = $'gluetun\nsearxng\nmarreta' ]
|
|
}
|
|
|
|
@test "retiring the standalone Gitea proxy deploys the canonical Caddy" {
|
|
run python3 "$PLANNER" --target dankclaw --paths \
|
|
dankclaw/gitea/quadlet/gitea-caddy.container
|
|
[ "$status" -eq 0 ]
|
|
[ "$output" = "caddy" ]
|
|
}
|
|
|
|
@test "Gitea network changes exclude the retired standalone proxy" {
|
|
run python3 "$PLANNER" --target dankclaw --paths \
|
|
dankclaw/gitea/quadlet/gitea.network
|
|
[ "$status" -eq 0 ]
|
|
[ "$output" = $'gitea-postgres\ngitea-server' ]
|
|
}
|
|
|
|
@test "frankbook-pro gluetun changes carry the whole netns family; arr changes stay scoped" {
|
|
run python3 "$PLANNER" --target frankbook-pro --paths \
|
|
frankbook-pro/gluetun/quadlet/gluetun.container
|
|
[ "$status" -eq 0 ]
|
|
[ "$output" = $'gluetun\nprowlarr\nqbittorrent\nradarr\nsonarr\nlidarr\nreadarr' ]
|
|
run python3 "$PLANNER" --target frankbook-pro --paths \
|
|
frankbook-pro/sonarr/quadlet/sonarr.container
|
|
[ "$status" -eq 0 ]
|
|
[ "$output" = "sonarr" ]
|
|
}
|
|
|
|
@test "remote Caddy deployment no longer blocks on the retired Gitea proxy" {
|
|
! grep -q 'refusing to replace it with caddy' "$REMOTE_DEPLOY"
|
|
! grep -q 'podman container exists gitea-caddy &&' "$REMOTE_DEPLOY"
|
|
}
|
|
|
|
@test "remote Caddy deployment retires the installed Gitea proxy" {
|
|
grep -q 'retire_legacy_gitea_caddy' "$REMOTE_DEPLOY"
|
|
grep -q 'systemctl --user stop gitea-caddy.service' "$REMOTE_DEPLOY"
|
|
grep -q 'systemctl --user disable gitea-caddy.service' "$REMOTE_DEPLOY"
|
|
grep -q 'gitea-caddy.container' "$REMOTE_DEPLOY"
|
|
grep -q 'HOME/.AGENTTRASH' "$REMOTE_DEPLOY"
|
|
grep -q 'stage_legacy_gitea_caddy_state' "$REMOTE_DEPLOY"
|
|
}
|
|
|
|
@test "remote deployment inventory no longer accepts gitea-caddy as a service" {
|
|
! grep -qE 'dankclaw:gitea-caddy|gitea-caddy\) deploy' "$REPO_ROOT/scripts/deploy.sh" "$REPO_ROOT/scripts/deploy-remote.sh"
|
|
! grep -q '"gitea-caddy"' "$REPO_ROOT/scripts/deploy-plan.py"
|
|
}
|
|
|
|
@test "remote deployer carries per-service deployment state" {
|
|
[ -x "$REMOTE_DEPLOY" ]
|
|
grep -q 'deploy-state' "$REMOTE_DEPLOY"
|
|
grep -q 'STATE\[' "$REMOTE_DEPLOY"
|
|
grep -q 'mark_status.*pending\|status=pending' "$REMOTE_DEPLOY"
|
|
grep -q 'mark_status.*deployed\|status=deployed' "$REMOTE_DEPLOY"
|
|
}
|
|
|
|
@test "remote deployer routes inference restarts through selected handlers" {
|
|
# The runner may restart inference only from the service-dispatch path. It
|
|
# must not contain a host-wide restart loop.
|
|
grep -q 'deploy_llm_large' "$REMOTE_DEPLOY"
|
|
grep -q 'deploy_llm_medium' "$REMOTE_DEPLOY"
|
|
grep -q 'deploy_service' "$REMOTE_DEPLOY"
|
|
! grep -qE 'for .*frank-(small|large|npu)-?' "$REMOTE_DEPLOY"
|
|
}
|
|
|
|
@test "remote runner rejects services belonging to the other host" {
|
|
run env DEPLOY_STATE_DIR="$(mktemp -d)" REMOTE_DIR="$REPO_ROOT" \
|
|
bash "$REMOTE_DEPLOY" dankserver "$(git rev-parse HEAD)" frank-default-large
|
|
[ "$status" -eq 2 ]
|
|
}
|
|
|
|
@test "remote runner recognizes a pending service at the same revision" {
|
|
# Contract superseded 2026-09-17: recognition used to mean "retry and
|
|
# hard-fail at the dispatcher" (exit 2), which let a retired service's
|
|
# stale pending entry (frank-default-sightsound) block every deploy
|
|
# before the e2e gate. Recognition now means: name the entry, prune it
|
|
# to the trash, and keep the run going.
|
|
local state_dir="$(mktemp -d)"
|
|
printf 'revision=%s\nstatus=pending\n' "$(git rev-parse HEAD)" > "$state_dir/unknown-service"
|
|
run env DEPLOY_STATE_DIR="$state_dir" REMOTE_DIR="$REPO_ROOT" HOME="$(mktemp -d)" \
|
|
bash "$REMOTE_DEPLOY" dankclaw "$(git rev-parse HEAD)" --pending
|
|
[ "$status" -eq 0 ]
|
|
[[ "$output" == *"unknown-service"* ]]
|
|
[ ! -e "$state_dir/unknown-service" ]
|
|
}
|
|
|
|
@test "top-level deployer delegates to the service-scoped remote runner" {
|
|
grep -q 'deploy-remote.sh' "$REPO_ROOT/scripts/deploy.sh"
|
|
grep -qE 'SERVICE|service' "$REPO_ROOT/scripts/deploy.sh"
|
|
grep -q 'OLD_REVISION' "$REPO_ROOT/scripts/deploy.sh"
|
|
grep -q 'OLD_REVISION\|old checkout revision' "$REPO_ROOT/scripts/deploy.sh"
|
|
}
|
|
|
|
@test "legacy service deploy wrappers delegate to the scoped deployer" {
|
|
for script in deploy-searxng-quadlet.sh deploy-pihole-quadlet.sh deploy-open-webui-quadlet.sh; do
|
|
grep -q 'deploy.sh' "$REPO_ROOT/scripts/$script" || {
|
|
echo "$script does not delegate to deploy.sh"
|
|
return 1
|
|
}
|
|
done
|
|
}
|