docs: distill the managed skill library into the vault (waves 1-7)

All ~173 managed skills read in full and merged, one polished reference
page per skill-cluster:

- LLM cross-cutting (17 pages): GGUF Compatibility, Llama Cpp Serving
  Sizing, Llama Cpp Multi-Device Splits, PCIe Link and Atomics, RDNA4
  Container Serving, RDNA4 MoE Expert Offload VRAM Budget, vLLM Boot
  Forensics, vLLM Porting and Compile Triage, LLM Bench Windows, LLM
  Performance Diagnosis, Model Swap and Rollback, Tcclaviger Fork Ops,
  Heterogeneous Split Triage, FreeToken Builds + deltas folded into the
  retired runbooks (GLM DFlash2 port laws, Lucebox dual-R9700, R9V
  qualification pipeline, FLM pack registration, ik hybrid envelope,
  frank-medium split rebalance, llama.cpp GGUF preflight, batch-ubatch
  split section, bench-window D-state/stale-read notes, second-endpoint
  bring-up).
- Architecture (3): Stale Signal Audit, Quadlet Lifecycle, Deploy State
  Triage.
- Operations (6 + index): Remote Job Runner, Payload and Disk Reclaim,
  Container Host Reachability, Fleet Ops Playbook, Fleet Audit and
  Posture, SSH and Remote Access.
- Infrastructure: Observability Provisioning + Homepage Status Probes;
  updates to Authentik, Anubis, LAN-DNS (per-client DoH bypass, Porkbun
  zone ops), Swap Configuration, OpenWebUI, Hermes (pre-existing page
  preserved and extended).
- FrankBook-Pro: ARR-Stack filled (API split, schema-first probing,
  auth-posture, auth-config trap); qBittorrent E2E verification.
- Desktop/Agent/Research: Noctalia and Niri, OMP Harness, Agent Docs and
  Edits, Command Center Dotfiles, PICO-8 Asset Standards.
- Indexes: LLM Cross-Cutting References section, Operations/ and Agent/
  index pages, root-index Skill Distillation section, AGENTS.md pointer.
- log.md carries the wave plan.

check-vault-links.py 0 unresolved (185 pages); ./tests/run.sh 366/366.
This commit is contained in:
Frank committed 2026-10-05 21:19:15 -07:00
1 parent 8618ec6dbe
commit 33eae71d33
43 files changed
+5436 -2

No files matched your search

