phase: 32_gateway_resolved_target_liveness
Fixture and live stream.conf are byte-identical (modulo header comment). Let me test the parser directly:
This commit is contained in:
@@ -10,7 +10,7 @@ file:
|
||||
| `/disks` | Are any of my **physical** disks failing, and which one — health, temperature, power-on hours, wear, sector alarms | the same agents, running `smartctl -i -A -H` per whole drive (never a partition or a mount); a host whose agent has no `smartctl` is not a host on this page |
|
||||
| `/llm` | Tokens/sec, active sessions, queue depth, KV-cache pressure, latency percentiles | llama.cpp-family (gufo) `/metrics` + `/slots`, vLLM `/metrics` — read-only |
|
||||
| `/ups` | Battery health, runtime left, power events, and battery-test buttons | NUT `upsd`, on whatever port it really listens on (its own wire-protocol client) |
|
||||
| `/gateway` | Which domains are coming in on which port, and where each one goes — **two connection tables, one per proxy config file** (`nginx`, then `haproxy`): a trunk per bound port with its configured routes branching off it, each line reading the config's own **inbound** word (`server_name`, `map` key, `backend` name) on line 1 over the counterpart it routes to on line 2 (`→ host:port`), plus its live connection count — and one filter box above the map that hides the routes whose own labels do not contain what you typed, asking nothing of the server | the agent on **one** host, `GATEWAY_HOST` (default `gateway`), reading its `haproxy.cfg` + nginx tree and `/proc/net/tcp{,6}` — so it needs **agent 1.2.0**, and the whole page is *unconfigured* (never red) unless `GATEWAY_HOST` names a `HOST_TARGETS` entry |
|
||||
| `/gateway` | Which domains are coming in on which port, and where each one goes — **two connection tables, one per proxy config file** (`nginx`, then `haproxy`): a trunk per bound port with its configured routes branching off it, each line reading the config's own **inbound** word (`server_name`, `map` key, `backend` name) on line 1 over the counterpart it routes to on line 2 (`→ host:port`), plus its live connection count — and one filter box above the map that hides the routes whose own labels do not contain what you typed, asking nothing of the server | the agent on **one** host, `GATEWAY_HOST` (default `gateway`), reading its `haproxy.cfg` + nginx tree and `/proc/net/tcp{,6}` — so it needs **agent 1.2.0**, plus **1.3.0**'s `resolver` section to put a number on a route whose target the config writes as a *name* (without it those lines are drawn and dashed), and the whole page is *unconfigured* (never red) unless `GATEWAY_HOST` names a `HOST_TARGETS` entry |
|
||||
|
||||
No Prometheus, no Grafana, no CDN, no build step. Nothing is fetched from the internet at
|
||||
runtime; every asset is served by this app.
|
||||
@@ -212,23 +212,88 @@ an address: the name-keyed ones were a chart of the port-only guess the join no
|
||||
would be permanently empty). Nothing in `/api/system/snapshot` grows a proxy or socket field, and no
|
||||
`sample` row is written for a config.
|
||||
|
||||
All three parsers are pinned to captures rather than to descriptions, and
|
||||
The `resolver` section is the fourth and the only one that leaves the host. The two configs above
|
||||
name their targets two ways — an address (`proxy_pass http://127.0.0.1:8081`) and a name
|
||||
(`server mc1 minecraft.reeselink.com:25565`) — and the kernel's socket table speaks addresses, so a
|
||||
name is the half a page could never count. The agent therefore takes every `(name, port)` the two
|
||||
**parsed** sections write as a target (haproxy `server` lines, then nginx `upstream` members,
|
||||
`proxy_pass` targets and `map` entry targets, deduplicated in config order) and asks libc what each
|
||||
one is: `getaddrinfo` over `SOCK_STREAM`, published as `via: "libc"` because that is the resolution
|
||||
haproxy's own libc path performs **on this box** — the box that opens the connections. The nginx
|
||||
config's own `resolver` directive travels beside it verbatim as `config_directive` (`10.1.0.1`
|
||||
here, read out of `stream.d/stream.conf`), so a divergence between the two vantages is something an
|
||||
operator can see rather than something this file guesses; the agent does not query that resolver
|
||||
directly, and the dashboard never resolves anything at all (`sources/gateway.py` has a test that
|
||||
fails if it ever calls a resolver). An address literal is reported `status: "literal"` with its
|
||||
address and **no** lookup, and a name that does not answer right now is `nxdomain` or `error` with
|
||||
`addresses: []` — the route keeps its row and carries no count, never a `0`.
|
||||
|
||||
That pass is bounded and cached exactly the way the SMART one is, for the SMART reason: at most
|
||||
**64** lookups inside **3 s** of wall clock, cached **300 s**, the first pass inline so a freshly
|
||||
started agent answers with real addresses and every later expiry refreshed on a single background
|
||||
thread while the request is served the previous result. `getaddrinfo` takes no timeout argument, so
|
||||
stopping between lookups is the only defence a black-holing resolver leaves — and the cost of not
|
||||
stopping is the *system* page's poll timeout, spent on an add-on that page never draws. Each entry
|
||||
carries the time **it** was answered, so a cache-served entry cannot pretend to be current; the
|
||||
budget and the name bound each report themselves in one `errors` line and leave `available` at
|
||||
`true`. `available` is the family's three-way answer (`false` means there was nothing to resolve
|
||||
because no proxy config was read, and its one line says **not found** or **unreadable** according to
|
||||
which of the two it was), and
|
||||
`--no-resolve` removes the section in silence the way `--no-smart` removes SMART — while `--no-proxy`
|
||||
removes it too, because a box that collects no proxy has no declared names to look up.
|
||||
|
||||
All four 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.
|
||||
glob `include` lines name (`gateway_nginx_conf_d_*.conf`, `gateway_nginx_stream_d_*.conf`),
|
||||
`/proc/net/tcp{,6}`, and `tests/fixtures/gateway_resolver.json` — the `resolver` section itself, so
|
||||
the name→address join is testable with no network. No sudo and no DNS client: those paths are
|
||||
world-readable, which is exactly what the agent reads as itself, and the resolver is recorded with
|
||||
`getent ahostsv4` / `getent ahostsv6`, which is libc on the box that dials (the same vantage
|
||||
`via: "libc"` claims). 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 seven captures into seven empty files.
|
||||
|
||||
The resolver document is **derived, never listed**: the script feeds its own freshly captured
|
||||
configs through `agent.host_agent`'s parsers and `target_names()`, so it asks about exactly the
|
||||
targets the agent will ask about, an address literal costs no remote read, and a route added to the
|
||||
gateway moves the fixture with it. `getent` answering `rc 2` is a fact — that name has no record and
|
||||
becomes `status: "nxdomain"`, kept beside the names that did answer — while a dead transport, a
|
||||
missing `getent`, or **not one name answering** is a broken vantage: the run refuses, because a
|
||||
document of nothing but `nxdomain` would make every route on `/gateway` a permanent dash and every
|
||||
join test a test of nothing. Re-capturing a gateway nobody changed is a no-op (fixed `ts`,
|
||||
deduplicated, sorted with the agent's own `sort_resolver_addresses`), so a diff in that file means
|
||||
the ingress changed.
|
||||
|
||||
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.
|
||||
as the paths nginx is told to read); the socket tables and the resolver's answers 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.3.0**, the version that adds the `resolver` section on top of the three
|
||||
phase 28 shipped; `GET /schema` names all four, each with its `available` flag, and `--no-proxy`
|
||||
and `--no-resolve` are listed there too. A host still running 1.2.0 answers with a snapshot that
|
||||
simply has no `resolver` key — which is why the version spread costs nothing: a section an agent
|
||||
did not collect is absence, and the gateway page counts the address-written routes it has and draws
|
||||
the name-written ones with a dash.
|
||||
|
||||
**The dashboard reads that section and never produces one.** `sources/gateway.py` indexes the
|
||||
entries by `(name, port)`, stamps each route's `out` with `resolved` / `via` / `resolved_ts` — the
|
||||
address the gateway dialled, the agent's own string for how it got it, and the moment that **entry**
|
||||
was answered — and lets the socket join reach a name-written route through it. Which is the whole
|
||||
of phase 32: the outbound endpoint of a `server` line, a `proxy_pass` or a `map` entry is now an
|
||||
address the box that opens the connection published, so the `gateway.out_conns@<domain>:<port>`
|
||||
gauges phase 30 task 02 stopped writing are written again, under the config's own spelling, with a
|
||||
figure that belongs to the domain instead of to whatever else was listening on its port. Four rules
|
||||
keep the join honest, each pinned in `tests/test_gateway_source.py`: a name nobody resolved keeps
|
||||
`resolved: null`, `connections: null`, `metric: null` and **no stored sample** (never `0`, never an
|
||||
empty list); matching is by normalised address, so `::ffff:10.1.2.64` in `/proc` and `10.1.2.64`
|
||||
from `getaddrinfo` are one endpoint while a zero-padded `010.1.2.64` — which is no one's address
|
||||
notation — matches nothing; two names resolving to one address, or two route lines reaching it, are
|
||||
**one** count held by every line that reaches it; and the module still never calls a resolver, a spy
|
||||
pin covering the parse, the gauge name and the capture alike. An `address: true` route claims none
|
||||
of the three provenance fields, because the config wrote that address and no lookup happened.
|
||||
|
||||
`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
|
||||
@@ -431,9 +496,12 @@ No `--cert-bundle`, no `--fetch-bundle`, no `--force`: the bundle already on dis
|
||||
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
|
||||
`{"ok": true, …, "version": "1.3.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.
|
||||
(1.2.0) and the `resolver` section (1.3.0) — beside the sections it always had. That is exactly how
|
||||
`gateway` moved 1.1.0 → 1.2.0 (phase 28) and 1.2.0 → **1.3.0** (phase 32, 2026-10-25: the same unit
|
||||
sha256 before and after, the bundle untouched, the guard silent, one file and one restart), and the
|
||||
`/gateway` page counted **33 of 33** routes afterwards where it had counted 2 before.
|
||||
|
||||
Two things the first full rollout learned, both cheap to hit and expensive to guess at:
|
||||
|
||||
@@ -887,8 +955,10 @@ certs.py ─ root CA + client cert, minted once at startup (unreadable row → n
|
||||
`connections` graph (phase 30: one row per route the two config files declare, each naming its
|
||||
inbound port, its outbound endpoint, and the config's own selector for it — a haproxy backend
|
||||
name, an nginx `server_name`, a stream `map` key) with its **live count** — the socket table's
|
||||
ESTABLISHED rows, inbound by the socket's local port and outbound only where a remote
|
||||
`(address, port)` equals a target the config wrote as an address. Each row carries the `metric` it
|
||||
ESTABLISHED rows, inbound by the socket's local port and outbound where a remote
|
||||
`(address, port)` equals an endpoint the join can name — a target the config wrote as an address, or
|
||||
(phase 32 task 03) one of the addresses the gateway's own `resolver` section published for the name
|
||||
it wrote. Each row carries the `metric` it
|
||||
is stored under, and `shared: true` when more than one route reaches its endpoint (one count, shown
|
||||
twice, never split). `supported`/`sections`/`why` cover the degradation arms. `warnings` (phase 30
|
||||
task 03) is the list of the two config files **not connecting to themselves** — seven kinds, each
|
||||
@@ -903,16 +973,25 @@ certs.py ─ root CA + client cert, minted once at startup (unreadable row → n
|
||||
or absent section warns about nothing — a host that cannot be read has not been found guilty of
|
||||
anything — and a `warnings: []` is therefore the truth, not a shrug. Non-zero, `/` gets **one**
|
||||
amber row (`N proxy config problems to address — <first message>`) and this page gets the list. **Nothing reconciles
|
||||
to `sockets.established` and that is the design**: a connection to a target the config wrote as a
|
||||
name (most of this fleet's fan-out) or on a port no config names is ignored — not counted, not
|
||||
totalled, not warned about — so a pipe count means "this route has N live connections" and nothing
|
||||
more. `GATEWAY_HOST` naming no `HOST_TARGETS` entry is
|
||||
to `sockets.established` and that is the design**: a connection to a target nothing can name (a
|
||||
name with no resolver entry behind it) or a socket on a port no config names is ignored — not
|
||||
counted, not totalled, not warned about — so a pipe count means "this route has N live connections"
|
||||
and nothing more. The payload also carries the agent's `resolver` block (`available`, `via`, and
|
||||
the projected `entries`: name, port, status, addresses, ts — nothing an operator has not reviewed)
|
||||
beside the `sections` pair that already says which halves answered, so a dash on one line is
|
||||
explainable from the document the page already holds. On the recorded
|
||||
ingress that section places every declared route, which makes the dash the exception rather than
|
||||
the normal look of this page, and its meaning is *this name has no record* — and on the live fleet
|
||||
it is the exception too: the gateway has run agent **1.3.0** since 2026-10-25, `resolver.available`
|
||||
answers `true` with one entry per declared target, and that host's page counted **33 of 33** routes
|
||||
with no dash at all. `GATEWAY_HOST` naming no `HOST_TARGETS` entry is
|
||||
a `404 {"configured": false}` answered before any client is built (both arms spy-pinned), a host
|
||||
that has never answered is the `unreachable` stub whose every count is `null`, and a 1.1.0 agent
|
||||
is `supported: false` plus its one `why` sentence — three arms, no red among them (invariant 3).
|
||||
`/history` serves the `gateway.in_conns@<port>` and `gateway.out_conns@<host>:<port>` gauges —
|
||||
the port in the outbound name because two services on one host are two facts, and only ever for an
|
||||
address endpoint — with `hosts=`
|
||||
endpoint whose address is known: one the config wrote, or (since phase 32 task 03) one the gateway
|
||||
resolved the name to — with `hosts=`
|
||||
defaulting to the ingress alone, unknown names answering as empty series, and no dial either way
|
||||
because history reads only SQLite.
|
||||
* `web/` — six static dashboard pages (`/`, `/system`, `/disks`, `/llm`, `/ups`, `/gateway`) plus
|
||||
@@ -1108,9 +1187,10 @@ more. `GATEWAY_HOST` naming no `HOST_TARGETS` entry is
|
||||
Above the map, one **filter box** finds a domain among 33 routes (`stackexpected`, `brain`, `4443`,
|
||||
`minecraft`) because the rows are in config order and the question is always about one name. It hides
|
||||
what the payload already sent and asks for **nothing**: no request, no re-derivation, no new route.
|
||||
It matches the strings the config wrote — the inbound word(s), the endpoint, the port, the haproxy
|
||||
`server` name, and a trunk's own domains and client addresses — case-insensitively, so a label CSS
|
||||
ellipsised still matches its whole text. A trunk that matches keeps its **whole fan-out**: typing
|
||||
It matches the strings the payload carries — the inbound word(s), the endpoint, the port, the haproxy
|
||||
`server` name, the **addresses the gateway resolved** for a name target, and a trunk's own domains
|
||||
and client addresses — case-insensitively, so a label CSS ellipsised still matches its whole text and
|
||||
typing `10.1.2.64` finds the route whose target that address belongs to. A trunk that matches keeps its **whole fan-out**: typing
|
||||
`4443` asks for the port, and every route hanging off it is the answer. `Escape` clears it, a chip
|
||||
beside the field counts `N of M routes`, and a query that matches nothing leaves both panels standing
|
||||
with `no route matches "…"` in each. What it never touches is the header's four tallies, the warnings
|
||||
@@ -1120,12 +1200,17 @@ more. `GATEWAY_HOST` naming no `HOST_TARGETS` entry is
|
||||
than inventing a zone no proxy declared.
|
||||
**Nothing on the page
|
||||
is coloured by a verdict** — a busy ingress is the job, so `severity.py` rates no `gateway.*` metric
|
||||
and no number gets a green/amber/red tint. A line to an endpoint the config wrote as a **name**
|
||||
prints `—`, never `0`: counting it would need a DNS lookup and the join never asks one, and the row's
|
||||
tooltip says which of the two facts it is looking at. Expect that on most of this ingress's routes —
|
||||
31 of its 33 are written as domains — and read it as *drawn, not counted* rather than as a fault; the
|
||||
footer says the same thing once, in plain terms, because a dash per row needs one explanation and not
|
||||
thirty-two. The header's four tallies are `ports`, `connections`, `live` and
|
||||
and no number gets a green/amber/red tint. A line prints `—`, never `0`, and its tooltip and its
|
||||
accessible name say **which** unknown it is: *the gateway could not resolve this name in this
|
||||
reading* when nothing is known for the endpoint, or *the socket table did not answer this reading*
|
||||
when an address is known and nothing was measured — two different things to go and fix. A resolved
|
||||
endpoint with nothing open is neither of them: it prints `0`, because that is a measurement. A
|
||||
counted line carries its provenance on hover (`… · address from libc, resolved 3m`), and a line the
|
||||
config wrote as an address claims no resolution at all. A dash on a domain-named route now means the
|
||||
gateway had **no record** for that name in this reading (`nxdomain`/`error`, or no `resolver` section
|
||||
at all on a pre-1.3.0 agent) rather than "this page cannot count names": the ingress here runs
|
||||
**1.3.0**, so all 33 of its declared routes carry a number. The
|
||||
footer says the same thing once, in plain terms, and points at the hover. The header's four tallies are `ports`, `connections`, `live` and
|
||||
`warnings`, every one of them the payload's own figure. A `warnings` count above zero raises a panel
|
||||
above the map with the config-integrity rows (`a backend no frontend routes to`, a `proxy_pass` with
|
||||
no `upstream` behind it, an SNI entry that reaches no address) in the overview's alert vocabulary;
|
||||
@@ -1142,7 +1227,8 @@ more. `GATEWAY_HOST` naming no `HOST_TARGETS` entry is
|
||||
documented here as a thing you can have: name-based liveness needs the agent to report resolved
|
||||
targets (a future agent version), and switching a route needs a second gated write path in front of
|
||||
the proxy configs, which is a change to this app's read-only promise rather than a feature. What runs
|
||||
today is **agent 1.2.0**, no name resolution, and no switching.
|
||||
today is **agent 1.3.0** — name resolution on the gateway, counts on every declared route, and no
|
||||
switching.
|
||||
The four event feeds (`/system`, `/disks`, `/llm`, `/ups`) are a **paged window** over the `event`
|
||||
table rather than a printout of it: the list is a bounded box that scrolls, 50 events to a
|
||||
page, with `1–50 of 200` between `‹ prev` and `next ›` (disabled at the ends) underneath.
|
||||
@@ -1326,7 +1412,7 @@ plus its included documents, and the two `/proc/net/tcp{,6}` captures — run th
|
||||
`host_agent` parsers, so the test compares the join against the agent's own document rather than
|
||||
against a copy of its field names. The assertions are the join's two rules and the limits around
|
||||
them: the graph carries exactly the ports, `server_name`s and targets those documents declare (all
|
||||
32 routes on this capture, as a **set**, so a missing or invented route fails by name); a socket is
|
||||
33 routes on this capture, as a **set**, so a missing or invented route fails by name); a socket is
|
||||
inbound by its local port and outbound only when its remote `(address, port)` equals a target the
|
||||
config wrote as an address; **every other socket is ignored** — `established`, `live_in` and
|
||||
`live_out` are all printed and deliberately never reconciled, because a connection to a name, or on
|
||||
@@ -1337,6 +1423,13 @@ stands for "not counted"; and **no join ever asks a resolver** — `socket.getad
|
||||
fails the test, because deciding "already an address, or a name?" is the only question that module
|
||||
may ask, and a DNS lookup in the poll loop would be rule 2 with a round trip attached.
|
||||
|
||||
The seventh capture, `gateway_resolver.json`, is in that suite's reach too: it is the agent's own
|
||||
`resolver` document from the real gateway, so `tests/test_agent.py` proves it has the section's shape
|
||||
(one entry per name `target_names()` derives, `via: "libc"`, the config's own `resolver` line, no
|
||||
globally routable address) and `tests/test_gateway_source.py` proves it reaches every route the
|
||||
configs write as a **name** and shares addresses with the recorded socket table — the capability the
|
||||
join needs, pinned before the join exists to use it.
|
||||
|
||||
`tests/rate_window_contract.py` is the same device for one number rather than a field table: the Δ
|
||||
window each source measures its rates over — `RATE_WINDOW_SECONDS` for the host agents,
|
||||
`LLM_INTERVAL_SECONDS` for the engines, one estimator per measuring cadence (phase 23). It holds
|
||||
|
||||
Reference in New Issue
Block a user