Commit Graph

41 Commits

Author SHA1 Message Date
vh 2a05ae91af feat(training-probes): counted-not-surfaced classifier scaffold
Reusable measurement discipline for probes that must classify how a model
responds to material that should not be printed, logged, or pasted into a
report. Supplies the discipline; the axis map and prompts stay with the caller.

Four rules, each because skipping it produced a wrong number:

  - classify, never surface. Completion text is held inside classify() and does
    not cross the return boundary. A probe that prints what it measured has
    turned a measurement into a distribution channel.
  - three-way, not binary. A refusal regex undercounts — models decline by
    redirecting with no refusal token present, measured at 2/5 to 5/5 on models
    a regex scored 0.
  - the deflection count is a FREE CONTROL. Run both arms: zero on both means
    the model is binary and the regex is sound; only one means the difference is
    real. An artifact does not care which arm it runs against.
  - EMPTY and ERROR get their own buckets. Folding them into either side biases
    the result, and a truncation-heavy arm flatters itself if its failures land
    in the wrong bucket.

Requested by brokkr-smithy-dev for the domain-compliance probe — the discipline
in code rather than reimplemented, with the axis map his side of the line.
2026-08-25 13:10:03 -07:00
vh 64bf9d313f docs(training-playbook): measure refusal retention on the abliteration's OWN axis
§3.13, plus the probe that produced it. Two lessons, both about measuring the
wrong thing confidently.

First: a tune applied AFTER an abliteration can walk it back, and a
reasoning/craft/memorisation gate cannot see that. brokkr-smithy-dev's
preregistered gate measured none of it — a tune that gains 41 items of
contradiction detection and quietly restores refusals passes every check. The
compliance axis has to be added explicitly.

Second, and this is the trap: measure the axis the abliteration was actually
FOR. Ours was run so the model engages explicit fiction. The probe reached for
mlabonne/harmful_behaviors — weapons, malware, fraud — because it was cached and
carried a recorded baseline. Different refusal surface entirely, and a model
moves on them independently. 29/100 general-harm refusals on a tune whose prose
the operator was praising at the time is not obviously a defect and may be
desirable: general-harm refusals returning while domain compliance holds is
close to the ideal shape for an internal creative seat. The measurement was
real; its relevance was assumed.

Also recorded, because both were nearly missed:

- Read the interesting cell. In 29 hard / 0 deflect / 71 comply, the
  load-bearing number is 71. Stock refused 100/100; near that would mean the
  abliteration was undone. 71 complying means partially walked back on one
  axis — a different finding, and only one of the two threatens the seat.
- A baseline from a different harness is not a baseline. The recorded 3/100
  came from the abliteration tool's scorer, which reads first-token probability
  distributions; a probe that generates and regexes is a different instrument.
  Run your own against both arms on the same seat or report the number alone.
- A refusal regex undercounts, so classify hard/deflect/comply — and the free
  discriminator: if both arms return zero deflections the model is binary; if
  only one does, the regex is fine. An artifact does not care which arm it runs
  against.
2026-08-25 12:58:27 -07:00
vh ab980e9345 fix(erp-tune-serve): four defects the end-to-end dry run found, all silent
Validated the full adapter -> merge -> NVFP4A16 -> serve pipeline against
checkpoint-100 of the live run. It works, and it produced a served model
generating coherent prose. Getting there surfaced four failures, none of which
announced itself as the thing it actually was.

1. transformers 5.15 MIGRATES the config schema on save. It drops Gemma-4's
   `global_head_dim` / `num_global_key_value_heads` and writes `per_layer_config`
   instead. transformers 5.10 (what the llmcompressor venv pins) does not know
   the new key and resolves num_key_value_heads to None:
       TypeError: unsupported operand type(s) for //: 'int' and 'NoneType'
   Every working artifact on the box - bf16 base, served nvfp4 prod seat,
   nvfp4a16 build - uses the OLD schema. Merging changes weights, not
   architecture, so the merge now downgrades the schema and asserts the result.

2. llmcompressor cannot auto-init a processor for a multimodal checkpoint and
   dies with a message that names neither the model nor the cause. Calibration
   here is text-only, so the tokenizer is passed explicitly as `processor`.

3. save_pretrained writes tokenizer files only, so `processor_config.json` was
   never carried. vLLM then fails at startup with "Can't load feature extractor",
   which reads as a vision bug and is actually a missing-file bug. Both scripts
   now carry the base's auxiliary configs.

4. The quant needs more than the 32 GiB free on GPU1 alongside the resident
   seats. Rather than leave that to a caller, quant_with_gen_down.sh stops
   vllm-gen and restores it from a trap on EVERY exit path - crash, OOM, kill,
   or success - because the restore must not depend on the calling session
   surviving. Uses `docker start`, not `compose up`, so the container comes back
   with its exact original config. Measured window: ~15 min, gen healthy after.