@@ -0,0 +1,128 @@
---
type: reference
title: Container Host Reachability
description: The two podman/netavark reachability quirks on the fleet — containers cannot dial unpublished host ports (LAN IP or loopback), and netns membership decides what "reachable" means. Probes that prove it, fixes, and the pasta port gate.
tags: [podman, netavark, networking, rootless, reachability, caddy]
timestamp: 2026-10-05T00:00:00-07:00
---
# Container Host Reachability
Two failure families on rootless podman + netavark hosts (Dankclaw), both
reading as "service down" when the service is up and the *path* is wrong.
Fleet source of truth: `serverAppConfigs-docs/Architecture/Container Network
Namespaces.md` (commit `2e179b0`).
## Quirk 1 — LAN-IP dialing of an unpublished host port refuses instantly
A container cannot reach a `Network=host` service at the host's LAN IP.
netavark DNATs **published** ports only: from a bridge container,
`192.168.0.143:<published>` answers while `192.168.0.143:<host bind>` and the
gateway `10.89.1.1` are both refused. This is what 502'd
`hermes.franksdank.cloud` until the Caddyfile upstream became
`host.containers.internal` on 2026-09-16 — its vhost comment had claimed the
LAN IP was open from that bridge; it never was.
Probe that proves it in one command:
```bash
ssh <host> "podman run --rm --network <net> --entrypoint sh \
docker.io/library/busybox:latest -c \
'for p in <target-port> <published-port>; do
nc -w2 -z -v <host-lan-ip> \$p >/dev/null 2>&1 && echo port-\$p-OK || echo port-\$p-BAD
done'"
podman exec <svc> bash -c 'exec 3<>/dev/tcp/host.containers.internal/<port> && echo ALIAS-OK'
```
Published OK + target BAD ⇒ quirk confirmed. Note busybox `nc -z` reports any
failure as failure — use it for bulk sweeps; use `/dev/tcp` (or `nc -v`) when
refusal-vs-timeout matters.
Fix: point the service's host-facing URL at
`http://host.containers.internal:<port>` in compose/quadlet env, redeploy via
git (`deploy.sh <target> --service <svc>`), verify
`podman exec <svc> printenv <URL_VAR>` and the connect log line (~40 s).
**Do not publish the daemon port** to make the LAN IP work — publishing is
exactly what makes other ports refuse.
## Quirk 2 — addressability is per netns, not per host
- `NetworkMode=host` → the container **is** the host netns: dials all of
`127.0.0.1`, binds any free port.
- bridge/named net → own netns; only published ports are reachable, via the
network's **gateway IP**. Unpublished host listeners refuse **instantly
(RST)** — no firewall rule, no stale DNAT, `nft`/`iptables -t nat` empty. Do
not hunt for REJECT rules; probe.
Probe sequence (read-only):
```bash
podman inspect <name> --format '{{.HostConfig.NetworkMode}}'
sudo ss -ltnp 'sport = :<port>' # host netns only — why 0.0.0.0 can still be unreachable
podman exec <name> cat /proc/net/route # default route = the gateway IP to dial
podman exec <name> sh -c 'timeout 2 bash -c "exec 3<>/dev/tcp/<gw-ip>/<port>"; echo rc=$?'
```
Interpretation without blurring:
- **instant refusal** = nothing listening in a reachable namespace (or
rootlessport rejected an unpublished port map).
- **timeout** = no route to that address at all. `host.containers.internal`
(`169.254.1.2`) is an alias on the **default `podman` net only**; containers
on a named network time out against it.
Tool gotchas (podman 5.x, verified live):
- `podman network inspect <net> --format '{{json .containers}}'` errors
(`can't evaluate field containers in type interface {}`) — list membership
from the container side: `podman ps -a --format '{{.Names}} {{.Networks}}'`.
- An **empty** `Networks` field = host network or shared namespace
(`--network=container:<other>`), not "no network".
- Quadlet `Network=<name>.network` yields the runtime name **`systemd-<name>`**
— probe with `podman network ls`, never the `.network` filename.
Fix options when a bridge-net container must reach a host-netns listener:
(a) move it to `Network=host`, or (b) publish the port — (b) makes the port
reachable from every namespace **and the LAN**, an exposure change needing
explicit operator approval, never a silent fix.
## Related trap — the pasta port gate (rootless published ports)
On hosts publishing ports through pasta, the port is bound in **userspace at
container CREATE**, not when the app binds. A predecessor stopped moments
earlier can still hold listener/TIME_WAIT state, so the replacement dies
`pasta: ... bind: Address already in use` (conmon exit 126) — then **self-heals
seconds later** under `Restart=on-failure`. The failure looks like it failed,
then looks fine, and the missing gate stays invisible because the retry masks
it. Observed 2026-09-16 cutting `frank-large` from a root unit to a rootless
Quadlet.
Gate procedure: stop the old unit AND the new one (a redeploy's running
replacement is itself the port-holder — without that stop the gate deadlocks
against itself), then wait for the port to be empty across **all socket
states**:
```bash
wait_port_free() {
local port="$1" limit="${2:-90}" i
for ((i = 1; i <= limit; i++)); do
if [ -z "$(ss -Htan "sport = :$port" 2>/dev/null)" ]; then return 0; fi
sleep 1
done
echo "FAIL: port $port still held after ${limit}s:" >&2
ss -tan "sport = :$port" >&2 || true
return 1
}
```
`sport` catches accepted-connection server sockets and TIME_WAIT residue that
`ss -ltn` misses; fail loudly with the socket table attached. Verify the gate
by re-running the same deploy against a running service (idempotency) and
assert it in seam tests (`wait_port_free` present in the deploy handler).
## Related
- [[Quadlet Lifecycle]] — the unit layer these containers run under
- [[Container Network Namespaces]] — fleet network namespace source of truth
- [[Observability]] — the alerting paths that depend on reachability
- [[Deploy State Triage]] — the deploy flow that installs these configs
@@ -0,0 +1,331 @@
---
type: reference
title: Fleet Audit and Posture
description: Three standing audits — monitoring completeness (what should exist vs what is merely down), vhost proxy health decoding, and the behavioral security posture audit; plus the stash supersession check before dropping old work.
tags: [audit, security, prometheus, caddy, monitoring, git]
timestamp: 2026-10-05T00:00:00-07:00
---
# Fleet Audit and Posture
## Monitoring gap audit
Use after any reboot, or whenever Prometheus targets look wrong. Two failure
modes, only the first announces itself.
**1. A service that should exist and doesn't is invisible.** A missing
exporter shows up as nothing — no failing target if the job was configured
for a host that no longer serves, no alert because the series is absent. So
check **existence, not health**:
```bash
podman ps -a --format '{{.Names}} {{.Status}}' # includes dead ones
ls ~/.config/containers/systemd/*.container # what should be boot-persistent
systemctl --user is-enabled <name>.service # expect: generated
```
A container in `ps -a` that is `Exited(…)` **and has no unit** is the classic
case: rootless podman does not restart bare containers after the stop that
shutdown performs, so a compose-only service dies at the first reboot and
stays dead. If the compose file still names it, the repo's own rule is being
violated — fix by writing the quadlet (mirror the same service on the other
host), deleting the compose entry (one owner per container name), and making
the deploy path reach it: planner service-set + path rule, dispatcher
allowlist `<target>:<service>`, handler installing from `"$TARGET/..."` never
a hardcoded peer's path.
**2. Classify every down target — do not report a count.**
`targets?state=active` gives health **and** last error; two down targets can
mean opposite things:
| Symptom | Meaning |
|---|---|
| `connection refused` | service not listening → real outage (podman ps, logs, exit codes) |
| `404` on `/metrics` | exporter path wrong or metrics disabled → config defect, service fine |
| `unsupported Content-Type …` | scraping an HTML *page* as metrics → probe inert; Prometheus 3 refuses HTML; a real probe needs a blackbox exporter |
| target absent | §1 |
```bash
curl -s "http://127.0.0.1:9090/api/v1/targets?state=active" | python3 -c "
import json,sys
for t in json.load(sys.stdin)['data']['activeTargets']:
if t['health'] != 'up':
print(t['labels']['job'], t['scrapeUrl'], (t['lastError'] or '')[:80])"
curl -s -o /dev/null -w '%{http_code} %{content_type}\n' http://<target>/metrics # hand-probe before believing the scraper
```
"7 of 11 up" without the four reasons is how a real outage hid behind three
config defects on this fleet — the crash-looping GPU service was one of four
failures reported as a number.
**3. Exposure posture while you are in there.** An exporter bound to the
wildcard publishes an unauthenticated API on every interface. Expect exactly
two listeners per host-exporter — loopback for host-local probes, the host's
own LAN address for the Prometheus container: `ss -ltn | grep <port>` must
show two explicit addresses, never `0.0.0.0`.
**4. Leave decisions that are not yours.** Fix mechanical drift (missing
unit, wrong path, wildcard bind). Do NOT disable a probe to green a
dashboard, or "fix" a crash-looping service whose posture depends on owner
intent — capture the evidence (restart count, exit code, last error line) and
hand it over intact.
## vhost proxy health probes
After deploying or auditing a Caddy/Anubis front door, verify **from a
client**, not from the backend's localhost — that is where transport
mismatches, auth walls, and dead upstreams show up.
```
curl -s -o /dev/null -m 6 -w "%{http_code}\n" https://<vhost>/ping
curl -s -o /dev/null -m 6 -w "%{http_code}\n" https://<vhost>/
```
A status code is a diagnosis, not a failure:
- `200` on `/ping` from *arrs = alive; **auth state unknown** (`/ping`
bypasses auth by design) — probe `/` to decide.
- `404` = backend **alive**, route missing (qBittorrent has no `/ping`; 404
there = healthy).
- `502` = Caddy could not reach/negotiate with the upstream: dead backend
**or transport mismatch**. Probe the backend port directly from the proxy's
network (`ssh <proxy-host> curl http://<backend>:<port>/…`). Direct 200 +
vhost 502 ⇒ config bug, not an outage.
- Login page to anonymous (grep body for `password`) = auth ON; instant
redirect to `/transferlist/`-style routes = auth OFF = open door.
**Transport-mismatch mechanism:** `transport http {versions h2c 1.1}` makes
Caddy send an h2c upgrade preface; .NET/Kestrel backends (Jellyfin) reject it
→ 502 per request while the same port answers 200 on plain HTTP/1.1. Fix =
drop the override (default HTTP/1.1). Opposite twin: Plex PMS drops plain
HTTP off-loopback and needs a TLS transport. Match the transport to what the
backend speaks.
**Shell trap when gating commits on a test suite:** `grep -c "^not ok"` exits
**1 when zero mismatches** — inside an `&&` chain it silently kills
everything after it while still printing `0`. Wrap:
`(./tests/run.sh | grep -cE '^not ok' || true) && git commit …`.
## Security posture audit (behavioral)
Answers "what can the internet touch, and is everything else actually
gated?" — **all findings must be behavioral probes**, not config reads.
**1. Inventory (source of truth).** `dankclaw/Caddyfile`: classify every
vhost — guarded (`reverse_proxy anubis:8923`), exempt (direct upstream), or
redirect-only. Public DNS: `dig +short +time=3 +tries=1 <name>.franksdank.cloud
@1.1.1.1` per name — a record at the WAN IP = public; NXDOMAIN = LAN-only by
DNS absence.
**2. Guard chain (expect the challenge, not the app).** Per guarded vhost:
`curl -sk https://<vhost>/` and grep the body for
`anubis|challenge|proof-of-work` (~8 markers on a live challenge page). A 200
with NO markers means the app is served directly — the guard is bypassed.
**3. Exempt vhosts — per-app gating probes.** OpenWebUI `/api/v1/users/me` →
401. Gitea `/api/v1/user` → 401; repo visibility `/api/v1/repos/dank/<repo>`
→ 404 = private. Pi-hole any path → 401 (Caddy basicauth edge). Plex `/web/`
→ 302 (login is JS-rendered). Calibre-Web `/` → 302 to `/login`. Jellyfin
`/Users` → 401. Prowlarr/Sonarr/Radarr `/api/v3/system/status`; Lidarr/Readarr
`/api/v1/system/status` → 401 = gated, 200 = fully open. qBittorrent
`/api/v2/torrents/info` → 403 without SID. Hermes/hermesadmin `/` → 302 to
`/login`. IdP (`auth.`): `/` → 302 → `/if/flow/default-authentication-flow/`
→ 200 (flow HTML is React; grep "password" finds nothing — the 200 on the
flow URL is the proof).
**4. WAN port scan — from an EXTERNAL vantage.** LAN→WAN hairpin is
unreliable; scan from gluetun's ProtonVPN egress:
```bash
ssh dankclaw 'podman exec gluetun sh -c "for p in 21 22 53 80 443 3000 3005 8080 \
8081 8082 8083 8085 8096 8787 8923 8989 9000 9090 9119 9696 7878 8686 3100 \
5055 32400 7770 7771 2019; do timeout 2 wget -qO- http://71.227.227.172:\$p/ \
>/dev/null 2>&1 && echo OPEN: \$p; done"'
```
Confirm the egress is truly external first (`wget -qO- https://api.ipify.org`
→ ProtonVPN IP, not Comcast). **Trap: plain-HTTP probes against TLS ports
(443, 32400) are false negatives** — port open, protocol mismatch; re-probe
with `wget -qO- https://IP[:port]/`. busybox wget has no `--secure` flag; it
errors instantly and the `&&` chain silently skips the echo (a sub-second
wall time on a "timeout" scan means the flag failed, not that ports closed).
**5. Residuals.** Grafana basic-auth residual: `curl -u admin:admin
http://127.0.0.1:3002/api/org` → 200 means `admin:admin` still live
(loopback-only; the vhost path needs Anubis + OAuth2 first). LLM binds:
`sudo ss -tln | grep -E ':(7770|7771)'` — `0.0.0.0` = LAN-reachable with no
auth (OpenAI API, no token). Stale-record check: current WAN IP =
`ssh frankbook-pro 'curl -s https://api.ipify.org'` (residential egress);
compare against every public A record — a record at a different Comcast IP
is stale.
**Baseline (2026-09-21):** WAN: only 80 + 443 open. Public names: `www`,
`search` (guarded) + `ai`, `gitea`, `plex`, `books`, `jelly` (own auth, all
verified) + `auth` (IdP, by design); everything else LAN-only. Open findings:
LLM endpoints 0.0.0.0 auth-less on the LAN; Grafana `admin:admin` loopback
residual; `plex.franksdank.casa` stale (73.59.75.210 = previous Comcast IP);
`plex.franksdank.cloud` WAN-broken (Plex Web 302s to LAN origin
192.168.0.235:32400); vestigial *arr handlers on the :8017 post-auth listener
(dead config, no exposure).
## Stash supersession audit before drop
Use when a repo (especially a live-deployment checkout where the working tree
IS the deployment) has an old stash whose content may have been superseded by
later commits, and dropping blindly risks losing unique facts.
1. **Inventory**: `git stash show --stat stash@{0}`.
2. **Probe for unique content per file**: `git show "stash@{0}:<path>"` and
diff against HEAD; count lines present only in the stash version. A file
with zero unique-vs-HEAD lines is fully superseded — safe to drop.
3. **Classify each unique-content hit**: *stale fact* (superseded by a later
refactor, e.g. old model naming) → drop is correct; *live durable fact*
(an operational invariant never re-recorded) → extract it into live
docs/memory or flag to the user before dropping.
4. **Archive before dropping** — zero loss: tag the stash commit, then drop:
```bash
git tag archive/stash-<YYYY-MM-DD>-<base-branch>-wip "stash@{0}"
git stash drop "stash@{0}"
```
The tag pins the full stash object including its index parent; recoverable
via `git show archive/stash-…` or `git checkout archive/stash-…^3 -- <path>`
for the untracked-files third parent. Stashes are invisible in normal history
and expire from reflogs (~90 days); tagging converts "drop" into a
reversible archive at zero workflow cost.
## Whole-fleet audit (git/test/live/docs cross-check)
Deep-audit the homelab fleet — git/test state, live service health, and
docs-vs-live cross-check. Run from the dev checkout
(`/home/blank/Projects/serverAppConfig`).
**1. Probe the live fleet — self-identifying, ONE ssh per host.** Do NOT
trust the display order of parallel SSH outputs: they can render crossed and
you will attribute host A's output to host B (2026-09-21: a false host↔IP
"conflict" dissolved by a single self-contained per-host probe). Put
`hostname` + a distinctive marker + the LLM identity in the SAME command:
```
for ip in 192.168.0.143 192.168.0.235; do
ssh -o BatchMode=yes dank@$ip \
'echo "host=$(hostname)"; \
echo "romm=$(podman ps -q --filter name=^romm$|wc -l) caddy=$(podman ps -q --filter name=^caddy$)"; \
echo -n "llm7770="; curl -s -m3 http://127.0.0.1:7770/v1/models|head -c60; echo'
done
```
Ground truth (verify, don't assume — IPs can move): 192.168.0.143 = dankclaw
= AI stack + `frank-large` :7770 + `frank-small` :7771 (RTX 3090 + Strix
Halo iGPU); 192.168.0.235 = dankserver = media stack + `frank-primary`
:7770 (2× R9700). Repo dirs + live hostnames agree; the deploy tooling is
hostname-anchored (`ssh dankclaw`/`ssh dankserver` via `~/.ssh/config`), so
it is unaffected by IP drift.
**2. LLM endpoints — health + identity.** `curl /health` per endpoint, then
`/v1/models` (first 120 chars) — a 200 with the right model `id`/`name` is
the pass condition.
**3. Tests — the deploy completeness gate.** `./tests/run.sh` (bats) must
end `ALL TESTS PASSED`; `tests/e2e/run.sh` runs Playwright against the LIVE
fleet. **A single-test flake is normal**, not a regression: the plex crawl
intermittently lands on `app.plex.tv`, the grafana SSO test occasionally
times out, and the FrankBook-Pro leg fails when the mini sleeps. Re-run just
that spec (`npx playwright test --project=standard specs/<n>-<name>.spec.ts`)
to classify before alarming. A `deploy.sh` e2e gate can fail on a flake
while the change itself is fine — verify the actual change live, then re-run
the suite.
**4. Repo state.** `git status --short --branch; git log --oneline -20;
git stash list; git worktree list` (prune stale worktrees), and
`du -sh .AGENTTRASH` for staged deletions awaiting purge approval. This is a
**shared checkout with parallel instances**: uncommitted peer WIP and
stash-list churn are expected. Never commit or drop a peer's files. To
deploy with a dirty tree: `git stash push -u -m "..." -- <peer paths>`,
deploy, `git stash pop`. `deploy.sh` refuses a dirty tree.
**5. Prometheus — expected-down targets (not outages).** Query
`/api/v1?query=up` from inside the container; the structurally red-by-
construction jobs (`comfyui` HTML scrape, `services` up/down-as-scrape,
`gitea` exporter off) are documented in [[Observability]] — anything else
down, especially a `node-*` or LLM job, is a real gap. The recurring trap: a
same-host `net=host` bind (node-exporter :9100, llama-server :7770/:7771) is
a *host bind*, not a published port, so the bridge-networked Prometheus
container cannot reach it at the host's LAN IP — netavark DNATs published
ports only. Scrape same-host services via
`host.containers.internal:<port>`; verify the fix from inside the container
before editing: `podman exec prometheus wget -qO-
http://host.containers.internal:<port>/metrics`.
**6. Cross-check docs vs live.** Confirm the AGENTS.md host↔IP↔hardware↔
stack mapping against step 1. The LLM endpoint table and `models.yml` are
probed live and stay current; the prose host labels are the part that goes
stale after an IP move. Report discrepancies; do not "fix" prose from a
single crossed probe.
## Bulk-redact an identity from a docs + config tree
Removing a retired host / machine / project name from a repo that holds both
documentation and live config. Evidence-first, surgical, reversible.
**1. Inventory before touching anything.** Count hits **per file** (the
count decides whether this is a 5-minute job or a user-input decision),
excluding logs/`*.db`/runtime state, and capture `git status --short` first
— you must know which files carry in-flight peer WIP so you never stage
them.
**2. Classify every hit:**
| Bucket | Test | Action |
|---|---|---|
| **Narrative** | prose describing the thing | Reword to the class ("the workstation", "a client box") |
| **Load-bearing** | the identifier IS a lookup key, hostname match, or config value | **Keep.** Prove it: read the code that consumes it |
| **Evidence** | quoted logs, transcripts, reports | Keep timestamps/lines; de-identify only the host field |
Load-bearing examples that look like prose but are not: `install.sh` doing
`PKGS=("common.pkgs" "$HOST.pkgs")` with `HOST="$(hostname)"` (the manifest
*filename* is the key); a `--host <name>` case list; a Wi-Fi SSID. Hardware
model strings, driver versions, PCI addresses are subject matter, not
identity. State the boundary in the commit message.
**3. Subdirectories: distill, don't rename or keep.** For a per-host doc
tree (`docs/system/<host>/`), ask before acting — rename/trash/distill are
genuinely different. When distilling: read every page, sort into
still-true-and-machine-independent (fold into a general page) vs
specific-to-the-retired-box (let go), then `mv` the directory to
`.AGENTTRASH/YYYYMMDD-topic/` and repoint every index. Wikilinks resolve by
filename anywhere in the tree, so deleting a page can break a link in a
file you never grepped — run the link checker.
**4. Execute with count assertions, never blind sed.** Per-file mapping of
`(path, old_exact_string, new_string, expected_count)`; abort the entry if
the count differs; exact strings, not regex. **Do not hand this to a
subagent** — observed: dropped list markers so bullets stopped rendering, a
numbered list with two `3.` items, "corrected" paths to nonexistent
directories, a deleted sentence where a reword was required. If a subagent
already did it: `git checkout --` its touched paths (excluding the WIP
files from step 1) and redo it.
**5. Audit:** `grep -rniE '<name>|<its-ip>' <scope>` (expect nothing), then
`git diff -U0 -- <scope> | grep -E '^\+[^+]'` and **read every added line**
— the diff is the step that catches damage; the grep only proves a string
is gone. Then the repo's own linter.
**6. Commit scoped** — only your own paths, never `git add -A`. If the
checkout IS the live deployment, committing on the current branch is usually
correct; a branch switch can swap live config out from under a running
process. Push/PR only when asked.
**7. Report the residue honestly** — what still carries the name and why:
excluded scopes, load-bearing config, git history, and the cost of rewriting
history (force-push + re-clones). "Purged from the working tree" is a true
claim; "purged" alone is not.
## Related
- [[Observability]] — the monitored stack
- [[Stale Signal Audit]] — the down-target decision table
- [[Observability Provisioning]] — adding scrape targets and rules
- [[Homepage Status Probes]] — the status page that renders probe states
- [[Deploy State Triage]] — the deploy plumbing the audits exercise
@@ -0,0 +1,272 @@
---
type: reference
title: Fleet Ops Playbook
description: Incident-shaped fleet procedures — LLM endpoint down with an abandoned GPU holder, scratch vLLM boot windows (stop-first law), iterating on a fleet service before commit, incident-window reconstruction, and waking a sleeping host.
tags: [operations, incidents, gpu, systemd, wol, forensics]
timestamp: 2026-10-05T00:00:00-07:00
---
# Fleet Ops Playbook
## Endpoint down, connection refused — GPU holder reclaim
Use when `:7770`/`:7771` gives **connection refused** (not a timeout) and the
service was not crashed. Applies to `frank-medium` (dankserver, root unit),
`frank-small` (dankclaw, root unit), `frank-large` (dankclaw, rootless
Quadlet).
**1. Classify the stop before touching anything**
```bash
ssh dankserver "systemctl is-enabled frank-medium; systemctl is-active frank-medium; \
systemctl show frank-medium -p LoadState,UnitFileState,ActiveState,SubState,NRestarts"
ls -l /etc/systemd/system/<svc>.service # symlink to repo = unmasked; -> /dev/null = masked
```
`inactive` + `enabled` + unmasked + `NRestarts=0` → **intentional stop**,
never restored. No crash; do not look for segfaults. `NRestarts>0` /
`activating` → different path; read the journal first.
**2. Are the GPUs actually free?** A check, never an assumption:
```bash
rocm-smi --showmeminfo vram | grep Used # idle baseline ~60 MiB/card
ps -eo pid,comm,etime,args | grep -Ei 'llama|bench' | grep -v grep
```
Cards idle → `systemctl start <svc>` and verify (step 5). Cards busy with no
prod unit → get the holder, and compare its bytes against the service's
requirement before concluding a start would even fit (frank-medium 35:30
needs ~30.8 GiB ROCm0 / ~28.9 GiB ROCm1; a stray holding 15+17 GiB blocks the
restart on allocation, not config).
**3. Identify the holder and prove it is abandoned**
```bash
ps -o pid,ppid,user,lstart,etime,comm -p <pid>
sudo readlink /proc/<pid>/cwd /proc/<pid>/exe
tr '\0' ' ' < /proc/<pid>/cmdline # port, ctx, split
sudo ls -l /proc/<pid>/fd # where stdout/stderr go
sudo ss -tnp | grep <port>
awk '{print $14+$15}' /proc/<pid>/stat; sleep 10; awk '{print $14+$15}' /proc/<pid>/stat
```
Abandoned **only if all** hold: utime+stime **identical** across the 10 s
sample (frozen CPU, not just low); **zero** established connections; bind
scope `127.0.0.1` (loopback scratch, invisible to endpoint monitors); log
file (fd 1/2 target) last written long ago, last lines showing success not a
hang. Active CPU or live connections → someone's running session. Stop and
ask.
**4. Reclaim**
```bash
cp -a <logfile> <logfile>.preserved-YYYYMMDD # evidence outlives the kill
kill <pid> # SIGTERM first; exits <1 s
rocm-smi --showmeminfo vram | grep Used # expect ~60 MiB baseline
```
Never `pkill -f` a pattern present in your own command line.
**5. Restore and verify — health 200 alone is NOT proof**
```bash
sudo systemctl start <svc>
for i in $(seq 1 60); do curl -s -m 5 -o /dev/null -w '%{http_code}\n' \
http://127.0.0.1:<port>/health; sleep 10; done # 503 while loading -> 200
sudo tr '\0' ' ' < /proc/<MainPID>/cmdline | grep -oE '\-\-(tensor-split|split-mode|ctx-size|port|spec-type|parallel|flash-attn)'
```
Prove with a **real completion** from off-host — content, reasoning, spec
counters:
```bash
curl -sS -m 180 http://<host>:<port>/v1/chat/completions -H 'Content-Type: application/json' \
-d '{"model":"<alias>","messages":[{"role":"user","content":"Reply with exactly: OK"}],"max_tokens":64,"temperature":0}'
# choices[0].message.content / .reasoning_content / timings.draft_n, draft_n_accepted
```
Record the runtime fingerprint (`<bin> --version` → `b<build>, commit <sha>`)
and confirm it is the pinned runtime. Append a dated entry to
`serverAppConfigs-docs/log.md`, run `./tests/run.sh`, commit, push (origin is
the local Gitea on Dankclaw — push allowed).
**Gotchas seen here:** dankserver's login shell is ZSH — never start an
unquoted word with `=` in an ssh one-liner (`echo ===` aborts the chain). A
freshly restarted endpoint under live agent-queue backlog may return empty
bodies/timeouts for minutes while `/health` is 200 — re-probe with a long
timeout. First request after a halogen (`frank-large`) restart can go silent
45–60 s and self-recover; do not restart over it. Rootless `frank-large`:
read logs **without** sudo (per-user storage). If the restore never happened,
the sudo journal names who and when: `journalctl -b | grep sudo`.
## Scratch vLLM boot on a fleet GPU host — stop-first law
Any scratch/experimental vLLM container on a host that also runs a production
LLM service (`frank-primary`, `frank-large`, `frank-small`) MUST stop the
production unit first, and the window script must verify it. Two independent
failure modes if skipped:
1. **Expert-offload planner dies on host RAM.**
`vllm/model_executor/offloader/expert_plan.py` reads `MemAvailable` at
plan time. A production rank worker holds tens of GiB of unevictable
pinned expert rows (frank-primary: ~76 GiB × 2 ranks), so a beside-prod
boot sees ~86 GiB instead of ~239 and dies at plan time:
`ExpertPlanError: host holds 5113 of 12096 swappable experts per rank`.
Looks like a planner bug; is pure RAM contention. Signature: container
exits within ~60 s, after `Resolved architecture: …` but before worker
init; `free -g` showing `available ≈ 86` while prod runs is the smoking
gun.
2. **GPU ownership.** Two vLLM containers cannot share the GPUs regardless
(the fleet's benchmarking law). Scratch ports (`:7771`/`:7773`) are fine
only once the prod unit is down.
Window skeleton (dankserver):
```bash
sudo systemctl stop frank-primary.service
sudo systemctl mask --runtime frank-primary.service # relinked unit: plain mask refuses; runtime mask self-clears at reboot
sleep 3
podman ps --format "{{.Names}}" | grep -q frank-primary-glm && { echo "prod still running - abort"; exit 1; }
# ... launch scratch container, watch logs ...
podman rm -f <scratch> # stuck vLLM ignores SIGTERM
sudo systemctl unmask --runtime frank-primary.service
sudo systemctl reset-failed frank-primary.service
sudo systemctl start frank-primary.service
# restore oracle is a CONJUNCTION: health 200 AND is-active active AND is-enabled enabled AND no /run/systemd/system mask symlink
```
The deploy restores the unit after bench windows, so stop is sufficient
there; `mask --runtime` is for scratch boots that must survive a stray
restart. Always end a window by re-checking the conjunction before
reporting — if a restore after failure doesn't happen automatically, prod
stays DOWN. See the repo's benchmarking policy (AGENTS.md) and
[[LLM Bench Windows and Measurement]].
## Iterating on a fleet service before commit
The repo's contract is git → `deploy.sh` → host, but live testing needs the
new file on the host first, and the naive way (copy into the host checkout)
blocks the very deploy that would install it. The loop:
1. Edit locally; run `./tests/run.sh`.
2. Ship working files **for testing**:
`cat local/file | ssh <host> 'cat > /home/dank/serverAppConfigs/<path>'`
(then `chmod +x` if executed — `cat >`/scp drop the exec bit and a
container entrypoint fails status 126, "found but not executable").
3. Test against the live service; iterate.
4. **Before committing**, prove the host copy is byte-identical to what you
are about to commit (`sha256sum` both ends) — otherwise step 6 loses work.
5. Commit and push.
6. Clean the host checkout so the deploy can pull: untracked hand copies →
`mv` to `/tmp/handcopy/`; modified tracked files → `git checkout -- <paths>`
(safe only because of step 4); confirm `git status --short` prints nothing.
7. `./scripts/deploy.sh <target> --service <svc>`.
Traps, in the order they bite: the **pull aborts on hand copies** ("untracked
working tree files would be overwritten by merge" — exactly the file you
shipped); `git checkout --` silently eats uncommitted work without step 4; a
runtime may **mount files straight out of the checkout** (`dankserver/frank-medium/**`,
`shared/grafana/provisioning/**`) so a hand copy takes effect at next
container restart with no deploy; root-owned files from a container write
need `chown` back before git operations; and **a restart is not a
verification** — verify the *specific* claim (new code path's own output,
file checksum on the host, the API serving the resource).
Why not keep a scratch copy on the host? The deployed artifact must be the
committed one. Probes may live anywhere; runtime state lives in git.
## Incident window reconstruction
Use when the question is **"what was running when it broke?"** and the answer
must come from artifacts, not recollection. Three independent clocks,
cross-checked:
1. **Per-run duration** — scripts wrap commands in `time` → `real 4m19.129s`.
2. **Log mtime** — last write of the log.
3. **Journal first-failure instant** — first error line from the affected
service.
`start ≈ mtime − real` for the last run in a log; each run nests inside the
previous one's window; the run whose `[start, end]` contains the journal's
first-failure instant is the answer.
```bash
ssh <host> 'ls -l --time-style=+%m-%d_%H:%M:%S <logdir>/*.log | awk "{print \$6, \$7}"'
ssh <host> 'grep -ah "^real" <logdir>/*.log'
ssh <host> 'grep -aE "^####|^\+ /path/to/binary" <logdir>/<name>.log | sed "s/\(.\{130\}\)/\1\n /g"'
ssh <host> 'journalctl --user --since "<date> <HH:MM>" | grep -a "invalid internal status" | head -2'
```
Traps (all hit for real): `/tmp` does not survive a reboot — launch
experiment scripts from a persistent path (`/home/dank/models/logs/<tag>.log`
survived while `/tmp/x4*.sh` died); a log's mtime is not proof a run finished
(truncation with the fd still held keeps growing); long command lines are
display-truncated — wrap before reading or you report parameters never used;
off-by-one filenames (`x4b.log` vs `x4b-experiment.log`) silently skip
evidence — list the directory and match exactly; **overlap is a hypothesis,
not an observation** unless a sampler proves it; do not invent the cause —
reconstruct the *window* and keep the *mechanism* separate (`journalctl -k |
grep -i oom`); publish "trigger not established" when the proof is absent.
Fleet-specific: container healthchecks shell out to the **podman CLI**, so
the first `invalid internal status … could not find any running process` in
the user journal is an early, per-minute indicator of rootless-podman state
death — it timestamps the incident to within one healthcheck interval (how
the 18:58:30 Dankclaw break was pinned to a bench still loading).
Worked example (2026-09-16, Dankclaw): log mtimes x4e-run 18:58:29 /
x4f-run 18:59:56; durations E1 `real 4m53.946s`, F `real 5m24.785s` (exit 143
= killed); journal first failure 18:58:30 ⇒ **F** was in flight (launched
18:54:31, inside its window).
## Wake-on-LAN for a sleeping fleet host
A fleet host is asleep (100% ping loss; `ip neigh show <ip>` shows STALE or
FAILED) and you need it up — typically the FrankBook-Pro Mac mini
(192.168.0.112, **MAC `de:6b:6d:c1:52:23`**, sleeps and breaks the e2e
`11-admin` spec).
- **Find the MAC**: `ip neigh show <ip>` — even a STALE entry carries it;
fallback Pi-hole/DHCP leases or fleet docs.
- **Send the packet**: `6×0xFF + 16×MAC` = 102 bytes, UDP to port **9** (and
7), broadcast. The dev workstation has **two interfaces on 192.168.0.0/24**
(Wi-Fi + wired USB-Ethernet, default route metric 100) — a plain
`sendto("255.255.255.255", 9)` leaves only the default-route interface.
Bind explicitly to each interface's local IP and target the subnet
broadcast `192.168.0.255` as well as `255.255.255.255`; burst ~15 packets
per interface ~0.2 s apart. `wakeonlan` is not installed; use a raw UDP
socket with `SO_BROADCAST=1`:
```python
import socket, time
MAC = "de:6b:6d:c1:52:23"
mac = bytes(int(x,16) for x in MAC.split(":"))
pkt = b"\xff"*6 + mac*16 # 102 bytes
for local, dest in [("192.168.0.83","192.168.0.255"),("192.168.0.104","192.168.0.255"),
("192.168.0.83","255.255.255.255"),("192.168.0.104","255.255.255.255")]:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
s.bind((local, 0))
for _ in range(15):
s.sendto(pkt,(dest,9)); s.sendto(pkt,(dest,7)); time.sleep(0.2)
s.close()
```
- **Poll for the wake**: a Mac mini takes **30–90 s**; `ping -c 1 -W 1` every
5 s for ~2–3 min before concluding failure.
- **If it does not wake** (the common case): macOS honors WoL only when BOTH
are enabled on the mini — **System Settings → Battery → Options → "Wake
for network access"** = ON, and the active port's **"Enable Wake for LAN"**
(Network → [interface] → Details). If either is off, no packet from any
source wakes it, and you cannot change it while asleep. A full power-off
(smart plug cycled) ignores WoL entirely. Ask the user to toggle the
setting, then re-fire.
## Related
- [[LLM Bench Windows and Measurement]] — the bench windows this playbook arms
- [[Remote Job Runner]] — supervising the long jobs that create these windows
- [[Container Host Reachability]] — the pasta port gate at container create
- [[Quadlet Lifecycle]] — the unit layer behind stop/mask/start
- [[Model Swap and Rollback]] — swap-time incidents and rollbacks
@@ -0,0 +1,204 @@
---
type: reference
title: Payload and Disk Reclaim
description: Standing procedure for disk pressure on a fleet host — measure all buckets first, classify against the runbook index, prove nothing live references the payload, stage to trash with fingerprints, purge only on approval; plus the purge-and-refresh protocol with fetch verification.
tags: [operations, disk, podman, payloads, huggingface, trash]
timestamp: 2026-10-05T00:00:00-07:00
---
# Payload and Disk Reclaim
Standing procedure for "clean up old models" / "disk is full" on a
serverAppConfigs host. Order matters: **measure, prove nothing live uses it,
stage, ask.** Staging frees nothing until the user approves the purge — say so
up front or the request reads as unfulfilled.
## 1. Measure the whole filesystem, not the category asked about
```sh
du -sh /home/dank/models /home/dank/.local /home/dank/.cache \
/home/dank/comfyui-data /home/dank/GitPackages 2>/dev/null
df -h /home/dank | tail -1
podman system df # reclaimable image space
du -sh ~/.local/share/containers # where rootless storage physically is
```
The answer is usually NOT the category the user named. On Dankclaw
(2026-09-28) retired models were 510G, but podman images were **279G
reclaimable (95%)** and uv+pip caches **73G** — 352G of regenerable garbage,
bigger than the models. Lead with that finding; it changes the decision.
Check for sparse files before claiming a saving:
```sh
stat -c 'apparent=%s blocks=%b' <file>; du -sh --apparent-size <file>; du -sh <file>
```
A 3.3G "log" (`logs/x4e-run.log`) was **4K real** (8 blocks × 512) — deleting
it frees nothing. `du` vs `du --apparent-size` finds these instantly.
## 2. Classify against the runbook index
Every payload directory maps to a runtime in the AGENTS.md retired-runbook
table. Three buckets:
- **live** — referenced by a running service's env/unit.
- **rollback** — named as a rollback target in a runbook. ASK before
touching: they are the difference between a one-minute flip and a 30G
re-download.
- **retired** — maps to a retired runtime. Safe candidates.
Trap: a directory can hold **both** a dead target and a live drafter
(frank-large's DFlash2 drafter lives beside the previous target). Move the
single file, never the directory, and say so in the manifest.
## 3. The live-reference check — before moving anything
```sh
for d in <candidate dirs>; do
grep -rl "$d" --include="*.sh" --include="*.env" --include="*.service" \
--include="*.container" --include="*.yaml" . | grep -v '^./old/'
done
```
Then **read every hit**; do not count them. Most are benign (comments,
retired fetch-script destinations, `retire_*_quadlet` helpers that move a
quadlet not a model) — but on Dankclaw this check found a live bug:
`serve-frank-small.sh`'s `: "${MODEL_DIR:=...}"` fallback still pointed at a
payload retired a week earlier. The env file always overrode it, so it was
invisible; a missing env file would have produced a "missing asset" naming a
model that no longer exists.
**Durable lesson:** `:`-defaults in a serve script are never exercised in
production, so they rot silently. When reclaiming a payload, grep serve
scripts too — the fallback is a live reference in all but name.
Also check service **env files** (`scripts/*.env`) for `MODEL_DIR`/`MODEL`
paths, not just unit `Volume=` lines: a payload can be mounted by path and
named by env. `models/logs` and small tool dirs are not cache — logs are
evidence, keep them.
## 4. Stage, never delete
```sh
STAGE=/home/dank/.AGENTTRASH/$(date +%Y%m%d)-<topic>
mkdir -p "$STAGE"
mv <path> "$STAGE"/
```
Same filesystem ⇒ instant and recoverable — **and reclaims no space**; say so
rather than implying space was freed. Write `MANIFEST.md` into the stage dir:
per item — path, size, owning runtime, when retired, the `mv` command to
restore. Record what you **deliberately kept** and why; that is what a future
reader needs. Always check for pre-existing staged trash
(`du -sh ~/.AGENTTRASH/*`): 118G from a 2026-09-21 cleanup sat un-purged;
report the running total, not just the new batch. Root-owned leftovers need
`sudo`, and check `swapon --show` first (a trashed swapfile must not be
active).
## 5. Verify, then report — and do not purge
```sh
systemctl is-active frank-large frank-small
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:7770/health
```
Report: staged items and sizes, what was kept and why, what is **still not
freed**, remaining candidates untouched. Purge only on explicit approval.
## Repo-flow gotcha: do NOT push before you deploy
`scripts/deploy.sh` sets `BASE_REVISION=$(git rev-parse origin/main)`. If you
pushed separately, the diff is empty and the planner reports "changed
services: (none)" — looks like a planner bug, is not one. The tool is
designed for the **unpushed** state: edit → test → `deploy.sh` (it pushes,
then dispatches). If you already pushed, pull on the host instead, and reason
about whether a restart is actually owed. Any serve-script/env change needs
`./tests/run.sh` before commit; a `:`-default change is usually **inert**
(env overrides it) and owes no restart — but say so explicitly.
# Purge and refresh protocol
When the task is clearing/reclaiming/**refreshing** model caches on a serving
host, add these steps.
## Fingerprint before deleting
`ls -l --block-size=1 <dir>`; for LLM payloads compare each file against the
Hub's declared size. Fingerprints are the only way to answer "did the refresh
change anything" afterwards — **a refresh is also an update check**: if
upstream never changed, the bytes come back identical, which is itself the
finding. Sidecars are versioned **in place**: equal main-checkpoint sizes do
not prove the repo is unchanged, and a stale sidecar is invisible from its
name (observed: `overlay.hgn` 2,477,677,120 → 2,572,466,560 while the main
checkpoint returned byte-identical). If a file is irreplaceable upstream
(delisted revision, gated repo), say so before deleting.
## Confirm re-fetchability first
Query the Hub anonymously; a gated repo 403s even with the host's token:
```bash
curl -s "https://huggingface.co/api/models/<repo>/tree/main?recursive=true&blobs=true" \
| python3 -c "import json,sys; d=json.load(sys.stdin); print(len(d),'entries'); [print(e.get('size'),e['path']) for e in d[:5]]"
```
An error/empty result means it is NOT safe to delete. Filter by **quant
name**, never `endswith('.gguf')` — mradermacher ships large quants as
`.partNofM` fragments that filter hides.
## Refresh per engine — know the recovery path BEFORE deleting
- **frank-large (halogen)**: the engine fetches its own payload via
`HALOGEN_DOWNLOAD` when the checkpoint is absent. Clear the directory but
leave it **existing and empty**, then start the unit. **Bind-mount trap:**
podman refuses a mount whose source is missing
(`Error: statfs /home/dank/models/halogen: no such file or directory`) and
the unit loops on `Restart=on-failure` — `mkdir -p` the empty dir. ~124 GiB
at 130–240 MB/s ≈ 10–20 min, then load.
- **llama.cpp services**: no self-fetch; the serve script refuses to start
without weights. Pull yourself and verify:
```bash
curl -fL -C - --retry 10 -o "$DEST/<file>" \
"https://huggingface.co/<repo>/resolve/main/<file>" # -f is mandatory
sha256sum "$DEST"/*; head -c 4 "$DEST"/*.gguf # compare to LFS oid + GGUF magic
```
`curl` exits 0 on HTTP 401/403 and writes the error body as the artifact — a
gated repo once produced three 153-byte shards with identical sha256s and a
cheerful DONE line for 104 GiB. Always `-fL -C -`, compare byte counts to the
Hub's declared sizes, compare sha256 to the LFS oid. Assemble `.partNofM`
fragments with `cat part1 part2 part3 > name.gguf` in numeric order (per-part
hashes verify the whole — concatenation is deterministic). Public repos need
no token: omit `-H "Authorization: Bearer …"` from argv entirely (it is
world-readable in `/proc/<pid>/cmdline`).
## Restore, then verify behaviour — not health alone
Put the quadlet back (`mv` from trash + `systemctl --user daemon-reload`),
`reset-failed`, `start` (root units need `sudo`). Watch the engine's own
fetch/load lines, then `/health`, `/v1/models`, and a **real completion** on
every restored endpoint. Read the engine's memory ledger (kv pool / weights /
blocks left) rather than assuming the config still fits. Sweep the vhosts too
(`www`, `grafana`, `prometheus`, `hermes`, `marreta`, `gitea`, `loki` via
`curl -sk --resolve <host>:443:192.168.0.143`) — a purge touches state the
whole host shares.
## Things that bite
- **Do not run a 100 GB+ re-fetch concurrently with a big iGPU benchmark** —
the A* run commits ~116 GiB and leaves ~10 GiB available; cache churn risks
the OOM/state failures this fleet has already seen.
- **Reboot is the repair of last resort** if rootless podman's pause process
dies mid-work (`invalid internal status … could not find any running
process`); `podman system migrate` panics in 5.7.0.
- A same-filesystem trash reclaims no space; root-owned leftovers need
`sudo`; never `rm` without explicit permission.
## Related
- [[Model Swap and Rollback]] — the runbook index this classification reads
- [[Quadlet Lifecycle]] — restoring the unit after a payload refresh
- [[Remote Job Runner]] — supervising long re-fetches
- [[Deploy State Triage]] — the push-before-deploy trap in context
@@ -0,0 +1,135 @@
---
type: reference
title: Remote Job Runner
description: Detached long-job execution on fleet hosts with sentinel files (job.sh) — why ssh tail-watchers fail structurally, the script, its invariants, verification, and reporting discipline.
tags: [operations, ssh, jobs, sentinel, detached]
timestamp: 2026-10-05T00:00:00-07:00
---
# Remote Job Runner
Use for any long-running job on a remote host (benchmarks, model loads,
builds, sweeps) whose result must survive the session. Also whenever you catch
yourself writing `ssh host 'tail -f log | sed …'` as a watcher.
## Why the obvious approach fails structurally
`ssh host 'tail -f log | grep/sed …'` as a background watcher fails in ways
that all LOOK like "the job died":
- the session broker **aborts long-lived ssh** — the watcher vanishes and the
result is lost;
- a `ready` regex can match the log's own **header** on the first line — the
supervisor reports success instantly while the job is still running;
- a `sed -n '/PAT/,$p'` filter **exits at the first match**, ending the pipe;
- output goes into the ssh stream, so an aborted session loses everything.
## The mechanism: detach on the HOST, poll a sentinel file
Completion is a **`.done` sentinel touched last**, not a log pattern. Status
is then one fast independent command with no long-lived process to lose.
```bash
#!/usr/bin/env bash
# job.sh {start|status|wait|out|tail|watch} ... Jobs live in $JOBS.
set -uo pipefail
JOBS=/home/dank/jobs
mkdir -p "$JOBS"
case "${1:-}" in
start)
NAME="$2"; shift 2
[ "${1:-}" = "--" ] && shift
rm -f "$JOBS/$NAME.done" "$JOBS/$NAME.rc"
# setsid detaches from this session's process group: the critical bit.
setsid nohup bash -c '
NAME="$1"; shift
"$@" > "'"$JOBS"'/$NAME.out" 2>&1
echo $? > "'"$JOBS"'/$NAME.rc"
touch "'"$JOBS"'/$NAME.done"
' _ "$NAME" "$@" < /dev/null > /dev/null 2>&1 &
echo "started $NAME"
;;
status)
# Glob *.out (not *.done) or running jobs are invisible.
names="${2:-$(cd "$JOBS" && ls *.out 2>/dev/null | sed 's/\.out$//' | sort)}"
for n in $names; do
if [ -f "$JOBS/$n.done" ]; then
printf ' %-18s DONE rc=%s %s lines\n' "$n" \
"$(cat "$JOBS/$n.rc" 2>/dev/null)" \
"$(wc -l < "$JOBS/$n.out" | tr -d ' ')"
elif [ -f "$JOBS/$n.out" ]; then
printf ' %-18s RUN %s lines so far\n' "$n" "$(wc -l < "$JOBS/$n.out" | tr -d ' ')"
else
printf ' %-18s PENDING\n' "$n"
fi
done
;;
wait)
NAME="$2"; LIMIT="${3:-3600}"; waited=0
while [ ! -f "$JOBS/$NAME.done" ] && [ "$waited" -lt "$LIMIT" ]; do
sleep 5; waited=$((waited+5))
done
[ -f "$JOBS/$NAME.done" ] \
&& echo "DONE $NAME rc=$(cat "$JOBS/$NAME.rc" 2>/dev/null)" \
|| echo "TIMEOUT $NAME after ${waited}s"
tail -"${4:-25}" "$JOBS/$NAME.out" 2>/dev/null
;;
out) NAME="$2"; tail -"${3:-40}" "$JOBS/$NAME.out" 2>/dev/null ;;
tail) [ -n "${2:-}" ] && exec tail -f "$JOBS/$2.out" || exec tail -f "$JOBS"/*.out ;;
watch) while true; do clear; date '+%H:%M:%S'; bash "$0" status; sleep 5; done ;;
*) echo "usage: job.sh {start|status|wait|out|tail|watch}" >&2; exit 2 ;;
esac
```
```bash
job.sh start NAME -- <command> # launch detached
job.sh status [NAME] # one-shot state
job.sh wait NAME 900 25 # block, then print the tail
job.sh out NAME 60 # retrieve any past result
job.sh tail [NAME] # live follow
job.sh watch # refreshing status table
```
## Invariants
- **`.done` is touched LAST**, after the exit code is written — its presence
alone means the output file is final.
- **Glob `*.out` for status**, never `*.done` — globbing `.done` hides running
jobs entirely, the exact blindness this fixes.
- **Capture the exit code** (`rc=7` proved a self-test worked; without it a
job can "complete" having failed).
- **Never put the payload in a line you grep for** — the sentinel is the only
completion signal.
- **Never `pkill -f <pattern>`** where the pattern also matches your own ssh
command line — it kills the session. Distinctive names or `pkill -x`.
- **Write helper scripts to a file and `scp` them.** Triple-nested
`ssh 'bash -c "python3 -c \"…\""'` quoting broke repeatedly (unterminated
quotes; `grep -c` returning `"0\n0"` because `|| echo 0` appended a line);
every failure disappeared once the script was a file.
## Verify before trusting it (3am law)
```bash
job.sh start selftest -- bash -c 'echo one; sleep 2; echo two; exit 7'
job.sh start slowtest -- bash -c 'echo start; sleep 30; echo end'
sleep 4; job.sh status # selftest DONE rc=7; slowtest RUN
```
Both paths, including the failure case, and the rc round-trip — a runner that
only proves the happy path is not trustworthy.
## Reporting discipline
- Give the user a real terminal when the run is long:
`alacritty --title "bench - dankserver" -e bash -c 'ssh -o
ServerAliveInterval=30 -t dank@192.168.0.235 "bash /home/dank/job.sh tail"' &`
- State the MECHANISM, not vibes: the job is durable on the host;
`job.sh out NAME` retrieves it later. Do not narrate "still loading"
repeatedly — check once, act, report the resolved result.
- When the user asks for status, one fast command, not a narration.
## Related
- [[LLM Bench Windows and Measurement]] — the sweeps this runner executes
- [[Deploy State Triage]] — the two-process deploy this lesson generalizes
@@ -0,0 +1,130 @@
---
type: reference
title: SSH and Remote Access
description: Passwordless SSH bootstrap without sshpass/expect, remote execution against fish login shells, and diagnosing a frozen GUI from an SSH session.
tags: [ssh, remote, fish, gui, diagnosis]
timestamp: 2026-10-05T00:00:00-07:00
---
# SSH and Remote Access
## Passwordless SSH bootstrap without sshpass/expect
Use when you must install your pubkey on remote hosts but `sshpass` and
`expect` are not installed and you only have a password. Modern OpenSSH
(≥ 8.4) can drive the password prompt from a helper program, so the password
flows through the **process env only** — never a file, never argv.
1. **Generate a key** (if none):
```bash
ssh-keygen -t ed25519 -f ~/.ssh/id_ed25519 -N "" -C "host-deploy-$(date +%Y%m%d)"
```
2. **Create an askpass helper** that echoes the password from an env var (the
helper itself stores NO secret):
```bash
printf '#!/bin/sh\necho "$DEPLOY_PW"\n' > /tmp/askpass.sh
chmod 700 /tmp/askpass.sh
```
3. **Copy the pubkey**, forcing the askpass path and detaching from the TTY:
```bash
env DEPLOY_PW='<password>' \
SSH_ASKPASS=/tmp/askpass.sh \
SSH_ASKPASS_REQUIRE=force \
setsid ssh-copy-id -o StrictHostKeyChecking=accept-new -o ConnectTimeout=10 user@host
```
Password via the transient `env` argument only.
4. **Add host aliases** in `~/.ssh/config` so deploy scripts resolve names
(`HostName`, `User`, `IdentityFile`, `IdentitiesOnly yes`).
5. **Verify** with `ssh -o BatchMode=yes myhost 'echo OK; hostname'` —
BatchMode fails fast if a password would be required; a clean success
proves the key works.
Gotchas: without `SSH_ASKPASS_REQUIRE=force`, ssh ignores SSH_ASKPASS when a
TTY is present and prompts interactively (hangs in a non-interactive tool);
`setsid` detaches the controlling terminal — required on older OpenSSH even
with `force`; the helper must be executable (`chmod 700`) and print ONLY the
password; `IdentitiesOnly yes` stops ssh offering other agent keys that could
be rejected first ("Too many authentication failures"). Passwordless **sudo**
on the target is a separate concern (NOPASSWD / `FRANK_SUDO_PW`).
## Remote execution on a fish login shell
Running remote commands or shipping files over SSH to a host whose login
shell is **fish** (blankpad: user `blank`, reachable only as mDNS FQDN
`blankpad.local` / 192.168.0.83 — the bare hostname does not resolve from the
workstation).
1. **Auth**: `SSHPASS='<pw>' sshpass -e ssh -o
PreferredAuthentications=password -o PubkeyAuthentication=no user@host '…'`
— password via env, never argv (ps-visible).
2. **Remote exec**: `ssh host 'cmd'` pipes the command string to the login
shell (fish). Bashisms — `for … do`, `$?`, heredoc markers — fail at
*parse* time on fish (`Missing end to balance this for loop`). Always wrap
compound commands: `ssh host 'bash -c "for b in a b c; do command -v $b ||
echo MISSING; done"'` (escape inner `$`).
3. **File shipping**: never combine `cat > /remote/path <<EOF` with a
trailing `bash -s` in the same ssh command — `cat` drains stdin, so the
interpreter silently runs on empty stdin and prints nothing (a silent
no-op that *looks* like success). Ship real bytes via stdin redirect:
`ssh host 'cat > /remote/path' < localfile`, then verify byte-truth with
`md5sum` on both ends.
4. **Exit codes**: `$?` in an unquoted remote command is parsed by fish
(`$status` there) — wrap in bash or drop the status echo; ssh's own exit
code propagates to the caller anyway.
Both traps fired during the 2026-09-09 blankpad dotfiles deploy: the first
probe died on fish loop parsing; a `cat >` ship silently swallowed the
heredoc meant for `bash -s`, deploying wrong bytes until md5-compared.
Related: dankserver's login shell is ZSH — never start an unquoted word with
`=` in an ssh one-liner (`echo ===` aborts the whole chain).
## GUI hang — remote diagnosis over SSH
Use when the desktop is frozen, VT switches do nothing, but SSH still works.
1. **Find the smoking gun** — the kernel hung-task reporter already logged
it:
```bash
journalctl -b -p err --since "-2 hours" --no-pager | tail -80
```
Look for `INFO: task X blocked for more than N seconds` and `blocked on a
semaphore likely last held by task Y:PID` (modern kernels name the lock
holder outright).
2. **Full stacks** for victim and holder:
```bash
journalctl -b -k --since "<hang minute -5>" --no-pager | tail -120
cat /proc/<pid>/status | head -5 # State: R (spinning) vs D (blocked)
```
Victim `D` + holder `R` = livelock/deadlock with the lock held;
`voluntary_ctxt_switches` in the millions on an `R` kthread = spinning,
not progressing.
3. **Hardware or software?** GPU/CPU still alive, no Xid errors?
```bash
nvidia-smi --query-gpu=driver_version,pstate,temperature.gpu,clocks_event_reasons.active --format=csv
journalctl -b -k --no-pager | grep -i xid
```
Instant answers with no Xid ⇒ driver lock issue, not hardware.
4. **Trigger window**: block start = first hung message timestamp − reported
seconds; then inspect user-session events around it
(`journalctl -b --since "<start-2m>" --until "<start+1m>" | grep -v audit`).
5. **Collateral**: other `D` tasks (`ps -eo pid,stat,comm | awk '$2 ~ /D/'`),
USB workers, etc.
**Recoverability verdict:** compositor in `D` state is unkillable and a
spinning kernel thread holding the lock cannot be poked — **the only exit is
reboot**: `sync && systemctl reboot --force --force` (double-force skips
stop-jobs waiting forever on D-state PIDs). If the holder is `D` (sleeping on
something that can complete, e.g. storage), recovery may be possible by
clearing that dependency first.
After diagnosing: map stack symbols to driver internals to name the exact
subsystem, check upstream for the exact symbols + driver version, and propose
targeted off-switches (`NVreg_RegistryDwords`, module params from
`/proc/driver/nvidia/params`) rather than blanket driver swaps.
## Related
- [[Fleet Ops Playbook]] — incident procedures that run over these sessions
- [[Remote Job Runner]] — long jobs that must survive session loss
- [[Deploy State Triage]] — the deploy flow these credentials serve
+32
View File
@@ -0,0 +1,32 @@
---
type: index
description: Operational procedures for the fleet — job runners, disk and payload reclaim, container reachability, audits, SSH craft, and incident playbooks.
tags: [operations, index]
timestamp: 2026-10-05T00:00:00-07:00
---
# Operations
Procedures for operating the fleet. The LLM-specific pages live under
[LLM](../LLM/index.md); infrastructure and architecture under the
`../Infrastructure/` and `../Architecture/` folders.
- [[Remote Job Runner]] — detached long-job execution with sentinel files;
why ssh tail-watchers fail
- [[Payload and Disk Reclaim]] — measure-classify-prove-stage-ask disk
procedure, plus the purge-and-refresh protocol
- [[Container Host Reachability]] — netavark/pasta reachability quirks and
the pasta port gate
- [[Fleet Ops Playbook]] — endpoint-down GPU-holder reclaim, scratch-boot
stop-first, pre-commit iteration, incident-window reconstruction, WoL
- [[Fleet Audit and Posture]] — monitoring gaps, vhost probes, security
posture, whole-fleet audit, bulk redaction, stash audits
- [[SSH and Remote Access]] — passwordless bootstrap without sshpass, fish
login shells, GUI-hang diagnosis over ssh
## Related
- [Observability Provisioning](../Infrastructure/Observability%20Provisioning.md)
— monitoring config as code
- [Deploy State Triage](../Architecture/Deploy%20State%20Triage.md) — the
deploy plumbing
- [Quadlet Lifecycle](../Architecture/Quadlet%20Lifecycle.md) — the unit layer