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:
+172
-14
@@ -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) == []
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
Reference in New Issue
Block a user