phase: 30_gateway_connection_map

**Phase 30 final verification pass — all 6 tasks already in `complete/`, so this was the phase-completion pass.**

- **Verified (re-run, not trusted):** `/gateway` serves exactly two panels (nginx→haproxy, `GATEWAY_CARD_KEYS`); haproxy rows `.solo` straight across, nginx `:443` branched with one tick per leaf (18 live, fixture 17); every pipe computes the theme `--ok` in dark+light, animates only at count ≥1, fully static under `prefers-reduced-motion`; switch = `role="switch"`+`aria-checked`+`aria-disabled`, no listener, footer+title name the missing write path; zero non-GET requests anywhere.
- **Live read-only re-run** (`data/logs/30-final-live-verify.out`): 17 PASS — `warnings: []`, 6 haproxy frontends→own `server`, `:443` 18 map leaves incl. `default→127.0.0.1:4443`, `:4443` 6 / `:8443` 3, `:80` bare listener, no `unmatched`/`outbound`/`by_port`/`partial`, no `9120`; inbound reconciliation **page 16 = kernel 16**; `gateway` block = the 12 declared keys; no `gateway:config` alert on a clean config. `git diff --stat agent/ deploy/` empty; agents 1.2.0 (gateway) / 1.1.0 ×2, all `tls`, `bundle_rev 2`.
- **Tests/lint (exact):** `uv run pytest` → **1791 passed, 16 skipped**, exit 0 · `bash .agents/validate.sh` → **exit 0**, `--cov` **98.94%** (floor 94), `ruff check src tests agent` clean, `pyright` 0 errors, `node --check` all page scripts, hygiene clean · `uv run pytest -m live --live` → **12 passed** · `uv pip list --outdated` empty · `./scripts/dev-server.sh status` → **10 sources ok**, `gateway` included · browser pass `30-connection-map.out` 63 PASS/0 FAIL (5 screenshots at 1440/360) · `30-final-mobile-audit.out` 371 PASS/0 FAIL/0 FINDING, exit 0, nine pages.
- **Docs fixed:** README `alerts.py` bullet omitted phase 30's keyword-only `gateway_payload` and its one `warn` config-integrity row (+suppression) — added; overview ingress line labelled a dated reading. AGENTS gap 13 corrected 391 px → **361 px** (task 05's pill took 30 px), "task 06 measured" → "phase 29 measured", coverage figure un-pinned to 98.9 %, live-suite count noted (12 today), "all five pages" severity sentence clarified for `/gateway`'s existence-only palette. Everything else reviewed in full and **verified consistent**.
- **Defect fixed:** duplicated footer-assertion block at the end of `tests/test_api.py::test_the_switch_is_built_and_explicitly_unwired` (one copy removed).
- **Notable:** `NOTES.md` is empty in the working tree — both items in it (non-mTLS agents, disappearing admin links) landed in phases 24 and 20; left cleared. Fixture/live `map`-count drift (17 vs 18) is recorded in AGENTS.md, not silently re-captured.
- **Next pending phase:** none — `30_gateway_connection_map` was the only entry in `.agents/phases/todo/`.
This commit is contained in:
2026-10-23 22:56:21 -04:00
parent fa4f38c694
commit e366f599f3
51 changed files with 5419 additions and 1519 deletions
+172 -14
View File
@@ -7,9 +7,15 @@ are the ones that make the landing page trustworthy rather than merely busy:
* a source nobody configured is **never** an alert (`AGENTS.md` invariant 3);
* a source that is stale but still serving is amber, not red;
* a dead source is **one** line, not one per stale metric in its last good payload;
* a dead source is **one** line, not one per stale metric in its last good payload (and not one
more for the config warnings its last good payload still carries — phase 30 task 03);
* only `fail` and `warn` come through — `ok`, `unknown`, and a missing number are silence;
* the list dedupes per (source, metric), is bounded, and says how much it hid.
The one non-reachability rule for a source with no graded numbers is the gateway's config-integrity
row: a proxy config that does not connect to itself is a structural fact, so it gets exactly one
amber line naming how many there are and quoting the first, and it is suppressed whenever that
job's own reachability line is on screen.
"""
from __future__ import annotations
@@ -483,13 +489,154 @@ def test_a_dead_upsd_is_one_alert_and_not_its_stale_cards_too() -> None:
# --------------------------------------------------------------------------- #
# the gateway page: reachability is the only voice, and a dead agent is still one line
# the gateway page: reachability plus config integrity, and a dead agent is still one line
# --------------------------------------------------------------------------- #
def gateway_state(**kwargs: Any) -> dict[str, Any]:
"""The health record of the one `gateway` job (the `nut` shape: key `gateway`, label = host)."""
return state("gateway", "gateway", "gateway", **kwargs)
def gateway_payload(*messages: str, ts: float | None = 1_000.0) -> dict[str, Any]:
"""A gateway payload carrying exactly `messages` as its config warnings, in that order."""
return {"ts": ts, "host": "gateway", "supported": True,
"warnings": [{"id": f"synthetic:{index}", "kind": "synthetic",
"subject": str(index), "message": text}
for index, text in enumerate(messages)]}
def test_config_warnings_are_one_warn_row_naming_the_count_and_the_first_message() -> None:
"""`gateway:config`: the one row a proxy config that does not connect to itself earns.
Phase 30 task 03 changed the documented rule this file used to pin — a `gateway` alert used to
be reachability only. What it is now: one `warn` row, `href` `/gateway`, `at` the payload's own
timestamp (the config did not change between polls, so an alert whose clock reset every 30 s
would be unreadable), the count and the first message in the sentence.
"""
out = alerts.build(Settings(gateway_interval=30.0), [gateway_state()],
gateway_payload=gateway_payload(
'frontend haproxy-22 routes to backend "gone", which no backend '
"block declares",
'backend "old" has 2 servers and no frontend routes to it'),
now=2_000.0)
assert out == [{"id": "gateway:config", "severity": "warn", "source": "gateway",
"href": "/gateway", "at": 1_000.0,
"message": ("2 proxy config problems to address — "
'frontend haproxy-22 routes to backend "gone", which no '
"backend block declares")}]
def test_one_warning_and_seven_produce_exactly_one_row_either_way() -> None:
"""The row is aggregate on purpose: seven dangling routes are one config to fix.
Seven amber lines would push every other source off the overview panel to say one thing, and the
page beside them lists all seven — so the count is the number and the first message is the
sentence, once.
"""
settings = Settings(gateway_interval=30.0)
for count in (1, 2, 7):
out = alerts.build(settings, [gateway_state()],
gateway_payload=gateway_payload(*[f"problem {index}" for index
in range(count)]),
now=2_000.0)
assert len(out) == 1, f"{count} warnings produced {len(out)} rows"
assert out[0]["id"] == "gateway:config" and out[0]["severity"] == "warn"
assert out[0]["href"] == "/gateway" and out[0]["source"] == "gateway"
noun = "problem" if count == 1 else "problems"
assert out[0]["message"] == f"{count} proxy config {noun} to address — problem 0", (
"the count, the noun that matches it, and the first message verbatim")
def test_a_clean_ingress_warns_about_nothing_and_a_missing_payload_does_too() -> None:
"""Both directions of the row, because an absence-only assertion is satisfied by deleting it."""
settings = Settings(gateway_interval=30.0)
assert alerts.build(settings, [gateway_state()],
gateway_payload=gateway_payload(), now=2_000.0) == []
assert alerts.build(settings, [gateway_state()],
gateway_payload={"host": "gateway", "warnings": None},
now=2_000.0) == [], "a malformed list is not a fault either"
assert alerts.build(settings, [gateway_state()], now=2_000.0) == [], (
"no payload at all — unconfigured, or configured and never answered — says nothing")
def test_a_failing_gateway_job_keeps_its_reachability_row_and_gains_no_config_row() -> None:
"""A dead source is one line, and the payload it left behind is not a second one.
Both arms of the rule, in both of its shapes: `ok: false` (the job is down) and stale (`ok: true`
past three of its own polls). In each, the reachability row is what the operator sees, and the
last good payload's warnings are not re-listed beside it — the warnings will be on `/gateway`
when the source answers again, and a second line here would be the invariant the `disks` rule
already keeps.
"""
settings = Settings(gateway_interval=30.0)
broken = gateway_payload('backend "gone" declares no server — it routes nowhere')
down = alerts.build(settings, [gateway_state(ok=False, error="UpstreamError: gateway: timed out")],
gateway_payload=broken, now=2_000.0)
assert [row["id"] for row in down] == ["gateway:gateway:down"], down
assert "timed out" in down[0]["message"]
stale = alerts.build(settings, [gateway_state(age=100.0)], gateway_payload=broken,
now=2_000.0)
assert [row["id"] for row in stale] == ["gateway:gateway:stale"], stale
assert "every 30s" in stale[0]["message"]
# the control: the same payload with a job that IS answering does grow the config row
live = alerts.build(settings, [gateway_state(age=4.0)], gateway_payload=broken, now=2_000.0)
assert [row["id"] for row in live] == ["gateway:config"]
def test_an_unconfigured_gateway_with_a_warning_payload_still_says_nothing() -> None:
"""Invariant 3 with a payload attached: a source nobody installed has no config to be broken."""
out = alerts.build(Settings(gateway_interval=30.0),
[gateway_state(configured=False)],
gateway_payload=gateway_payload("anything"), now=2_000.0)
assert out == []
def test_a_gateway_config_row_survives_a_dead_host_beside_it_and_sorts_after_the_fail() -> None:
"""The config row is the gateway's own voice and the outage row is the host's — both stand.
The echo table suppresses a `gateway` job that failed *because* its agent did; a config warning
is not that, so a healthy host with a broken ingress gets exactly one overview row about the
ingress and nothing else, and a failing host plus a stale config list stays one row too.
"""
settings = Settings(gateway_interval=30.0)
out = alerts.build(settings, [gateway_state(), state("host:gateway", "system", "gateway")],
gateway_payload=gateway_payload("fix me"), now=2_000.0)
assert [row["id"] for row in out] == ["gateway:config"]
host_down = state("host:gateway", "system", "gateway", ok=False, error="unreachable")
out = alerts.build(settings, [gateway_state(ok=False), host_down],
gateway_payload=gateway_payload("fix me"), now=2_000.0)
assert [row["severity"] for row in out] == ["fail"], "the outage is the only line"
def test_a_config_warning_row_is_the_worst_thing_about_a_gateway_never_a_number() -> None:
"""The rule restated after it changed: reachability plus config integrity, no graded number.
Three ways, because deleting a rule satisfies an absence-only assertion:
* `severity.THRESHOLDS` has no `gateway.*` row, so there is no bound for this module to read
even if a threshold were added here (and a busy proxy is the job, the `net.*` case);
* the row's message is built from `warnings` and nothing else — a payload of nothing but counts
yields no row at all;
* `alerts.HREF` still carries `/gateway`, so both voices reach the page that explains them.
"""
assert not [name for name in severity.THRESHOLDS if name.startswith("gateway.")], (
"a connection count is not a graded number")
settings = Settings(gateway_interval=30.0)
counts = {"ts": 1_000.0, "host": "gateway", "listeners": [{"connections": 9_999}],
"connections": [{"connections": 9_999}], "sockets": {"established": 9_999}}
assert alerts.build(settings, [gateway_state()], gateway_payload=counts, now=2_000.0) == []
assert alerts.HREF["gateway"] == "/gateway"
assert alerts.AGENT_ECHO_DASHBOARDS == ("disks", "gateway"), (
"task 03 changed what a gateway may alert ON, not which lines it echoes")
# and a gateway with a payload whose rows are junk is silence, not a crash and not a claim
assert alerts.build(settings, [gateway_state()],
gateway_payload={"warnings": ["not a mapping", 7]},
now=2_000.0) == []
def test_a_stale_gateway_job_is_a_warn_measured_in_its_own_thirty_second_poll() -> None:
"""The pipes are drawn from a 30 s poll, so 60 s of silence is two healthy polls.
@@ -539,24 +686,35 @@ def test_an_unconfigured_gateway_source_says_nothing_even_when_its_job_says_down
def test_a_gateway_payload_with_a_fabricated_verdict_alerts_nothing() -> None:
"""The phase's no-verdict rule, asserted as a rule and not only as an absence.
"""The no-verdict rule restated for the payload this task added a reader for.
Three ways, because deleting a rule satisfies an absence-only assertion:
* `alerts.build` takes no gateway payload at all — its signature is the whole surface, so a
gateway count can reach an alert only through a threshold added to this module;
* `severity.THRESHOLDS` has no `gateway.*` row, so no such threshold could be written without
editing the one file that owns thresholds (and a busy proxy is the job, the `net.*` case);
* the module's `HREF` does carry `/gateway`, so reachability still reaches the overview.
`alerts.build` now *takes* a gateway payload (phase 30 task 03), so the old signature-only pin
is gone and what replaces it is stronger and narrower: the parameter is keyword-only with a
default (a positional gateway would be a rule this module grew by accident), it is the ONLY
gateway input, `severity.THRESHOLDS` still has no `gateway.*` row for it to consult, and a
payload with `severity` stamped into it stays silent — the row reads `warnings` and nothing
else. An answering gateway with a clean list is the all clear.
"""
assert not {"gateway", "gateway_payload"} & set(inspect.signature(alerts.build).parameters)
signature = inspect.signature(alerts.build).parameters
gateway_argument = signature["gateway_payload"]
assert gateway_argument.kind is inspect.Parameter.KEYWORD_ONLY, (
"a positional gateway argument would let a caller pass a count where a payload belongs")
assert gateway_argument.default is None
assert not [name for name in signature
if name.startswith("gateway") and name != "gateway_payload"], (
"a second gateway input is a new rule in disguise")
assert not [name for name in severity.THRESHOLDS if name.startswith("gateway.")], (
"a connection count is not a graded number")
assert alerts.HREF["gateway"] == "/gateway"
assert alerts.AGENT_ECHO_DASHBOARDS == ("disks", "gateway")
# and an answering gateway with nothing else going on is the all clear
assert alerts.build(Settings(gateway_interval=30.0), [gateway_state()], [], [], None, [],
now=2_000.0) == []
# a fabricated verdict anywhere in the payload changes nothing: only `warnings` is read
rigged = gateway_payload()
rigged["severity"] = {"listeners": "fail", "connections": "fail"}
rigged["listeners"] = [{"connections": 100, "severity": "fail"}]
rigged["connections"] = [{"connections": 100, "severity": "fail"}]
settings = Settings(gateway_interval=30.0)
assert alerts.build(settings, [gateway_state()], gateway_payload=rigged, now=2_000.0) == []
assert alerts.build(settings, [gateway_state()], [], [], None, [], now=2_000.0) == []
# --------------------------------------------------------------------------- #