Committing work deployed on 2026-09-17 that had been left uncommitted, so canonical intent stops disagreeing with the running host. The deployed /opt/docker/conf/searxng/searxng-settings.yml is byte-identical to the canonical file here, verified before this commit. Search requests and their DNS now exit via socks5h://10.0.50.65:1080 on esh-scale (CT 108), an application-level proxy rather than a host-wide exit node; no route or firewall changes. microsocks runs as nobody under searxng-egress.service, binds only 10.0.50.65:1080, and bypasses SOCKS auth for source 10.100.50.40 alone — every other source must supply a password regenerated at each start and never distributed. Verified active and enabled. There is deliberately no direct-NH3 fallback: an ESH outage must fail the search rather than silently revert egress. ⚠ THE CHANGE HAS NOT ACHIEVED ITS PURPOSE AS DEPLOYED. Two independent live queries, 2026-09-18, both report brave "Suspended: too many requests", duckduckgo "CAPTCHA" and startpage "Suspended: CAPTCHA", leaving google cse as the only answering engine. Moving egress off NH3's residential address is what this change did, and CAPTCHA avoidance was the stated reason searxng sits at NH3 at all. The README anticipated the risk in its Dependency note; it has materialised. Rollback procedure is in the README and the pre-change config is kept on the host as searxng-settings.yml.pre-esh-20260917. Measured egress also drifted from the value recorded at cutover: the README notes 154.50.58.126, the proxy now exits 128.177.138.182. Expected — the README pins no public IP and calls out WAN failover — but recorded here so the number in the doc is not mistaken for current. Also retargets seat-inventory.py's default host from the mesh address 100.64.0.7 to fv-ml1's LAN address 10.251.50.54, routed by the site gateway.
252 lines
11 KiB
Python
Executable File
252 lines
11 KiB
Python
Executable File
#!/usr/bin/env python3
|
||
"""Regenerate the GPU seat inventory for an inference host from the LIVE box.
|
||
|
||
The point of this script is that the document it writes is DERIVED, never
|
||
hand-maintained. On 2026-09-13 the LiteLLM config was found describing `char-rp`
|
||
as a 31B model on a host and GPU it had not been on since August -- a hand-written
|
||
description that drifted silently for three weeks while looking authoritative.
|
||
Anything a human types here will drift the same way; anything read off the running
|
||
containers cannot.
|
||
|
||
scripts/seat-inventory.py # write the doc
|
||
scripts/seat-inventory.py --check # exit 1 if the committed doc is stale
|
||
scripts/seat-inventory.py --host fv-ml1 # another inference host
|
||
|
||
⚠ Reads state, changes nothing. Safe to run against production at any time.
|
||
"""
|
||
import argparse, json, re, subprocess, sys, datetime, pathlib
|
||
|
||
DEFAULT_HOST = "10.251.50.54" # fv-ml1 (LAN addr; routed by vb-gateway over the mesh)
|
||
DEFAULT_OUT = "docs/pfi/fv-ml1-gpu-seat-inventory.md"
|
||
GATEWAY = "10.250.50.70" # LiteLLM, for alias resolution
|
||
|
||
|
||
def ssh(host, cmd, sudo=False):
|
||
full = f"sudo -n {cmd}" if sudo else cmd
|
||
r = subprocess.run(
|
||
["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=10", f"infra-ops@{host}", full],
|
||
capture_output=True, text=True, timeout=120)
|
||
return r.stdout.strip()
|
||
|
||
|
||
def gather(host):
|
||
"""Everything comes from the box. No constants, no remembered values."""
|
||
seats, uuid2idx = {}, {}
|
||
for line in ssh(host, "nvidia-smi --query-gpu=index,uuid --format=csv,noheader").splitlines():
|
||
i, u = [x.strip() for x in line.split(",")]
|
||
uuid2idx[u] = i
|
||
|
||
# nvidia-smi reports the vLLM ENGINE CHILD pid; docker reports the CONTAINER
|
||
# pid. They are different numbers -- map through the cgroup, never directly.
|
||
for line in ssh(host, "nvidia-smi --query-compute-apps=gpu_uuid,pid,used_memory "
|
||
"--format=csv,noheader,nounits").splitlines():
|
||
u, pid, mem = [x.strip() for x in line.split(",")]
|
||
cg = ssh(host, f"cat /proc/{pid}/cgroup 2>/dev/null", sudo=True)
|
||
h = re.findall(r"[0-9a-f]{64}", cg)
|
||
if not h:
|
||
continue
|
||
name = ssh(host, f"docker inspect --format '{{{{.Name}}}}' {h[0]}", sudo=True).lstrip("/")
|
||
if not name:
|
||
continue
|
||
s = seats.setdefault(name, {"gpu": uuid2idx.get(u, "?"), "vram_mib": 0})
|
||
s["vram_mib"] += int(mem)
|
||
|
||
for name, s in seats.items():
|
||
try:
|
||
args = json.loads(ssh(host, f"docker inspect {name} --format '{{{{json .Args}}}}'", sudo=True))
|
||
except Exception:
|
||
args = []
|
||
|
||
def flag(f):
|
||
try:
|
||
i = args.index(f)
|
||
out = []
|
||
for x in args[i + 1:]:
|
||
if x.startswith("--"):
|
||
break
|
||
out.append(x)
|
||
return out
|
||
except ValueError:
|
||
return []
|
||
|
||
s["served"] = flag("--served-model-name")
|
||
s["ctx"] = (flag("--max-model-len") or ["-"])[0]
|
||
s["util"] = (flag("--gpu-memory-utilization") or ["-"])[0]
|
||
s["seqs"] = (flag("--max-num-seqs") or ["-"])[0]
|
||
s["quant"] = (flag("--quantization") or ["-"])[0]
|
||
s["spec"] = " ".join(flag("--speculative-config")) or "-"
|
||
s["image"] = ssh(host, f"docker inspect {name} --format '{{{{.Config.Image}}}}'", sudo=True)
|
||
s["model"] = next((a for a in args if a.startswith("/")), "?")
|
||
|
||
# resolve a bind-mounted /model to its real path, so lineage is traceable
|
||
mounts = ssh(host, f"docker inspect {name} --format "
|
||
f"'{{{{range .Mounts}}}}{{{{.Source}}}}|{{{{.Destination}}}};{{{{end}}}}'", sudo=True)
|
||
for m in mounts.split(";"):
|
||
if "|" in m:
|
||
src, dst = m.split("|", 1)
|
||
if dst in ("/model", "/local-models") and s["model"].startswith(dst):
|
||
s["model"] = s["model"].replace(dst, src, 1)
|
||
|
||
# weights + KV come from the engine's own startup log, not from arithmetic
|
||
logs = ssh(host, f"docker logs {name} 2>&1 | grep -aoE "
|
||
f"'model weights take [0-9.]+GiB|Model loading took [0-9.]+ GiB|"
|
||
f"GPU KV cache size: [0-9,]+ tokens' | sort -u", sudo=True)
|
||
w = re.search(r"([0-9.]+) ?GiB", logs)
|
||
t = re.search(r"([0-9,]+) tokens", logs)
|
||
s["weights_gib"] = w.group(1) if w else None
|
||
s["kv_tokens"] = int(t.group(1).replace(",", "")) if t else None
|
||
try:
|
||
s["concurrency"] = s["kv_tokens"] / int(s["ctx"])
|
||
except Exception:
|
||
s["concurrency"] = None
|
||
|
||
# lineage: .PROVENANCE.txt is a SIBLING of the model dir, not inside it
|
||
if s["model"].startswith("/tank"):
|
||
prov = ssh(host, f"head -6 {s['model']}.PROVENANCE.txt 2>/dev/null", sudo=True)
|
||
s["provenance"] = prov or None
|
||
cfg = ssh(host, f"""python3 -c "
|
||
import json
|
||
c=json.load(open('{s['model']}/config.json'))
|
||
t=c.get('text_config',c)
|
||
q=c.get('quantization_config') or {{}}
|
||
g=list((q.get('config_groups') or {{}}).values())
|
||
print(json.dumps({{
|
||
'arch': c.get('architectures'), 'type': c.get('model_type'),
|
||
'layers': t.get('num_hidden_layers'), 'experts': t.get('num_experts'),
|
||
'quant_method': q.get('quant_method'), 'quant_format': q.get('format'),
|
||
'groups': [{{'fmt':x.get('format'),
|
||
'w':(x.get('weights') or {{}}).get('num_bits'),
|
||
'a':((x.get('input_activations') or {{}}).get('num_bits'))}} for x in g],
|
||
}}))" 2>/dev/null""", sudo=True)
|
||
try:
|
||
s["config"] = json.loads(cfg)
|
||
except Exception:
|
||
s["config"] = None
|
||
return seats
|
||
|
||
|
||
def aliases():
|
||
out = ssh(GATEWAY, """python3 -c "
|
||
import yaml,json
|
||
d=yaml.safe_load(open('/opt/docker/conf/litellm/config.yaml'))
|
||
r=[]
|
||
for m in d.get('model_list',[]):
|
||
p=m.get('litellm_params',{})
|
||
ab=str(p.get('api_base',''))
|
||
if '10.251.50.54' in ab:
|
||
r.append([m.get('model_name'), ab.rsplit(':',1)[-1].split('/')[0]])
|
||
print(json.dumps(sorted(r)))" """, sudo=True)
|
||
try:
|
||
return json.loads(out)
|
||
except Exception:
|
||
return []
|
||
|
||
|
||
def render(seats, als, host):
|
||
stamp = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
|
||
L = [
|
||
"# fv-ml1 — GPU seat inventory and model lineage",
|
||
"",
|
||
"<!-- GENERATED FILE — DO NOT EDIT BY HAND.",
|
||
" Regenerate: scripts/seat-inventory.py",
|
||
" Check drift: scripts/seat-inventory.py --check",
|
||
" Hand-edits are overwritten and, worse, drift silently while looking",
|
||
" authoritative — which is exactly the failure this file replaced. -->",
|
||
"",
|
||
f"**Generated {stamp}** by `scripts/seat-inventory.py`, read from the running",
|
||
f"containers on `{host}` — `docker inspect`, `nvidia-smi`, each model's own",
|
||
"`config.json`, and the `.PROVENANCE.txt` siblings on `/tank`.",
|
||
"",
|
||
"⚠ `.PROVENANCE.txt` lives *beside* the model directory, not inside it:",
|
||
"`/tank/aimodels/<model>.PROVENANCE.txt`. `ls <model>/` will not show it.",
|
||
"",
|
||
"## Placement, KV cache and concurrency",
|
||
"",
|
||
"| GPU | seat | VRAM | weights | KV tokens | ctx | concurrency | util |",
|
||
"|---|---|---|---|---|---|---|---|",
|
||
]
|
||
for n, s in sorted(seats.items(), key=lambda kv: (kv[1]["gpu"], -kv[1]["vram_mib"])):
|
||
conc = f"{s['concurrency']:.2f}×" if s["concurrency"] else "—"
|
||
L.append(f"| {s['gpu']} | `{n}` | {s['vram_mib']/1024:.1f} GiB | "
|
||
f"{s.get('weights_gib') or '—'} GiB | "
|
||
f"{s['kv_tokens']:,} | {s['ctx']} | **{conc}** | {s['util']} |"
|
||
if s["kv_tokens"] else
|
||
f"| {s['gpu']} | `{n}` | {s['vram_mib']/1024:.1f} GiB | "
|
||
f"{s.get('weights_gib') or '—'} GiB | — | {s['ctx']} | — | {s['util']} |")
|
||
|
||
L += ["", "**Concurrency** = KV tokens ÷ context: how many full-length requests fit at",
|
||
"once. Below ~1.0× the seat cannot hold even one conversation at its declared",
|
||
"context.", "", "## Lineage and quantization", ""]
|
||
for n, s in sorted(seats.items(), key=lambda kv: (kv[1]["gpu"], kv[0])):
|
||
L.append(f"### `{n}` — GPU {s['gpu']}")
|
||
L.append("")
|
||
L.append(f"- **serves:** {', '.join(f'`{x}`' for x in s['served']) or '—'}")
|
||
L.append(f"- **model:** `{s['model']}`")
|
||
c = s.get("config") or {}
|
||
if c:
|
||
arch = (c.get("arch") or ["?"])[0]
|
||
bits = f"{arch} ({c.get('type')}), {c.get('layers')} layers"
|
||
if c.get("experts"):
|
||
bits += f", {c['experts']} experts"
|
||
L.append(f"- **architecture:** {bits}")
|
||
if c.get("groups"):
|
||
gs = ", ".join(f"W{g['w']}A{g['a'] or 16} ({g['fmt']})" for g in c["groups"] if g.get("w"))
|
||
L.append(f"- **quantization:** {c.get('quant_method')} / {c.get('quant_format')} — {gs}")
|
||
if s["spec"] != "-":
|
||
L.append(f"- **speculative decoding:** `{s['spec']}`")
|
||
L.append(f"- **image:** `{s['image']}`"
|
||
+ (" ⚠ **floating tag**" if s["image"].endswith(":latest") else ""))
|
||
if s.get("provenance"):
|
||
L.append("- **provenance:**")
|
||
L += [" ```", *(" " + x for x in s["provenance"].splitlines()), " ```"]
|
||
L.append("")
|
||
|
||
dead = [a for a, p in als if p not in {str(x) for x in range(8000, 8100)}]
|
||
L += ["## Gateway aliases resolving to this host", "",
|
||
f"{len(als)} aliases. Ports with no listening seat are marked dead.", ""]
|
||
ports = {s["served"][0] if s["served"] else "": s for s in seats.values()}
|
||
L.append("| alias | port |")
|
||
L.append("|---|---|")
|
||
for a, p in als:
|
||
L.append(f"| `{a}` | {p} |")
|
||
L += ["", "---", "",
|
||
"*Lineage, provenance, model cards, measured tok/s and depth results live in the "
|
||
"hand-curated companion [`llm-seat-catalog.md`](llm-seat-catalog.md).*", "",
|
||
"*Regenerate with `scripts/seat-inventory.py` after ANY seat change —",
|
||
"model swap, quant change, context or utilization edit, or speculative-decoding",
|
||
"change. Run `--check` in CI to catch a stale document.*", ""]
|
||
return "\n".join(L)
|
||
|
||
|
||
def main():
|
||
ap = argparse.ArgumentParser()
|
||
ap.add_argument("--host", default=DEFAULT_HOST)
|
||
ap.add_argument("--out", default=DEFAULT_OUT)
|
||
ap.add_argument("--check", action="store_true",
|
||
help="exit 1 if the committed document differs from the live box")
|
||
a = ap.parse_args()
|
||
|
||
doc = render(gather(a.host), aliases(), a.host)
|
||
p = pathlib.Path(a.out)
|
||
|
||
if a.check:
|
||
if not p.exists():
|
||
print(f"MISSING: {a.out}", file=sys.stderr)
|
||
return 1
|
||
# ignore the generation timestamp when comparing
|
||
strip = lambda t: "\n".join(l for l in t.splitlines() if not l.startswith("**Generated "))
|
||
if strip(p.read_text()) != strip(doc):
|
||
print(f"STALE: {a.out} does not match the live box. Run scripts/seat-inventory.py",
|
||
file=sys.stderr)
|
||
return 1
|
||
print(f"current: {a.out}")
|
||
return 0
|
||
|
||
p.write_text(doc)
|
||
print(f"wrote {a.out}")
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|