phase: 28_gateway_agent_proxy
**Phase 28 final pass — complete, 100 % green.** All five tasks were already `complete/`; this pass verified the criteria, re-ran the live half, and did the docs pass. - Gate `bash .agents/validate.sh` **rc=0**: `1650 passed, 15 skipped`, coverage **98.97 %** (floor 94), `ruff` clean, `pyright` **0 errors**, `node --check` clean, hygiene clean (`/tmp/validate-done.log`). - Live: `pytest -m live --live` **11 passed**; `./scripts/dev-server.sh status` nine sources `ok`; mTLS re-read of `gateway` → agent **1.2.0**, all three sections `available: true`, 6/6 frontends routed, 11 nginx servers + `$targetBackend`, **19** listeners, no core errors (`data/logs/28-finalpass-live.out`); installed agent file byte-identical to this tree (`b0e9ebf1b718…`). - Criteria: 1 ✅ pytest/coverage · 2 ✅ three sections over a captured tree, `false`/`null` + one `errors` line each elsewhere with `cpu`/`mem`/`disk_io`/`system` intact · 3 ✅ `AGENT_VERSION` 1.2.0 + SCHEMA names all three · 4 ✅ six fixtures verbatim, capture refuses a bad read, 116 socket address columns hold no globally routable endpoint · 5 ✅ gateway runs 1.2.0 and answers over mTLS (transcript `data/logs/28-task05-rollout.md`, `28-finalpass-live.out`) · 6 ✅ validate · 7 ✅ additive: HEAD's 1.1.0 and this 1.2.0 `collect()`ed over one `--root` are key-for-key equal plus the three new sections, `--no-proxy` equal exactly (`data/logs/verify-28-final.py` → 41 PASS, `28-final-verify.out`). - Docs fixed (README/AGENTS/Containerfile, all re-green): README's opening said "Four dashboards" above a five-row table and said the new sections are "stored in the snapshot" (they are in the **agent's** document — no `/api` payload, no `sample` row, nothing charts them until phase 29); `Containerfile`'s header still said "three dashboards"; AGENTS's coverage figure (98.94 → **98.97 %**), invariant 6 gained the captured-socket-table identifier rule, the agent bullet gained "phase 28 stops at the wire", and the fleet record names the stash-and-compare control plus the volatile listener count. Full record: `data/logs/NOTES-28-final.md`. - Deviations: none functional; no git index/history touched, phase files untouched (`PLAN.md` and `complete/` unchanged). `uv pip list --outdated` still lists 4 packages (fastapi 0.153→0.161 etc.) — advisory only, dependencies untouched by this phase, left for a deliberate pass. - Next pending phase: `.agents/phases/todo/29_gateway_dashboard_page/` (the page that consumes these three sections).
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
# Homelab Dashboards
|
||||
|
||||
Four dashboards, one domain, one process, one SQLite file:
|
||||
Four dashboards and the overview page that points at them — one domain, one process, one SQLite
|
||||
file:
|
||||
|
||||
| Page | What it answers | Source |
|
||||
| ----------- | ------------------------------------------------------------------------------- | -------------------------------------------------- |
|
||||
@@ -90,7 +91,8 @@ that exact line. upsd is dialled at
|
||||
## The host agent
|
||||
|
||||
`agent/host_agent.py` is one file, **stdlib only**, Python 3.10+. It reads `/proc`,
|
||||
`/sys/block`, `/sys/class/drm` and `statvfs` and answers `GET /snapshot` (everything, one
|
||||
`/sys/block`, `/sys/class/drm` and `statvfs` — and, on the host that fronts the fleet, the
|
||||
haproxy/nginx configs and `/proc/net/tcp{,6}` — and answers `GET /snapshot` (everything, one
|
||||
JSON document; `?top=N` trims the process lists), `GET /info` (slow-changing identity: OS,
|
||||
kernel, CPU model, GPU list), `GET /schema` (the schema version plus this document) and
|
||||
`GET /health` (the one route that needs no credential) as JSON, with a plain HTML status
|
||||
@@ -147,6 +149,81 @@ one installed before phase 09 has no SMART pass to run. Opt out silently with `-
|
||||
The drive truth this is all developed against lives in `tests/fixtures/smartctl_driveripper_*.txt`:
|
||||
16 real drives, recorded with serials scrubbed, each one parsed by a test.
|
||||
|
||||
The `haproxy` section is the same kind of best-effort add-on, for the host that fronts the fleet.
|
||||
The agent reads `/etc/haproxy/haproxy.cfg` (`--haproxy-config` names another path) and reports
|
||||
what the config says: every `frontend` block with its name, each `bind` address:port exactly as
|
||||
written (`:::22` is the address `::` on port `22`) and the backend names it routes to
|
||||
(`default_backend`, plus every named `use_backend`), and every `backend` block with each `server`'s
|
||||
name, target and port — `port: null` when the line names none, never `0`. It interprets nothing:
|
||||
the routing between the two lists is the **names**, so joining them is the page's job (the gateway
|
||||
page is phase 29) and the agent never evaluates a rule. `available` answers three questions the way `smartctl_available` does —
|
||||
`true` read and parsed, `false` no config at that path (this host runs no haproxy), `null` a file
|
||||
is there but could not be read or understood — and each non-`true` answer costs exactly **one**
|
||||
`errors` line for the pass. Like the SMART lines, those lines never make the *system* page amber:
|
||||
a host that never installed haproxy is unconfigured, not broken, and the page that owns the add-on
|
||||
reads the sentence out of `agent.errors`. A failing config read cannot fail `/snapshot` or
|
||||
`/health`, and `--no-proxy` drops the section and the line both. The parser is pinned to
|
||||
`tests/fixtures/gateway_haproxy.cfg`, the verbatim capture from gateway. Neither path is an env
|
||||
var and `deploy/install-agent.sh` passes neither flag — the unit it writes runs on the defaults —
|
||||
so a box whose configs live elsewhere means adding the flag to `$UNIT` after installing.
|
||||
|
||||
The `nginx` section is that add-on's other half, and it reads more than one file: the main config
|
||||
(`/etc/nginx/nginx.conf`, `--nginx-config` names another) **plus the files its own `include` lines
|
||||
name**, one level deep, because that is where nginx keeps its vhosts
|
||||
(`include /etc/nginx/conf.d/*.conf`) and its SNI routing (`/etc/nginx/stream.d/*.conf`). It
|
||||
reports every `server` block with its `listen` address:port pairs (`[::]:8443` is the address `::`
|
||||
on port `8443`; a bare `listen 80` has no address, and the `ssl`/`proxy_protocol` flags are not
|
||||
this section's business), its `server_name` list (the catch-all `_` included verbatim — "any
|
||||
domain" is what the page says about it), and every `proxy_pass`; every `upstream` block with its
|
||||
`server` targets; and every `map` block with its keys. A `proxy_pass $var` is served with the
|
||||
variable's name and `target: null`: nginx resolves that per connection from the `map` that defines
|
||||
it, so the agent reports both facts **unmerged** and leaves the joining to a page — a merged target
|
||||
would be a number the wire never published. `files` names the main config and the included
|
||||
documents actually merged, and the include pass is bounded (8 files / 1 MB): what it could not
|
||||
take is said in one `errors` line naming the count, and the section still reads `true`. The same
|
||||
three-way `available`, the same one line per arm, the same rule that a missing, unreadable or
|
||||
unparseable config degrades only this section — and `--no-proxy` drops the whole family with it,
|
||||
in silence.
|
||||
|
||||
The `sockets` section is that add-on's live half: what is actually open and carrying traffic, as
|
||||
opposed to what a config claims. The agent reads `/proc/net/tcp` and `/proc/net/tcp6` and keeps
|
||||
only the two states a gateway page can draw pipes from — kernel state `0A` LISTEN into `listens`
|
||||
(`{family, addr, port}`) and `01` ESTABLISHED into `established` (`{family, local, remote}`, each
|
||||
end an `{addr, port}`). Every other state is a socket in transit: a `TIME_WAIT` row is a
|
||||
connection that stopped being true before the page painted, and a proxy accumulates thousands of
|
||||
them, so they are skipped without a word. Each table is read through **at most 4096 lines**
|
||||
(`/proc`'s own header among them, so 4095 sockets per table); a longer one reports the rows it read
|
||||
and costs one `errors` line naming the bound, with `available` still `true`. `addr` is the address
|
||||
itself, unrolled from `/proc`'s per-32-bit-word little-endian spelling — `0.0.0.0` and `::` keep
|
||||
the literal wildcard form, because "open on every address" is the fact the inbound join keys on —
|
||||
and a word that is not an address is `null`, never a guess. The tables are read independently: a
|
||||
kernel without IPv6 has no `tcp6` file, which costs nothing and no line, while a table that is
|
||||
there and denied costs one. `available` is `true` when either table answered and `null` when
|
||||
neither did, with one line saying why; there is no `false`, because a table that did not answer is
|
||||
"no answer" and not "this host has no sockets". Nothing here is joined to a config: a socket does
|
||||
not know its domain or its backend, so phase 29's gateway page will be what joins these ports to
|
||||
the `haproxy` and `nginx` sections — where a socket no frontend claims stays visible as one. Until
|
||||
that page exists the three sections live only in the agent's own `/snapshot` document: no `/api`
|
||||
payload grows them, no `sample` row is stored for them, and nothing charts them.
|
||||
|
||||
All three parsers are pinned to captures rather than to descriptions, and
|
||||
`./scripts/capture-gateway-config.sh` re-records them over the `GATEWAY_SSH` alias (default
|
||||
`gateway-ops`): `/etc/haproxy/haproxy.cfg`, `/etc/nginx/nginx.conf` plus every document its own
|
||||
glob `include` lines name (`gateway_nginx_conf_d_*.conf`, `gateway_nginx_stream_d_*.conf`), and
|
||||
`/proc/net/tcp{,6}`. No sudo — those paths are world-readable, which is exactly what the agent
|
||||
reads as itself — and a read that fails, or answers empty, leaves the existing fixture alone and
|
||||
exits 1, so a laptop with no route to the gateway cannot turn six captures into six empty files.
|
||||
The configs are not rewritten (their domains are the ingress map, and key material appears only
|
||||
as the paths nginx is told to read); the socket tables lose only their identifiers, in line with
|
||||
the rule that scrubs a drive's serial: a globally routable address becomes a documentation one
|
||||
(RFC 5737 / RFC 3849), because a global IPv6 on Linux is EUI-64 and an inbound visitor's address
|
||||
is not ours to publish. Every port, state, column and width in those tables is verbatim, and
|
||||
those files are volatile by nature — a proxy in service has a different connection set every
|
||||
second — so the tests assert shape and the ports the configs claim, never a particular row.
|
||||
`AGENT_VERSION` is **1.2.0**, the version that ships all three sections; a host still running
|
||||
1.1.0 answers `GET /schema` with a block that names none of them, and its snapshot simply has no
|
||||
such keys.
|
||||
|
||||
`deploy/install-agent.sh --fetch-bundle <url> --cert-name <name> --session <token>` is the same
|
||||
TLS install with the download done inside the registration run: it asks
|
||||
`GET <url>/api/certs/<name>/bundle`, writes the answer to a temp file, and hands it to the
|
||||
@@ -338,6 +415,20 @@ for a bundle install, so step 3 is a copy rather than a guess. A host left on `h
|
||||
working over its bearer token — the rollout is per host, and one revoked or expired certificate
|
||||
never blinks the others.
|
||||
|
||||
**Upgrading an already-registered host to a newer agent is one file, not a rollout.** Re-stage
|
||||
`agent/host_agent.py` into `/home/ops/agent-install/agent/` (the snapshot-first rule applies: a path
|
||||
grant is only as current as the file it names, so copy what you are replacing to
|
||||
`host_agent.py-<date>-backup` first — a name the sudo grant does not name, so sudo will not run it),
|
||||
run the granted path with **the unit's own arguments and nothing else**
|
||||
(`sudo /home/ops/agent-install/deploy/install-agent.sh --bind <addr> --port 9120`), and restart.
|
||||
No `--cert-bundle`, no `--fetch-bundle`, no `--force`: the bundle already on disk is what the unit
|
||||
render reads — the flag only replaces the file — so the render equals the installed unit, the re-run
|
||||
guard prints nothing, and `/etc/dashboards-agent.certs` and the token file keep their bytes. Then
|
||||
the same two checks the registration uses: the CA-only `--status` probe (with `--bind`) answers
|
||||
`{"ok": true, …, "version": "1.2.0"}` on `/health`, and the dashboard's own `https://` dial answers
|
||||
whatever the new version added — `agent_version` plus the `haproxy` / `nginx` / `sockets` sections
|
||||
for a 1.2.0 agent — beside the sections it always had.
|
||||
|
||||
Two things the first full rollout learned, both cheap to hit and expensive to guess at:
|
||||
|
||||
* **A CA row older than the identifier fix must be rotated before any bundle is installed.** A
|
||||
@@ -953,6 +1044,11 @@ uv run ruff check src tests agent && uv run pyright # lint + types
|
||||
SMART_HOSTS="host-a-ops host-b-ops" ./scripts/capture-fixtures.sh
|
||||
# ... and smartctl per physical disk: plain smartctl,
|
||||
# `sudo -n` if that is denied; scrubs serials/WWNs
|
||||
./scripts/capture-gateway-config.sh # ... and the gateway's proxy tree + socket tables:
|
||||
# haproxy.cfg, nginx.conf + its glob includes,
|
||||
# /proc/net/tcp{,6} over $GATEWAY_SSH (gateway-ops).
|
||||
# World-readable paths only; a failed read never
|
||||
# replaces a good fixture
|
||||
bash .agents/validate.sh # all of the above + `node --check` on every page
|
||||
# script + repo hygiene, with the 94% coverage floor
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user