Verified on the resulting artifact:

  merge   410 adapter tensors, sampled target weights confirmed CHANGED,
          upstream 390-line chat template shipped (not the base's stale 365)
  quant   49 GB -> 17 GB, format nvfp4-pack-quantized, a=null (genuine A16),
          weight_packed 11,725 of which 11,520 expert = 30 x 128 x 3,
          tokenizer truncation clean
  serve   Marlin NVFP4 kernel + Marlin MoE backend, 40,492-token KV cache,
          coherent generation with content correctly populated

One quality note: the reference nvfp4a16 artifact triggers a vLLM warning that
parallel layers (q/k/v) carry different weight global scales, "likely to result
in reduced accuracy". Our build does not - llmcompressor 0.12 links weight
observers across fused groups for a shared global_scale automatically. The
in-house quant is better than the downloaded one on that axis.

Separately: the lora_B inert-adapter gate PASSED on checkpoint-100 - 205/205
non-zero, median norm 0.829, zero vision_tower tensors. That check never ran in
round 1, and it is the only failure mode that stays invisible until the
acceptance gate reports base-identical numbers.
2026-08-24 23:48:25 -07:00
vh 6a8582936e feat(erp-tune): NVFP4A16 serving pipeline, and the MoE landmine it uncovered
Merge + quantize path for turning the Gemma-4 26B-A4B ERP/RP LoRA into a
servable NVFP4A16 seat, plus a playbook entry for the defect found while
validating it.

The landmine (playbook §3.15): a `targets=["Linear"]` NVFP4 recipe silently
misses every MoE expert on this architecture. Gemma-4 stores each layer's 128
experts as two fused 3-D nn.Parameter tensors, not nn.Linear modules, so the
recipe resolves 205 of 427 modules and ZERO experts — 22.84 B params, 88.5% of
the model, left in BF16 with no warning. This is the same blind spot that
killed QLoRA here via bitsandbytes; the tool changed, the checkpoint layout did
not.

  before linearize_moe:  427 Linears,    205 targeted, experts 0
  after  linearize_moe:  11,947 Linears, 11,725 targeted, experts 11,520
                         (30 layers x 128 experts x 3 projections)

llmcompressor's linearize_moe unfuses them; no registration needed because
Gemma-4 satisfies FusedExpertsProtocol structurally. Caught by an §4.1 dry run
that asserts the expert count before any GPU spend, which is now the documented
requirement rather than an optional step.

Scheme is NVFP4A16, deviating from the playbook's mixed-W4A4 default on
measured grounds: brokkr-smithy-dev benched the W4A4 quant of this checkpoint
at 12% on contradiction detection with CoT off against gen's 81%, the signature
of 4-bit input activations on a reasoning-dense task, and W4A4 KLD degrades
2-4x past ~10k ctx on sm_120. This is a 16,384-ctx RP seat. Marlin's prefill
cost is accepted.

Two further silent-failure guards, both from prior hard-won lessons:
- the merged model ships the UPSTREAM chat template, not the trainee base's
  stale 365-line one, because training rendered through upstream and the
  mismatch would present as a tuning failure
- calibration reads the run's own encode cache rather than re-tokenizing, which
  sidesteps §3.14 (a fast tokenizer mutated by truncation=True and persisted by
  save_pretrained clamps every prompt forever)

Merge-then-quantize rather than LoRA hot-swap, since hot-swap onto NVFP4 was a
silent no-op on vLLM 0.24.0 (#47639). merge_lora.py asserts sampled target
weights actually changed, so an inert adapter cannot ship as a tune.
2026-08-24 23:11:33 -07:00
vh 7b5fd91d3c docs(gemma4-erp-tune): root-cause the 8.6% MFU — attention on Ampere kernels, 29.9% padding
Run-01 was killed at step 19 by operator instruction to root-cause before
spending a ~13.9h window. Two independent methods now agree on where the step
time went, and neither was the hypothesis the consult panel converged on.

Scaling fit (3 points, 2 params, residuals <3ms over an 8x range):
  A = 6.87e-4 s/token, B = 8.85e-8 s/token^2
  quadratic share 20.9% @ w=2048 -> 67.8% @ w=16384
  No fixed term was needed, which refutes launch-bound outright.

Profiler kernel table (device rows only):
  attention   22,835.8 ms  65.2%   fmha_cutlass*_sm80
  dense GEMM   2,774.0 ms   7.9%
  other        5,739.0 ms  16.4%

The attention kernels are sm80 — Ampere-generation CUTLASS running on an
sm_120 Blackwell card, with the forward on the gmem fallback tier. That is the
mechanism behind 100% SM utilisation at 27 of 304 available TFLOPS.

Correctness cleared separately: the sliding mask asserts at max 1024
allowed/row, so the 25 windowed layers were genuinely windowed. The same probe
found that right-padding is what pins the 5 global layers to an explicit 4D
mask and off the is_causal fast path — measured at 9.4% slower for 24% less
loss work at fixed width.

The largest available win is not the attention kernel. The corpus is 29.9%
padding, and bucket-to-pair + shuffle-to-mix takes it to 0.0% for >=35.5% wall
clock, no new dependency, unchanged peak memory. Bucket size turned out not to
be a diversity knob — roots per accumulation window are flat across a 256x
range, so the global micro-batch shuffle does that work alone and the bucket
should be tight.

Adds docs/pfi/training-throughput-playbook.md as the durable model-agnostic
home (sibling to the quantization playbook), the four probes under
scripts/training-probes/ with raw output kept for re-derivation, and a §6 to
the sizing doc carrying the Gemma-4-specific numbers and round-2 restart
parameters.

Measured negatives recorded so they are not re-chased: grouped_mm (0.9%
slower, and MoE is only 7.9% of the step), CUDA graphs / torch.compile over
the expert loop (no fixed cost to amortise), liger fused CE (~1-3% lever),
FA4 on sm_120.

Round-1 state preserved: 609MB encode cache, order manifest, truncation
report, resume script. No checkpoints — it died at step 19 and the first was
due at 100, so the lora_B inert-adapter gate never ran and moves to the
restart.
2026-08-24 22:10:51 -07:00
vh b8003c73ae feat(dns): fleet .internal naming — git-sourced, agent-managed, three resolvers
Names for fleet hosts so addresses stop needing to be memorised. Built because
IPv6 makes that hopeless — and, more to the point, because v6 addresses are
derived rather than assigned, so they cannot reliably be written down once and
trusted either.

  dns/internal.yaml     source of truth: 38 hosts + 4 service aliases
  scripts/dns-sync.py   reconciles AdGuard resolvers against it
  stacks/adguard-ana/   the colo's resolver, which did not exist

Naming is <host>.<site>.internal with sites ana/esh/nh3 (operator's call).
.internal is ICANN-reserved for this; .local is reserved for mDNS, which is
why searxng.pfi.local was a collision that merely happened to work.

Same posture as deploy-stack.sh: file is intent, resolvers are derived state,
you see a diff before anything changes. Every name is published to every
resolver, so the site label says where a host IS, not who knows about it.

Two properties that matter:
- Authority is scoped to the ZONE, not the resolver. ESH carries hand-made
  esteban.net rewrites predating this; they are read, ignored and preserved.
  Resolver-wide authority would have silently deleted them.
- Within .internal it IS authoritative, so UI-added names get removed. That is
  the point — one place to look.

Colo gap closed: ana-docker had no resolver at all (hosts went straight to
1.1.1.1). Its AdGuard runs API on 8053 because 8080/3000 were taken, so the
port is carried per-site in the yaml rather than assumed by the script. It
ships with no blocklists — a false positive on a server network breaks
service-to-service calls for no upside.

Auth is a dedicated infra-ops AdGuard user, not the operator's account,
password vaulted at nh3-dev/adguard-infra-ops-password. Pre-change configs
backed up on each resolver. Both resolvers stayed answering across the restart.

searxng.pfi.local -> searxng.ana.internal, with the old Host() kept alongside
so nothing breaks mid-migration. matrix.pfi.local deliberately NOT migrated: a
Matrix server_name is baked into every user id, room id and signing key, so
renaming it rebuilds the homeserver's identity rather than changing a DNS name.

The v6 column is empty and correct — no fleet host has a global v6 address
yet. The file documents why addresses must be pinned statically before they go
in, since a record that silently stops matching is worse than no record.
2026-08-19 01:10:55 -07:00
vh b95802efa4 docs(backup): hourly off-box ~/development backup to nh3-nas (runbook + script)
Adds the rsync --link-dest hourly snapshot job (nh3-dev:~/development ->
nh3-nas, 48-snapshot retention, secrets/build-dirs excluded) that closes the
no-off-box-backup gap exposed by the 2026-07-12 working-dir clobber. Script
mirrors the live ~/.config/dev-backup/dev-backup.sh; runbook covers restore.
2026-07-12 01:40:43 -07:00
vh 76b317ce3e feat(backups): freshness check + daily alert timer; record rest-server-ana recovery, fstab hardening, esh-pve-nas gap, worldtree admin-key provisioning 2026-06-20 16:13:13 -07:00
vh 4f0c5f1f66 feat(inventory): bring nh3-dev under infra-ops management
Adds the NH3 dev box (10.100.10.50) as a managed host: README, first
system-details snapshot, ssh-target. Adds it to the fleet bootstrap's
Tier 1 and the CLAUDE.md servers table. infra-ops identity bootstrapped
there (operator-run) — NOPASSWD sudo + key, so root-level infra work on
the box (it runs the egress proxy, ttyd seat, mead-hall, volva, and dev
checkouts) no longer needs a per-task password.

First use: installed Playwright headless-Chromium system deps + binary
for bloom_music's OSMD browser-geometry test harness; headless launch +
real SVG geometry verified.
2026-06-04 14:03:52 -07:00
vh 8c32a0540f feat(infra-ops): commission a dedicated NOPASSWD-sudo agent identity for PFI boxes
Adds a host-agnostic elway play + fleet driver that stand up an
`infra-ops` system user (dedicated ed25519 key, NOPASSWD sudo with
log_output audit, docker group) so the infra-ops agent completes
DevOps work end-to-end instead of handing sudo steps back to the
operator. Scoped to PFI-owned Linux boxes; tiered (compute/app/
sensitive-infra) with SureFire/corviduo/esh/Synology explicitly
excluded. Validated live on irv-ml1.
2026-06-03 14:49:46 -07:00
vh f27ee47fac feat(nh3-egress-proxy): durable SOCKS5 egress on nh3-dev for datacenter-IP-gated colo services
YouTube (and a growing set of services) hard-flag datacenter IPs, bot-gating
even public content regardless of cookies/PO-tokens. Origin case: yt-voice-clipper
on irv-ml1 (Irvine colo) — every yt-dlp fetch returned LOGIN_REQUIRED. Confirmed
pure IP reputation: the same public video fetches cleanly (no cookies) once routed
through nh3-dev's residential egress (70.230.226.88).

- scripts/setup-nh3-egress-proxy.sh: idempotent dante (SOCKS5) install + config.
  Internal-only ACL (10.100.0.0/16), bound to the WG interface, systemd-managed.
- docs/runbooks/nh3-egress-proxy.md: purpose, usage, security model, caveats.

Reusable fleet egress, not yt-voice-clipper-specific.
2026-06-02 22:37:08 -07:00
vh 9e9bdf9810 deploy-stack: add -n to ssh calls to prevent stdin slurp in loops
Without -n, ssh inherits the surrounding loop's stdin and consumes
the heredoc that feeds $changed / $deleted, silently truncating the
diff output to the first file only.
2026-05-17 22:46:41 -07:00
vh c5ab99e74f kokoro: persist custom voices across container recreate
Wrapper only enumerates one voice directory (settings.voices_dir,
default /app/api/src/voices/v1_0 — inside the container's writable
layer, not bind-mounted). Override via VOICES_DIR=/app/user_voices
(host bind mount) and add a command shim that cp -r's built-ins from
the in-image v1_0 into user_voices on every start. Built-ins re-seed
fresh from the image (so upgrades that add voices propagate); custom
.pt files in user_voices are preserved (cp -r is additive).

Also adds scripts/blend_kokoro_voice.py + a playbook around it that
mirrors the wrapper's request-time voice="a(w)+b(w)" math but writes
the result as a named .pt to user_voices, making it discoverable via
GET /v1/audio/voices and persistent across recreate. Defaults to
athena = af_bella(2)+af_aoede(1) normalized.
2026-05-10 17:48:24 -07:00
vh 48aaa53c9d extract news-digest into its own repo (vh/nevermore)
The applet outgrew "stack alongside the infra-management workspace" —
it has its own pyproject, multi-tenant deploy story, separate
release cadence, and isn't actually about managing infrastructure.
Lives at https://gitea.phasefinal.com/vh/nevermore now, with
provenance noted in its initial commit.

This commit removes:
  stacks/news-digest/             (full stack tree)
  playbooks/deploy-news-digest.yaml
  scripts/add-digest-user.sh

The existing ana-docker deployment continues running on its baked
local/news-digest:v5 image — nothing changes for the live install
until you choose to redeploy from the new repo. Migration steps
(rename data dir, redeploy, retire old compose dir) are in
nevermore's README.

Updated:
  README.md     — Current stacks listing now points at the new repo
  STATUS.md     — milestones entry for the extraction
2026-04-29 17:18:14 -07:00
vh 765d89aed3 scripts: add fork-fleet.sh for bootstrapping a new fleet repo
Mirrors the reusable tooling (scripts/, generic playbook templates,
.gitignore, conventions section of CLAUDE.md) into a new directory
and strips everything fleet-specific: servers/, stacks/, configs/,
fleet-named playbooks (deploy-*, decouple-*), runbooks, status,
host-pinned scripts.

Output is an empty conventionally-organized workspace with fresh git
history, ready to populate with a different fleet. Skeleton
CLAUDE.md / README.md / STATUS.md are written with the new fleet
name baked in but no server table or placement rules pre-populated.

Deliberately does NOT create a remote or push — the user picks the
namespace + name explicitly via tea / git remote add.

  scripts/fork-fleet.sh ~/development/acme-prod-management
  scripts/fork-fleet.sh /tmp/test-fork test-fleet
2026-04-28 15:58:22 -07:00
vh d552e289cb news-digest: per-instance cron schedule via env
Hardcoded crontab → render at container start from
DIGEST_CRON_AM + DIGEST_CRON_PM. Defaults match the original
0800 / 2000 so existing deploys are no-ops.

scripts/add-digest-user.sh learns --am and --pm flags so each
teammate's stack can fire on their hours:

  scripts/add-digest-user.sh bob --am "0 6 * * *" --pm "0 17 * * *"
  scripts/add-digest-user.sh carol --pm "30 18 * * 1-5"   # weekdays only

Standard 5-field cron syntax; busybox crond honors the container's
\$TZ. Removed the now-unused stacks/news-digest/crontab file and
the matching COPY in the Dockerfile.
2026-04-28 14:29:55 -07:00
vh aeb5c18ca3 news-digest: multi-tenant deploy + masthead overlap fix
Two pieces:

1) Multi-tenant onboarding via scripts/add-digest-user.sh

   Shared miniflux + per-user digest stack. Onboarding a teammate
   takes one command (plus a one-time sudo for dir creation):

     scripts/add-digest-user.sh <username>

   What the script does:
     - Reads miniflux admin creds from ana-docker
     - Allocates next free port (scans existing digest-*/.env)
     - Generates a random password (or accepts one as 2nd arg)
     - Creates the miniflux user via the admin API
     - Materializes a per-user .env at /opt/docker/compose/digest-<user>/
       (inherits NEWS_DIGEST_TAG from the canonical stack so all
       tenants run the same image)
     - Brings up `docker compose -p digest-<user> up -d`
     - Seeds default world/local feeds in the new user's miniflux
     - Triggers a first digest run

   compose.yaml now uses ${DIGEST_PROJECT:-news-digest} to namespace
   container_name + homepage labels. Default keeps backward-compat
   for the singleton install — existing stacks unaffected.

2) Masthead overlap on phone widths

   Desktop CSS pinned .masthead-edition to grid-row 1, which collided
   with .masthead-brand once the mobile media query collapsed both
   to grid-column 1. Result: "MORNING EDITION" badge stacked on top
   of the "DAILY DIGEST" hero. Reset grid-row to `auto` for all
   three masthead children in the ≤720 px breakpoint so they
   auto-flow vertically.
2026-04-28 13:53:26 -07:00
vh 6f7bb4885e stacks/ is canonical; stacks-mirror/ is drift snapshot — stop confusing the two
Decision recorded in CLAUDE.md ("Stack tree convention") and memory
(convention_stacks_vs_mirror.md):

  stacks/<stack>/                    canonical / intent. git-tracked.
                                     deploy-stack.sh reads from here.
  stacks-mirror/<host>/<stack>/      snapshot / reality. gitignored.
                                     sync-stacks.sh writes here. Used
                                     for drift inspection only — never
                                     a deploy source.

Bug this fixes: deploy-stack.sh was reading from the mirror, so edits
to stacks/llama-swap/config.yaml never reached ana-ml2. Today's
two new model entries (qwen3.6-35-a3b-heretic + qwen3.6-27b) lived
in the canonical for hours but the deploy reported "in sync" because
the script only diffed mirror vs server.

Changes:
* deploy-stack.sh: source switched from MIRROR_DIR/$HOST/$STACK to
  STACKS_DIR/$STACK. Header comment + error message updated.
* sync-stacks.sh: header explicitly identifies its role as drift
  detection; documents the diff command for comparing canonical vs
  mirror.
* stacks/llama-swap/{config.yaml → conf/config.yaml}: matches the
  deploy mapping (conf/ in canonical → /opt/docker/conf/ on host).
* CLAUDE.md: "Stack mirror (pull / push)" section rewritten as
  "Stack tree convention (canonical vs mirror)" with the role table
  + workflow rules + diff recipe. Layout diagram updated.
2026-04-26 22:07:30 -07:00
vh c9c4262bdb scripts/upgrade-irv-ml1-cuda: drop pipe-to-head (SIGPIPE under pipefail)
Phase 1 silently aborted right after the 'cuda-drivers candidate' step
— before snapshotting, stopping containers, installing the new driver,
or rebooting. Symptom: phase2 showed driver still at 570.124.06 and
containers with multi-hour uptimes (proof they were never stopped).

Cause: `apt-cache policy cuda-drivers | head -10`. head reads its 10
lines and closes the pipe early; that delivers SIGPIPE to apt-cache,
which under `set -o pipefail` propagates as a non-zero exit, and
`set -e` immediately terminates the remote shell. The same hazard
existed in phase2's `docker ps ... | head -15`.

Fix is one-line per call site: drop the head. apt-cache policy output
for cuda-drivers is short (~10 lines), and docker ps's container list
on irv-ml1 is small enough to print fully.
2026-04-25 16:51:28 -07:00
vh 8beb858239 scripts/upgrade-irv-ml1-cuda: detect existing cuda repo, clean up orphan
Phase 1 was unconditionally adding /etc/apt/sources.list.d/nvidia-cuda.list
pointing at /etc/apt/keyrings/nvidia-cuda.gpg. But irv-ml1 already had
the upstream-style /etc/apt/sources.list.d/cuda-debian12-x86_64.list
with /usr/share/keyrings/cuda-archive-keyring.gpg — that's how the
existing 570 driver got installed in the first place. APT then refused
both: 'Conflicting values set for option Signed-By regarding source ...
debian12/x86_64/'.

Now the script:
  * removes any orphan nvidia-cuda.{list,gpg} files from a previous
    failed run
  * greps existing /etc/apt/sources.list.d/*.list for a debian12 cuda
    repo entry; if found, trusts and uses it
  * only writes a new repo entry if no existing one is found, and
    when it does, uses the same upstream paths (cuda-archive-keyring.gpg,
    cuda-debian12-x86_64.list) so reruns are stable

Re-run safe — phase1 can be invoked any number of times without
accumulating conflicting configs.
2026-04-25 16:43:59 -07:00
vh e4a809cfc0 scripts/upgrade-irv-ml1-cuda: stage remote script via scp, exec via ssh -t
The previous version piped a heredoc to `ssh -t host bash -s` —
which can't work because `-t` requires a real TTY and heredoc puts
stdin in pipe mode. They're mutually exclusive (orientation.md
gotcha, tripped over once already). The remote sudo therefore aborted
with 'a terminal is required to read the password' before doing
anything.

Restructured: the remote half of each phase is built as a tempfile
locally, scp'd to /tmp on irv-ml1, made executable, and invoked via
`ssh -t host /tmp/upgrade-cuda-remote.sh phase1`. Now stdin is the
real TTY (no pipe), sudo prompts work, and the rest of the script
runs as before.

Same three phases, same rollback behavior, same idempotency story.
Just a transport fix for the local→remote half.
2026-04-25 16:40:00 -07:00
vh 5a5ab94939 scripts: upgrade-irv-ml1-cuda.sh — driver bump 570 → 580 (CUDA 12.8 → 12.9+)
Two-phase script for bumping irv-ml1's NVIDIA driver to support
CUDA 12.9 — currently blocked at 12.8 per driver 570.124.06, which
is why the Kokoro GPU image (built against CUDA 12.9) wouldn't
start.

Phase 1 (interactive — sudo prompted once via ssh -t):
  * snapshot current nvidia packages to /tmp/nvidia-pre-upgrade.txt
    (rollback reference)
  * add NVIDIA's CUDA APT repo for Debian 12 with signed key
  * stop GPU containers (comfyui, cosyvoice, qwen3-tts, index-tts,
    parakeet, kokoro)
  * apt install cuda-drivers (currently pulls 580 series)
  * reboot

Phase 2 (re-run after host comes back):
  * poll for SSH availability
  * verify new driver + CUDA version
  * restart GPU containers via docker compose up -d
  * print next steps (flip Kokoro .env to gpu variant + redeploy)

Includes a rollback subcommand that reinstalls the snapshotted
packages with --allow-downgrades and reboots — for the case where
the new driver regresses on this kernel.

Driver source switched from Debian's nvidia-driver metapackage to
NVIDIA's official cuda-drivers metapackage; bookworm doesn't carry
575+ in its main or backports repos as of 2026-04-25.
2026-04-25 16:35:27 -07:00
vh 1f14c6d959 scripts: restic-prune.sh — quarterly forget + prune ceremony (closes #9)
Toggles --append-only off on the rest-server via a temporary
docker-compose.override.yaml (canonical compose untouched), runs
resticprofile forget --prune --verbose on each client of that
rest-server, then restores --append-only. The restore is wrapped in
a trap so a partial-failure prune still leaves the rest-server in
its safe configuration.

ANA side is fully automated against ana-docker (5 clients:
ana-docker, ana-ml2, esh-docker-vm, vm-esh-nas, esh-vm-db).

NH3 side currently prints a manual DSM ceremony — Synology Container
Manager doesn't expose docker on the expected paths and syncuser
sudo isn't NOPASSWD, so the toggle isn't safely scriptable from
this workstation. The instructions cover the same flow in DSM web
UI + interactive ssh on each NH3 client (nh3-docker, nh3-dev,
irv-ml1).

Usage:
  scripts/restic-prune.sh ana    # ANA only (auto)
  scripts/restic-prune.sh nh3    # NH3 instructions
  scripts/restic-prune.sh all    # both
  scripts/restic-prune.sh -h     # help
  scripts/restic-prune.sh --dry-run ana   # show every command
2026-04-24 22:01:37 -07:00
vh 4f7bf3b0b6 elway: tee every run to /tmp/elway-last.log + ~/.cache/elway/runs/
Avoids the "paste the full output" friction. Every elway run now
writes its full streamed output to two files in addition to the
terminal:

  /tmp/elway-last.log
      always overwritten — the easy "what just happened" target

  ~/.cache/elway/runs/<ts>-<host>-<playbook>.log
      timestamped permanent record; accumulates across runs

Implementation: small _Tee class wraps sys.stdout for the duration
of main() so all `print(...)` calls fan out to the real terminal +
both file handles. Subprocess output already goes through print()
via _stream_process, so the build/healthz/etc. text is captured.
ANSI color codes are kept in the file so colors are preserved in
log viewers that handle them (less -R, modern tail). Strip with
`sed 's/\x1b\[[0-9;]*m//g'` for paste-elsewhere.

New flags:
  --log <path>   override path; replaces both default destinations
  --no-log       terminal-only, skip both files

Path of the permanent log is printed at the top of every run so
you know where it landed without remembering the timestamp pattern.
2026-04-24 17:02:26 -07:00
vh 42fbc4b4c4 elway: lazy sudo probe — don't prompt when every sudo step will skip
Previously the startup logic ran `probe_sudo()` if any step in the
playbook declared `sudo: true`, regardless of whether that step's
creates:/when: gates would actually let it fire. The result on the
task-board deploy rerun was a spurious password prompt followed by
six SKIPPED lines — the prompt served no purpose.

New flow:
  - Remove the upfront probe in main().
  - SSHContext.sudo_password defaults to None; new sudo_probed flag
    tracks whether we've already prompted this session.
  - run_shell_step + run_upload_step call ensure_sudo(ctx) only at
    the point a sudo step is actually executing — i.e. after its
    skip conditions have been evaluated and passed. Idempotent:
    probes at most once per playbook run.

Tradeoff accepted: if the user fat-fingers the password, they see it
mid-run on the first sudo step rather than upfront. `stop_on_fail`
(default true) halts cleanly; they rerun. Lower friction for the
common idempotent-rerun case, same recoverability.

Verified against playbooks/deploy-task-board.yaml — prior run
prompted + completed in 1.7s; new run completes in 1.7s with no
prompt because every sudo step skip-gated.
2026-04-24 15:04:36 -07:00
vh f115c982bc elway: add tier 1 + tier 2 idempotency
Tier 1 — pre-step skip conditions:
  when:    <remote shell expr>   skip unless expr exits 0
  creates: <remote path>         skip if path already exists
  removes: <remote path>         skip if path is already absent
Any of the three saying "skip" marks the step `skipped` and moves on.
Evaluated under bash -c on the remote so `!`, `[[`, pipes etc. behave
consistently regardless of the default remote shell.

Tier 2 — post-step change detection:
  changed_when: <remote shell expr>
Evaluated after a successful step. Exit 0 → step counts as `changed`
(default). Exit != 0 → `ok` (ran, nothing actually different).
Without this field, successful steps default to `changed`, matching
Ansible's shell/command defaults. Useful on verify steps:
`changed_when: "false"` reports them as `ok` since they only attest.

Status model moved from pass/fail to four states:
  ok / changed / failed / skipped
Summary reports each count; overall outcome is CHANGED if any step
changed, OK if none did, FAILED on any non-skipped failure.

Rerunnable smoke: playbooks/elway-smoke.yaml now proves it. On a
clean target the cold run reports 4 changed, 3 ok. Rerunning with
the same vars reports 2 skipped / 2 changed (upload + log-record
have no idempotency hooks and are always `changed`). Overriding
--var greeting=... re-runs the gated step exactly as intended.

Doc block at the top of the script updated with the new schema
fields and state machine.
2026-04-24 10:39:48 -07:00
vh dea95bf526 elway: add mini playbook runner + smoke playbook
`scripts/elway` is a ~600-line Python tool (stdlib + python3-yaml) for
driving one-off ssh commands, ad-hoc file uploads, and YAML playbooks
against a single host. Fills the gap between "single ssh one-liner"
and "reach for Ansible."

Highlights:
  - Three invocation modes: --shell, --upload (LOCAL:REMOTE[:MODE]),
    and --playbook <path>
  - Playbook schema: inline vars, list of steps, optional verify block.
    Template via {{ var }}; CLI --var overrides inline defaults
  - stop_on_fail global (default on), per-step override. Verify phase
    always runs, even after a halt — you see end-state regardless
  - Sudo handled once: probes NOPASSWD; if not, prompts locally via
    getpass, validates up-front, then feeds via `sudo -S` per step.
    Password never written to disk/logs. Upload-with-sudo stages to
    /tmp then sudo-mv + sudo-chmod
  - SSH connection reuse via ControlMaster (60s persist) keeps
    multi-step playbooks responsive (~30ms/step reuse vs ~550ms cold)
  - Live interleaved stdout/stderr with per-step prefix and colored
    pass/fail summary. --dry-run prints the plan without executing
  - Shebang pinned to /usr/bin/python3 to bypass venv-shadowing
    when python3-yaml lives in the system site-packages

Smoke test (playbooks/elway-smoke.yaml) covers vars + upload + verify;
drove out a YAML-scalar-coercion bug before first commit (`shell: false`
parsed to Python bool, crashed the templater — now coerced to string
at load time with a clear error on nulls).
2026-04-24 10:09:16 -07:00
vh fd1c9287cd scripts/refresh-*: StrictHostKeyChecking=accept-new on first connect
BatchMode=yes (which the scripts set) implies strict host key checking
and refuses to prompt — so first-time SSH to a host that isn't in
known_hosts fails with "Host key verification failed". Every new host
we register needs a manual `ssh <host>` round-trip first to store the
key before the refresh scripts can reach it.

accept-new fixes that: unknown hosts are auto-accepted into
known_hosts on first connect; subsequent key CHANGES still fail loudly
(as they should — that'd be a MITM signal).

Matches the pattern already used by deploy-stack.sh.

Affects only refresh-server-info.sh and refresh-proxmox-info.sh;
deploy-stack.sh + sync-stacks.sh use their own targets that may or may
not want the same treatment (leaving alone for now).
2026-04-21 16:53:56 -07:00
vh b33439499b scripts/discover-*: four QoL upgrades from first live gap-analysis run
First real run surfaced 31 gap rows, ~20 of which were noise. These
changes reduce the output to actionable signal.

1. discover-unifi: /ea/devices now filters out
     - IPs outside the fleet LAN range (UDM's WAN IP appearing as a
       "device", ISP uplink records with public IPs)
     - UDM self-records (isConsole=true, or IP matches wans[].ipv4)
     - UCI records (UniFi Cable Internet = ISP modem tracking)
   LAN filter regex defaults to ^10\. (matches 10.0.0.0/8); override
   via UNIFI_LAN_FILTER env var if you run other private ranges.

2. discover-gaps: new --ignore-unifi flag drops rows where the final
   SOURCE column starts with "unifi:". Useful for "show me servery
   things to manage, not the fleet's network hardware."

3. discover-gaps: known-IP set now pulls IPs from
   servers/*/proxmox-details.txt AND servers/*/system-details.txt in
   addition to README.md and ssh-target. Consequence: VMs tracked by
   proxmox_inspect.sh are automatically counted as known without
   needing a separate servers/<vmname>/ dir. Also strips meaningless
   addresses (127.*, 0.0.0.0, 169.254.*) so they can't false-positive
   a "known" match.

4. MAC normalization: both discover-fortigate and discover-unifi now
   emit xx:xx:xx:xx:xx:xx lowercase. Previously FortiGate used colon
   format, UniFi used no-separator uppercase — same MAC looked
   different per source. Fortigate does tolower() in awk; UniFi uses
   a shared jq `norm_mac` function.
2026-04-21 14:19:12 -07:00
vh 57c944ad5f scripts/discover-unifi: correct field selectors for real API response shape
Raw dumps of /ea/hosts and /ea/devices surfaced the actual JSON:

- /ea/hosts: LAN IP isn't at top-level ipAddress (that's WAN public);
  it's buried in reportedState.ipAddrs[] mixed with WAN + link-local.
  Have to pick the first RFC1918 entry that ISN'T also a WAN interface
  IP (reportedState.wans[].ipv4). Name/mac/model all live under
  reportedState.{hostname,mac,hardware.shortname}.

- /ea/devices: outer records are per-host wrappers; real AP/switch
  records are in the nested `devices` array with top-level `ip`, `mac`,
  `name`, `model` fields. Previous parser was reading the wrapper and
  getting all `-`.

Reorder all TSV outputs so IP is column 1 — makes discover-gaps.sh
work uniformly against both FortiGate and UniFi sources. Sites TSV
dropped its IP slot since sites have no meaningful IP (metadata only).

Verified against the real payloads the user captured: ESH-UDMPM now
surfaces as 10.0.0.1 (LAN) instead of 192.168.200.111 (WAN2, RFC1918
but excluded via the wans cross-check). A sample device record
(E7-ESH-Media at 10.0.250.176) flattens correctly into a single TSV row.
2026-04-21 14:12:42 -07:00
vh ddc45dc3bd scripts/discover-unifi: add 'raw <path>' mode for debugging selectors 2026-04-21 14:07:28 -07:00
vh 3bdbfc3424 scripts/discover-unifi: switch to Site Manager API (developer.ui.com)
Rewrite to use Ubiquiti's public cloud API at api.ui.com instead of
logging into individual controllers via session cookies. Benefits:

  - One API key covers every UniFi OS device on the account (no
    per-controller login logic, no cookie jar lifecycle).
  - Read-only by design (auth keys are scoped).
  - Works across sites transparently.

Three endpoints wired up: hosts (controllers / Cloud Keys), sites,
and devices (APs / switches). Each emits a distinct TSV shape so the
output can be concatenated and still parsed.

`all` mode runs all three and prints section markers on stderr so
the stdout stream stays clean TSV suitable for discover-gaps.sh.

Pagination handled via nextToken. Rate limit not enforced locally;
Ubiquiti documents generous defaults for read endpoints.

Note: Site Manager API (early access) doesn't appear to expose a
connected-client list directly. For endpoint discovery (IP + MAC of
connected clients like laptops, IoT, etc.) we'd still need to hit
each local controller's REST API — follow-up if the infrastructure-
level data isn't enough.

Requires: curl (present), jq (apt install jq).
2026-04-21 14:04:50 -07:00
vh 8f1a2789a9 scripts/discover-fortigate: validate by IP presence, not error-string match
Bug: FortiOS 7.x ana-gw replied to 'execute dhcp lease-list all' with
"Interface name 'all' does not exist." — my error-pattern grep didn't
include that phrase, so the script thought it got valid data, bailed
out of the retry loop, and handed empty/garbage to the parser, which
produced zero output with no error.

Fix: try the plain `execute dhcp lease-list` form first (works across
versions we've seen), fall back to the `all` variant only if the plain
form returns nothing. Validate acceptance by grepping for an actual
IP-shaped token — the parser needs IPs anyway, so "got real data"
and "has at least one IP" are equivalent conditions.
2026-04-21 13:51:10 -07:00
vh 6f998a83c3 fleet: retire NH3-Firewall (Fortigate 101F @ 10.100.250.1)
Device has been removed from the NH3 site. Drop the homepage card
and the corresponding example in discover-fortigate.sh.

Note left in services.yaml so whoever adds the replacement edge
device knows where the old entry lived.
2026-04-21 13:48:26 -07:00
vh 6a72408c5e scripts/discover-fortigate: handle real FortiOS output + user@host arg
First live run against ana-fw.phasefinal.com surfaced two bugs:

1. Script double-prefixed user@ when the arg already contained it
   (e.g. `admin@10.250.250.1` became `admin@admin@10.250.250.1` →
   auth prompt loop). Accept either "host" or "user@host" and only
   prepend the default user if missing.

2. Parser assumed the wrong output format. Real FortiOS (tested on
   7.x) emits:

     <prompt> # <iface>
       IP          MAC-Address         Hostname    VCI  SSID  AP  SERVER-ID  Expiry
       10.x.x.x    ...
     <next-iface>
       IP          MAC-Address ...

   - Interface names are flush-left (no "Interface:" prefix)
   - First line has the shell prompt embedded before the iface
   - Hostnames don't contain spaces in practice
   - 8 columns, not 4; VCI can contain "udhcp 1.32.1" etc.

   Rewrote awk to detect interfaces via indentation (flush-left = iface,
   indented = header or lease) and extract IP/MAC/Hostname from the
   first three tokens of each lease row.

Verified against a captured sample; emits clean TSV.
2026-04-21 13:46:39 -07:00
vh 83eddc872a scripts/discover-fortigate: stop hiding SSH errors
First real run returned empty and we had no idea why — the script was
silently swallowing stderr via `2>/dev/null`. Remove the suppression
and try both `execute dhcp lease-list all` and the no-arg form, keeping
whichever returns non-error output.

Also emit a clearer diagnostic when both fail, pointing the user at
an interactive SSH to poke at command syntax.
2026-04-21 13:35:53 -07:00
vh 921891b27a scripts: network-discovery tooling for FortiGate + UniFi
Three scripts that surface hosts on the fleet's networks that aren't
already tracked under servers/*/. Goal: spot servers that need management
coverage (inventory, backup, monitoring) without wandering the LAN by
hand.

  discover-fortigate.sh  SSH to a FortiGate admin, run
                         `execute dhcp lease-list all`, emit TSV
                         (IP, MAC, hostname, vdom, source).

                         SSH was picked over the REST API for now
                         because it needs no API-token plumbing. The
                         parser is defensive about FortiOS output
                         format drift (multiple VDOM sections,
                         optional hostname). API variant can replace
                         it when the extra robustness is worth the
                         token setup.

  discover-unifi.sh      Cookie-auth REST call against a UniFi
                         Controller. Tries /api/auth/login (UniFi OS
                         / UDM / Cloud Key Gen2+) first; falls back
                         to legacy /api/login for self-hosted
                         controllers. Output is the same TSV shape
                         as the FortiGate script so the two mix.

                         Needs UNIFI_USER / UNIFI_PASS env and jq.

  discover-gaps.sh       Consumes one or more TSVs from the sources
                         above. Builds the set of managed IPs from
                         servers/*/ssh-target (plus a grep of README
                         files for documented IPs) and prints any
                         discovered IPs not in that set.

                         Exit code is 1 if gaps found — suitable for
                         cron alerting.

Common pipeline:
  scripts/discover-fortigate.sh ana-fw.phasefinal.com > /tmp/ana.tsv
  scripts/discover-fortigate.sh nh3-gw.phasefinal.com > /tmp/nh3.tsv
  UNIFI_USER=admin UNIFI_PASS=… scripts/discover-unifi.sh esh-uc.esteban.net > /tmp/esh.tsv
  scripts/discover-gaps.sh /tmp/ana.tsv /tmp/nh3.tsv /tmp/esh.tsv

First-time use probably needs:
  - SSH access configured to each FortiGate (admin login, key preferred)
  - UniFi user with read access (the built-in API read-only role works)
  - `jq` installed on the runner (for UniFi script)
2026-04-21 11:51:00 -07:00
vh 1431768596 scripts: refresh-proxmox-info.sh wrapper for PVE snapshots
Parallel to refresh-server-info.sh but pipes proxmox_inspect.sh and writes
to servers/<host>/proxmox-details.txt. Same discovery / ssh-target /
validate-only / dry-run behavior.

Fleet-wide `all` matches dir names containing `-pve` (covers *-pve and
*-pve-* so esh-pve-nas is included alongside pfi-pve / nh3-pve / esh-pve).
Explicit names are never filtered — useful for one-off PVE hosts with
non-matching names.

Validation checks the captured snapshot for: truncation, missing PVE
version line, non-Proxmox target, and surfaces backup-coverage "NO"
verdict counts so gaps show up in the validate-only output.

Initial fleet snapshot refreshed.
2026-04-20 23:01:48 -07:00
vh 852896120a scripts: proxmox_inspect.sh for VM/LXC/storage/backup discovery
Read-only companion to server_inspect.sh. Runs over ssh via stdin the
same way:

  ssh root@pfi-pve 'bash -s' < scripts/proxmox_inspect.sh \\
      > servers/pfi-pve/proxmox-details.txt

Captures what the generic inspect misses on PVE nodes:

- Cluster + node resources (pvesh get /cluster/resources)
- VM list + per-vmid config highlights (qm list, qm config)
- LXC list + per-ctid config highlights (pct list, pct config)
- Storage pools (pvesm status, zpool, storage.cfg, NFS mounts)
- Backup jobs table parsed from /etc/pve/jobs.cfg (no jq needed)
- Per-guest backup coverage verdict: YES/NO with reason
- Most-recent actual backup per VMID on each local dump storage
- PBS targets, replication jobs, listening ports, subscription status

Coverage analysis surfaces the real gap: on pfi-pve, VMIDs 106, 107,
109, 112, 113 have no scheduled backup; VMID 106 (PFI-Tailscale) has
not been backed up since 2024-05-31. nh3-pve uses "all" job and covers
everything. esh-pve + esh-pve-nas share the tank-vmbu dump storage via
NFS; coverage splits cleanly across the two nodes.

First snapshots committed for each PVE host.
2026-04-20 22:14:57 -07:00
vh 8c15ec2d5f Add the four Proxmox hypervisors to the inventory
pfi-pve (Anaheim), nh3-pve (NH), esh-pve + esh-pve-nas (ESH home lab) now
have servers/<host>/ entries with ssh-target files, README stubs, and
first snapshots. Register all four via add-host.sh + ssh-copy-id; SSH
auth is root@<name> with key-based access.

Fix a resolver bug in the three workspace scripts: previously, when a
hostname resolved via /etc/hosts, resolve_target returned the bare name
and ssh dropped the user prefix stored in ssh-target (connecting as the
wrong user). Now ssh-target wins whenever it's present, since it can
carry user@ or port info that /etc/hosts + ssh_config cannot. Falls back
to the dir name only when no ssh-target file exists.

CLAUDE.md and README.md split the fleet table into Docker hosts vs
hypervisors with an explicit placement rule: don't deploy Docker stacks
on PVE hosts; capture host-level detail with server_inspect.sh, use
Proxmox-native tooling (qm list, pvesh, zpool) for VM/storage audits.
2026-04-20 14:58:17 -07:00
vh e376d0aec9 Initial commit: PFI fleet inventory, stacks, tooling, and backup pipeline
Captures the full workspace state built up to this point:

  - CLAUDE.md + README.md describing conventions and the four-host fleet
    (ana-ml2, ana-docker, nh3-docker, esh-docker-vm).
  - Per-host notes under servers/<host>/ with ssh-target fallback files
    and latest system-details snapshots (two in-compose credential leaks
    scrubbed; the upstream compose files still need to move those to .env).
  - scripts/: server_inspect.sh (read-only remote diagnostic),
    refresh-server-info.sh (dir-driven discovery + snapshot capture with
    validation warnings), add-host.sh, sync-stacks.sh (pull
    compose/conf trees), deploy-stack.sh (push with per-file diff + prompt).
  - stacks/: canonical compose for backrest, beszel, dozzle, llama-swap,
    rest-server-ana, rest-server-nh3, vllm-qwen3, plus the retired
    infinity reference. All use the .env-driven + traefik-net + homepage
    label pattern.
  - configs/restic/ana-docker/: first resticprofile config + pre-backup
    hook (Synapse pg_dump, Seafile mysqldump, Vaultwarden SQLite); templates
    for the other three hosts to come.
  - docs/pfi/: general infrastructure reference carried over.
  - .gitignore excludes .env, stacks-mirror/, and assorted secret/state
    filenames to prevent re-leaks on later commits.
2026-04-20 14:29:48 -07:00