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:
+603
-65
@@ -4907,9 +4907,17 @@ def test_the_gateway_job_is_handed_no_rate_estimator(tmp_path: Path) -> None:
|
||||
|
||||
def make_gateway_client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch,
|
||||
**overrides: Any) -> Any:
|
||||
"""An app whose only host is the ingress, on clocks fast enough to poll inside a test."""
|
||||
"""An app whose only host is the ingress, on clocks fast enough to poll inside a test.
|
||||
|
||||
The intervals are the poller's own floor, not 0.05: `Job` clamps its cadence at 0.5 s,
|
||||
while the staleness threshold is three times the *configured* interval. At 0.05 s a healthy
|
||||
job's gap (≥ 0.5 s) always outlived its own threshold (0.15 s), so its `:stale` row
|
||||
appeared in the alert list 70 % of the time and any assertion on the list's length was
|
||||
a test of when the event loop happened to tick. At 0.5 s the threshold (1.5 s) is
|
||||
three times the gap a healthy job can make, and the premise jobs stay quiet whatever
|
||||
the machine is doing."""
|
||||
return build_client(tmp_path, monkeypatch, host_targets=(GATEWAY_TARGET,),
|
||||
host_interval=0.05, disks_interval=0.05, gateway_interval=0.05,
|
||||
host_interval=0.5, disks_interval=0.5, gateway_interval=0.5,
|
||||
**overrides)
|
||||
|
||||
|
||||
@@ -4975,25 +4983,56 @@ def test_the_summary_gateway_block_is_the_stub_of_a_source_that_never_answered(
|
||||
assert block["href"] == GATEWAY_HREF
|
||||
|
||||
|
||||
def endpoint_identity(out: dict[str, Any]) -> tuple[Any, ...] | None:
|
||||
"""One endpoint, in the currency the kernel writes — derived here, not by the source.
|
||||
|
||||
An endpoint written as an address **is** that address; an endpoint written as a name is every
|
||||
address the agent's resolver published for it (`out.resolved`), and `ipaddress` folds the
|
||||
`::ffff:`-mapped and zero-padded spellings so one address in two tables is one key. `None`
|
||||
means the join has nothing to name the far end with, which contributes nothing to a total.
|
||||
|
||||
This is deliberately a **third** reading of "what is one endpoint" (the others are
|
||||
`gateway.endpoint_of` behind `api._endpoint_values`, and the join itself): since phase 32 two
|
||||
route lines can reach one address while the configs spell it as two different names, and a
|
||||
total keyed on the config's text would book it once per name.
|
||||
"""
|
||||
port = out.get("port")
|
||||
if not isinstance(port, int):
|
||||
return None
|
||||
spellings = ([out["host"]] if out.get("address") is True
|
||||
else list(out.get("resolved") or []))
|
||||
keys: list[str] = []
|
||||
for spelling in spellings:
|
||||
try:
|
||||
address = ipaddress.ip_address(str(spelling))
|
||||
except ValueError:
|
||||
continue
|
||||
mapped = getattr(address, "ipv4_mapped", None)
|
||||
keys.append(str(mapped if mapped is not None else address))
|
||||
return (min(keys), port) if keys else None
|
||||
|
||||
|
||||
def endpoint_sums(payload: dict[str, Any]) -> tuple[int | None, int | None]:
|
||||
"""The two live totals, derived here so the block is checked against a **second** reading.
|
||||
|
||||
Inbound is the sum over trunks. Outbound is the sum over the **distinct** `(host, port)` the
|
||||
route lines name: `sources/gateway.py` repeats an endpoint's whole count on every line that
|
||||
reaches it (`shared: true`), so summing the lines would book the same sockets twice and print
|
||||
a router busier than the kernel has connections for. Neither is a sum when nothing answered.
|
||||
Inbound is the sum over trunks. Outbound is the sum over the **distinct endpoints** the route
|
||||
lines reach: `sources/gateway.py` repeats an endpoint's whole count on every line that reaches
|
||||
it (`shared: true`), so summing the lines would book the same sockets twice and print a router
|
||||
busier than the kernel has connections for. Neither is a sum when nothing answered.
|
||||
"""
|
||||
live_in = [row["connections"] for row in payload["listeners"]
|
||||
if isinstance(row["connections"], int)]
|
||||
endpoints: dict[tuple[Any, Any], Any] = {}
|
||||
endpoints: dict[tuple[Any, ...], Any] = {}
|
||||
for row in payload["connections"]:
|
||||
endpoints.setdefault((row["out"]["host"], row["out"]["port"]), row["connections"])
|
||||
key = endpoint_identity(row["out"])
|
||||
if key is not None:
|
||||
endpoints.setdefault(key, row["connections"])
|
||||
live_out = [value for value in endpoints.values() if isinstance(value, int)]
|
||||
return (sum(live_in) if live_in else None,
|
||||
sum(live_out) if live_out else None)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", ["full", "predates", "sockets_silent"])
|
||||
@pytest.mark.parametrize("mode", ["full", "resolved", "predates", "sockets_silent"])
|
||||
def test_the_summary_gateway_block_is_the_payloads_sums_not_the_pages_arithmetic(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, gateway_snapshot: Any,
|
||||
mode: str) -> None:
|
||||
@@ -5003,12 +5042,18 @@ def test_the_summary_gateway_block_is_the_payloads_sums_not_the_pages_arithmetic
|
||||
hand-written payload, so a rename in the source's shape fails here instead of printing a
|
||||
plausible zero (rule 7).
|
||||
|
||||
The split that replaced `partial` is the point of the three arms. `listeners` / `connections`
|
||||
The split that replaced `partial` is the point of the arms. `listeners` / `connections`
|
||||
are **row counts** — a fact readable from a config file, so they are numbers even when the
|
||||
socket table never answered — while `live_in` / `live_out` are measurements and go `null` with
|
||||
it. The old block could only report one number per side and needed a `partial` flag to say it
|
||||
was a subtotal; there is no residual to qualify now, because a socket outside the two files is
|
||||
counted nowhere by decision.
|
||||
|
||||
The `resolved` arm is phase 32's, and it is here because the **block** must not change when the
|
||||
join widens: twelve fields, the same names, and `live_out` still the sum over distinct
|
||||
endpoints — now endpoints reached through a resolved name as well as an address, each booked
|
||||
once however many lines reach it. A block that summed the lines instead would move from 15 to a
|
||||
bigger, wronger number on the same capture.
|
||||
"""
|
||||
payload = gateway_source.parse(gateway_snapshot(mode), "gateway")
|
||||
expected_in, expected_out = endpoint_sums(payload)
|
||||
@@ -5054,22 +5099,40 @@ def test_the_summary_gateway_block_of_the_captured_ingress_reads_the_live_join(
|
||||
"""The real capture, one figure at a time — the block an operator would actually get.
|
||||
|
||||
The parametrised test above proves the rule; this one pins the numbers, so a change to the join
|
||||
that moves a total is visible somewhere rather than only in a browser. The outbound figure is
|
||||
small **on purpose**: this capture's configs write almost every target as a domain, and a
|
||||
domain is not something this stack can join to a socket, so only the two `127.0.0.1` endpoints
|
||||
carry a count at all (`:4443` with 2 and `:8081` with 0).
|
||||
that moves a total is visible somewhere rather than only in a browser. The three config-shaped
|
||||
figures are literal, because only the operator moves them; the two live totals are derived from
|
||||
the capture rather than quoted, because a re-capture of a busy router lawfully moves them.
|
||||
|
||||
Since phase 32 task 03 the outbound total is no longer pinned to the two `127.0.0.1` endpoints:
|
||||
the recorded `gateway_resolver.json` places every name this ingress routes to, so the routes
|
||||
that used to be dashes are counted through the address the gateway dialled. What is still pinned
|
||||
is the **rule** the number comes from — one count per distinct endpoint, and never more than the
|
||||
kernel has connections for.
|
||||
"""
|
||||
payload = gateway_source.parse(gateway_snapshot("resolved"), "gateway")
|
||||
with make_gateway_client(tmp_path, monkeypatch) as client:
|
||||
answer_as(client, "gateway", gateway_source.parse(gateway_snapshot("full"), "gateway"))
|
||||
answer_as(client, "gateway", payload)
|
||||
deadline = time.time() + 10
|
||||
while time.time() < deadline and not client.app.state.ctx.hub.jobs["gateway"].state.ok:
|
||||
time.sleep(0.05)
|
||||
block = gateway_block(client)
|
||||
assert block["listeners"] == 10, "6 haproxy binds + 4 nginx listen ports"
|
||||
assert block["connections"] == 32, "every route the two captured configs declare"
|
||||
assert block["live_in"] == 9, "9 of the 18 live sockets face a configured listen port"
|
||||
assert block["live_out"] == 2, "the two loopback endpoints, counted once each"
|
||||
assert block["map_targets"] == 17, "the stream map's routing entries are the fan-out"
|
||||
assert block["connections"] == 33, "every route the two captured configs declare"
|
||||
assert block["map_targets"] == 18, "the stream map's routing entries are the fan-out"
|
||||
# The two live totals are the volatile half: a capture of a running router is a different set of
|
||||
# connections every second, so they are compared with the payload the block was built from
|
||||
# rather than with a number typed here (the doctrine this suite already keeps for the socket
|
||||
# fixtures themselves — assert the shape and the rule, never a particular row). What stays
|
||||
# literal is what only the two config files decide, above, and what stays asserted as a
|
||||
# relationship is the direction of the error, below.
|
||||
assert block["live_in"] == sum(row["connections"] or 0 for row in payload["listeners"])
|
||||
_, expected_out = endpoint_sums(payload)
|
||||
assert block["live_out"] == expected_out, "one count per distinct endpoint, never one per line"
|
||||
assert sum(row["connections"] or 0 for row in payload["connections"]) >= (expected_out or 0), (
|
||||
"the lines repeat a shared endpoint's count, which is exactly why the block adds the "
|
||||
"endpoints up instead")
|
||||
assert block["live_in"] + block["live_out"] <= payload["sockets"]["established"], (
|
||||
"the reported figures may under-count the kernel's, never exceed it")
|
||||
assert block["warnings"] == 0, (
|
||||
"this fleet's two config files connect to themselves, and 0 is the truth rather than a "
|
||||
"missing measurement — the overview line prints nothing for it either way")
|
||||
@@ -5106,6 +5169,13 @@ def test_one_dangling_use_backend_is_one_warn_row_on_the_overview(
|
||||
deadline = time.time() + 15
|
||||
while time.time() < deadline and not client.app.state.ctx.hub.jobs["gateway"].state.ok:
|
||||
time.sleep(0.05)
|
||||
# The two host jobs are this test's premise, not its subject: on a slow machine the wait
|
||||
# loops above outlive their 1.5 s staleness window, and the alert then gains a `:stale`
|
||||
# row for the premise and the assertion below reads a three-row list. Stamping their
|
||||
# `last_ok` pins the premise the docstring states ("the agent answers") and leaves the
|
||||
# gateway row the only line.
|
||||
for host_key in ("host:gateway", "disks:gateway"):
|
||||
client.app.state.ctx.hub.jobs[host_key].state.last_ok = time.time()
|
||||
rows = gateway_alerts(client)
|
||||
block = client.get("/api/summary").json()["gateway"]
|
||||
assert len(rows) == 1, rows
|
||||
@@ -5123,6 +5193,8 @@ def test_one_dangling_use_backend_is_one_warn_row_on_the_overview(
|
||||
deadline = time.time() + 15
|
||||
while time.time() < deadline and client.get("/api/summary").json()["gateway"]["warnings"]:
|
||||
time.sleep(0.05)
|
||||
for host_key in ("host:gateway", "disks:gateway"):
|
||||
client.app.state.ctx.hub.jobs[host_key].state.last_ok = time.time()
|
||||
assert gateway_alerts(client) == [], "a clean ingress is an all clear, not a green line"
|
||||
|
||||
|
||||
@@ -5164,6 +5236,13 @@ def test_a_gateway_job_that_stops_answering_keeps_one_row_not_two(
|
||||
time.sleep(0.05)
|
||||
else:
|
||||
pytest.fail("the gateway job never went from answering to failing")
|
||||
# The two host jobs are this test's premise, not its subject: on a slow machine the wait
|
||||
# loops above outlive their 1.5 s staleness window, and the alert then gains a `:stale`
|
||||
# row for the premise and the assertion below reads a three-row list. Stamping their
|
||||
# `last_ok` pins the premise the docstring states ("the agent answers") and leaves the
|
||||
# gateway row the only line.
|
||||
for host_key in ("host:gateway", "disks:gateway"):
|
||||
client.app.state.ctx.hub.jobs[host_key].state.last_ok = time.time()
|
||||
alerts = gateway_alerts(client)
|
||||
assert [row["id"] for row in alerts] == ["gateway:gateway:down"], (
|
||||
"the reachability row is the gateway's only line while it is down")
|
||||
@@ -5428,17 +5507,16 @@ def find_keys(node: Any, name: str) -> list[Any]:
|
||||
return []
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def gateway_client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch,
|
||||
gateway_snapshot: Any) -> Iterator[TestClient]:
|
||||
"""The ingress answering with the captured configs and sockets, its gauges already stored.
|
||||
def _gateway_routes_client(tmp_path: Path, mode: str,
|
||||
gateway_snapshot: Any) -> Iterator[TestClient]:
|
||||
"""One app per capture mode, its gauges already stored, its payload already in the job state.
|
||||
|
||||
Both halves come from `sources/gateway` over the **captured** tree, so every path a page reads
|
||||
and every series name a chart asks for is one the source really emits (rule 7). The target
|
||||
points at a dead port: no poll can overwrite the payload, dial out, or add a second sample
|
||||
between the store read and the route assertion below.
|
||||
"""
|
||||
db = str(tmp_path / "gateway-routes.sqlite3")
|
||||
db = str(tmp_path / f"gateway-routes-{mode}.sqlite3")
|
||||
store = SampleStore(db)
|
||||
app = create_app(Settings(db_path=db, admin_password=ADMIN_PASSWORD,
|
||||
pbkdf2_iterations=FAST_PBKDF2,
|
||||
@@ -5446,7 +5524,7 @@ def gateway_client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch,
|
||||
with TestClient(app) as test_client:
|
||||
sign_in(test_client)
|
||||
now = time.time()
|
||||
snapshot = gateway_snapshot("full")
|
||||
snapshot = gateway_snapshot(mode)
|
||||
snapshot["ts"] = now # "the agent answered just now", so charts are in range
|
||||
result = gateway_source.parse_gateway_snapshot("gateway", snapshot, now=now)
|
||||
store.add_many(result.samples)
|
||||
@@ -5455,6 +5533,31 @@ def gateway_client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch,
|
||||
store.close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def gateway_client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch,
|
||||
gateway_snapshot: Any) -> Iterator[TestClient]:
|
||||
"""The ingress answering as agent **1.2.0** (or 1.3.0 with `--no-resolve`) does.
|
||||
|
||||
No `resolver` section, so the routes whose targets the two config files write as domains carry
|
||||
no count, no gauge and no stored series — phase 30 verbatim, which is what the phase-32 pins
|
||||
below compare against.
|
||||
"""
|
||||
yield from _gateway_routes_client(tmp_path, "full", gateway_snapshot)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def resolved_gateway_client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch,
|
||||
gateway_snapshot: Any) -> Iterator[TestClient]:
|
||||
"""The same ingress answering as agent **1.3.0**: the captured resolver section attached.
|
||||
|
||||
The document is the recorded `gateway_resolver.json`, so every series name this fixture's store
|
||||
holds is one the deployed ingress will really write after task 05 — including the **name-keyed**
|
||||
outbound gauges phase 30 stopped writing, which are writable again precisely because the
|
||||
gateway published the address behind the name.
|
||||
"""
|
||||
yield from _gateway_routes_client(tmp_path, "resolved", gateway_snapshot)
|
||||
|
||||
|
||||
def dial_spies(monkeypatch: pytest.MonkeyPatch, calls: list[str]) -> None:
|
||||
"""Two spies that raise: a route that dials before its gate fails here, not in a timeout.
|
||||
|
||||
@@ -5568,11 +5671,19 @@ def test_gateway_snapshot_answers_the_pipes_the_projection_joined(
|
||||
"the deleted join is back in the payload")
|
||||
|
||||
# the config half, verbatim from the capture the agent read (phase 28's six frontends, the
|
||||
# stream map's 17 routing entries) — the echo the pipes were built from
|
||||
# stream map's 18 routing entries) — the echo the pipes were built from
|
||||
assert len(body["haproxy"]["frontends"]) == 6
|
||||
assert len(body["nginx"]["map_targets"]) == 17
|
||||
assert len(body["nginx"]["map_targets"]) == 18
|
||||
# `/proc/net/tcp` is one row per live connection, so every count below is derived from the same
|
||||
# capture the payload was built from instead of typed here: the rule is the pin, the number is a
|
||||
# reading. The config-shaped figures above stay literal, because only the operator moves those.
|
||||
established = gateway_snapshot("full")["sockets"]["established"]
|
||||
|
||||
stream = next(pipe for pipe in body["listeners"] if pipe["port"] == 443)
|
||||
assert stream["connections"] == 6 and stream["metric"] == "gateway.in_conns@443", (
|
||||
assert stream["connections"] == sum(1 for row in established
|
||||
if row["local"]["port"] == 443), (
|
||||
"the inbound pipe carries the sockets whose LOCAL port it is, which is the whole join")
|
||||
assert stream["metric"] == "gateway.in_conns@443", (
|
||||
"the pipe carries the series it is, so the page never rebuilds the name")
|
||||
assert stream["clients"], "the inbound pipe names the addresses that are using it"
|
||||
secure = next(pipe for pipe in body["listeners"] if pipe["port"] == 4443)
|
||||
@@ -5581,14 +5692,19 @@ def test_gateway_snapshot_answers_the_pipes_the_projection_joined(
|
||||
# The graph: every route the two captured configs declare, each line's inbound end a trunk this
|
||||
# same payload draws, and a count on **only** the lines whose endpoint the config wrote as an
|
||||
# address — this capture has exactly two (`127.0.0.1:4443` and `127.0.0.1:8081`).
|
||||
assert len(body["connections"]) == 32
|
||||
assert len(body["connections"]) == 33
|
||||
listener_ports = {listener["port"] for listener in body["listeners"]}
|
||||
assert listener_ports == {22, 2000, 5201, 8080, 25565, 25566, 80, 443, 4443, 8443}
|
||||
assert all(row["in"]["port"] in listener_ports for row in body["connections"]), (
|
||||
"a connection on a port no listener carries is an orphan the page cannot draw")
|
||||
def sockets_to(host: str, port: int) -> int:
|
||||
return sum(1 for row in established
|
||||
if (row["remote"]["addr"], row["remote"]["port"]) == (host, port))
|
||||
|
||||
counted = {row["id"]: row["connections"] for row in body["connections"]
|
||||
if row["connections"] is not None}
|
||||
assert counted == {"nginx:443->127.0.0.1:4443": 2, "nginx:8443->127.0.0.1:8081": 0}, (
|
||||
assert counted == {"nginx:443->127.0.0.1:4443": sockets_to("127.0.0.1", 4443),
|
||||
"nginx:8443->127.0.0.1:8081": sockets_to("127.0.0.1", 8081)}, (
|
||||
"a route written as a domain carries no count, and one written as an address carries the "
|
||||
"endpoint's own — 0 included, because the socket table answered")
|
||||
assert all(row["out"]["address"] is True for row in body["connections"]
|
||||
@@ -5600,11 +5716,88 @@ def test_gateway_snapshot_answers_the_pipes_the_projection_joined(
|
||||
if pipe["connections"] is not None)
|
||||
endpoints = {(row["out"]["host"], row["out"]["port"]) for row in body["connections"]
|
||||
if row["connections"] is not None}
|
||||
assert known_in + sum(counted.values()) <= body["sockets"]["established"] == 18
|
||||
assert known_in + sum(counted.values()) <= body["sockets"]["established"] == len(established)
|
||||
assert len(endpoints) == 2
|
||||
assert [row["key"] for row in body["states"]] == ["gateway"]
|
||||
|
||||
|
||||
def test_the_served_snapshot_counts_a_resolved_name_and_says_which_address_it_used(
|
||||
resolved_gateway_client) -> None:
|
||||
"""`/api/gateway/snapshot` on the 1.3.0 document: the dash is gone, and the reason is in the row.
|
||||
|
||||
One route, both halves of the claim. `brain.reeseapps.com` leaves this ingress for
|
||||
`reeseapps-brain-of-reese.reeselink.com:443`; the row now carries `address: false` (the config
|
||||
wrote a name — verbatim, still), `resolved: ["10.1.2.64"]`, `via: "libc"` and that entry's own
|
||||
`resolved_ts`, **and** a finite `connections`. A number with no provenance beside it would be
|
||||
the invention this app refuses to print, which is why the two halves are one test.
|
||||
"""
|
||||
body = resolved_gateway_client.get("/api/gateway/snapshot").json()
|
||||
assert body["resolver"]["available"] is True and body["resolver"]["via"] == "libc"
|
||||
brain = next(row for row in body["connections"]
|
||||
if row["out"]["host"] == "reeseapps-brain-of-reese.reeselink.com")
|
||||
assert brain["out"]["selectors"] == ["brain.reeseapps.com"]
|
||||
assert brain["out"]["address"] is False
|
||||
assert brain["out"]["resolved"] == ["10.1.2.64"]
|
||||
assert brain["out"]["via"] == "libc"
|
||||
assert isinstance(brain["out"]["resolved_ts"], (int, float))
|
||||
assert isinstance(brain["connections"], int) and not isinstance(brain["connections"], bool), (
|
||||
"resolved and idle is a `0`, not the dash: the socket table answered and this endpoint has none")
|
||||
assert brain["metric"] == "gateway.out_conns@reeseapps-brain-of-reese.reeselink.com:443"
|
||||
|
||||
ntfy = next(row for row in body["connections"]
|
||||
if row["out"]["host"] == "reeseapps-ntfy.reeselink.com")
|
||||
assert ntfy["connections"] > 0, "the capture has live ntfy sockets behind that name"
|
||||
counted = [row for row in body["connections"] if row["connections"] is not None]
|
||||
assert len(counted) == len(body["connections"]), (
|
||||
"every route this ingress declares is placed by the recorded resolver, so no route on the "
|
||||
"live page is a dash any more — the operator's complaint, in one assertion")
|
||||
assert all(row["out"]["address"] is True or row["out"]["resolved"] for row in counted), (
|
||||
"a count never arrives without the address it was counted through — one the config wrote, "
|
||||
"or one the gateway published; `resolved` stays null on the address rows, because copying "
|
||||
"the agent's `literal` echo of a string the config already wrote would claim a lookup that "
|
||||
"never happened")
|
||||
|
||||
|
||||
def test_an_injected_nxdomain_row_is_a_dash_in_the_served_document(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, gateway_snapshot: Any) -> None:
|
||||
"""Both arms on one route list: placed is a number, unplaced is `null` / `null`.
|
||||
|
||||
The capture resolves everything, so the second arm is injected: one entry of the recorded
|
||||
document emptied and marked `nxdomain`, exactly what agent 1.3.0 publishes when `getaddrinfo`
|
||||
says a name has no record. Its line keeps its place in the graph with `resolved: null`,
|
||||
`via: null`, `connections: null` and `metric: null` — no `0`, no series, no sample — while the
|
||||
routes around it carry their counts, so a page cannot read one failed name as a dead ingress.
|
||||
"""
|
||||
broken = gateway_snapshot("resolved")
|
||||
entries = [entry for entry in broken["resolver"]["entries"]
|
||||
if entry["name"] == "reeseapps-immich.reeselink.com"]
|
||||
assert entries, "the capture resolves immich, so the injection has something to un-resolve"
|
||||
for entry in entries:
|
||||
entry["addresses"] = []
|
||||
entry["status"] = "nxdomain"
|
||||
payload = gateway_source.parse(broken, "gateway")
|
||||
|
||||
with make_gateway_client(tmp_path, monkeypatch) as client:
|
||||
answer_as(client, "gateway", payload)
|
||||
deadline = time.time() + 10
|
||||
while time.time() < deadline and not client.app.state.ctx.hub.jobs["gateway"].state.ok:
|
||||
time.sleep(0.05)
|
||||
body = client.get("/api/gateway/snapshot").json()
|
||||
|
||||
row = next(item for item in body["connections"]
|
||||
if item["out"]["host"] == "reeseapps-immich.reeselink.com")
|
||||
assert row["out"]["resolved"] is None, "never `[]`, so the page's dash arm is one `is null` test"
|
||||
assert row["out"]["via"] is None and row["out"]["resolved_ts"] is None
|
||||
assert row["connections"] is None and row["metric"] is None
|
||||
assert row["in"] == {"port": 443}, "the route is still drawn: a name with no record is not no route"
|
||||
placed = [item for item in body["connections"] if item["connections"] is not None]
|
||||
assert len(placed) == len(body["connections"]) - 1, (
|
||||
"one unresolved name dims one line and nothing else")
|
||||
documented = {entry["name"]: entry["status"] for entry in body["resolver"]["entries"]}
|
||||
assert documented["reeseapps-immich.reeselink.com"] == "nxdomain", (
|
||||
"the entry the index refused to use is still the entry the page can quote")
|
||||
|
||||
|
||||
def test_no_port_the_two_configs_do_not_name_appears_anywhere(gateway_client,
|
||||
gateway_snapshot: Any) -> None:
|
||||
"""The live router's own agent port is `9120`, and this payload never mentions it.
|
||||
@@ -5700,10 +5893,12 @@ def test_gateway_history_serves_the_pipe_gauges_the_store_holds(
|
||||
facts), and the `source` says the `gateway` family — a page must never ask the 10-second `host`
|
||||
series for a connection count.
|
||||
|
||||
The outbound name asked for is an **address** (`127.0.0.1:4443`), and that is the whole point of
|
||||
the phase-30 change to `samples()`: a route whose target the config wrote as a domain publishes
|
||||
no series at all, so a name-keyed `gateway.out_conns@<domain>:<port>` is no longer stored and
|
||||
the store's `gateway` family is exactly the endpoints the join could count.
|
||||
The outbound name asked for is an **address** (`127.0.0.1:4443`) because this fixture answers as
|
||||
a 1.2.0 agent. The rule `samples()` keeps is about the **address**, not the spelling: a route
|
||||
publishes a gauge when the kernel can name its far end — an address the config wrote, or (since
|
||||
phase 32 task 03, pinned beside this one on the `resolved` arm) a name the gateway itself
|
||||
resolved. A name nobody resolved publishes nothing, which is what
|
||||
`test_gateway_history_answers_empty_for_a_pipe_it_never_measured` still proves.
|
||||
"""
|
||||
store: SampleStore = gateway_client.app.state.ctx.store # type: ignore[attr-defined]
|
||||
body = gateway_client.get("/api/gateway/history", params={
|
||||
@@ -5743,6 +5938,65 @@ def test_gateway_history_answers_empty_for_a_pipe_it_never_measured(gateway_clie
|
||||
assert series_source(body) == {"gateway"}
|
||||
|
||||
|
||||
def test_a_resolved_name_publishes_its_gauge_and_history_answers_it(
|
||||
resolved_gateway_client) -> None:
|
||||
"""The name-keyed series phase 30 stopped writing, written again on an honest footing.
|
||||
|
||||
Phase 30 task 02 retired `gateway.out_conns@<domain>:<port>` because the only figure it could
|
||||
have carried was the *port's* — the port-only join's count filed under whatever domain happened
|
||||
to share it. Phase 32 task 03 gives the name a figure of its own: the sockets to the address the
|
||||
**gateway** resolved that name to. So the name is stored, `/api/metrics` lists it, and
|
||||
`/api/gateway/history` answers points for it — read through `store.query`, the third path, as
|
||||
every other history pin is.
|
||||
"""
|
||||
store: SampleStore = resolved_gateway_client.app.state.ctx.store # type: ignore[attr-defined]
|
||||
metric = "gateway.out_conns@reeseapps-ntfy.reeselink.com:443"
|
||||
body = resolved_gateway_client.get("/api/gateway/history", params={
|
||||
"hosts": "gateway", "metrics": metric, "range": "1h"}).json()
|
||||
|
||||
assert [series["metric"] for series in body["series"]] == [metric]
|
||||
assert series_source(body) == {"gateway"}
|
||||
rows = store.query("gateway", "gateway", metric, res=RES_RAW)
|
||||
assert rows, "the resolved route's gauge was not stored"
|
||||
assert [(point["ts"], point["value"]) for point in body["series"][0]["points"]] == rows
|
||||
assert metric in resolved_gateway_client.get("/api/metrics",
|
||||
params={"source": "gateway"}).json()["metrics"]
|
||||
|
||||
|
||||
def test_one_shared_endpoint_costs_one_insert_per_poll_even_through_a_name(
|
||||
tmp_path: Path, gateway_snapshot: Any) -> None:
|
||||
"""Two lines, one gauge name, **one row per poll** — counted in the store, not asserted.
|
||||
|
||||
The captured ingress reaches `keycloak.reeselink.com:8080` from `:4443` and from `:8443`, and
|
||||
since task 03 the endpoint is reachable through a **resolved name** rather than a written
|
||||
address. `shared: true` says the two lines carry one number; `samples()` de-duplicating by
|
||||
metric is what stops that number being inserted twice for one reading, and the row count is the
|
||||
only proof worth having. Two polls, two rows: one per reading, never one per line.
|
||||
"""
|
||||
db = str(tmp_path / "gateway-shared.sqlite3")
|
||||
store = SampleStore(db)
|
||||
now = time.time()
|
||||
metric = "gateway.out_conns@keycloak.reeselink.com:8080"
|
||||
# both readings in the past: `store.query` bounds its window at now, so a simulated second poll
|
||||
# that lands in the future is a row that never comes back, not a lost insert
|
||||
for offset in (-60.0, -30.0):
|
||||
snapshot = gateway_snapshot("resolved")
|
||||
snapshot["ts"] = now + offset
|
||||
result = gateway_source.parse_gateway_snapshot("gateway", snapshot, now=now + offset)
|
||||
shared = [row for row in result.payload["connections"] if row["shared"]]
|
||||
assert {row["metric"] for row in shared} == {metric}, (
|
||||
"this capture's shared endpoint is the keycloak pair, and one poll writes it once")
|
||||
assert len({row["connections"] for row in shared}) == 1, (
|
||||
"both lines carry the same number, which is what makes one insert the honest count")
|
||||
out = [row for row in result.samples if row.metric == metric]
|
||||
assert len(out) == 1, "one sample per poll, however many lines reach the endpoint"
|
||||
store.add_many(result.samples)
|
||||
|
||||
rows = store.query("gateway", "gateway", metric, res=RES_RAW)
|
||||
assert len(rows) == 2, "two polls, two rows — not four (one per line) and not one (lost poll)"
|
||||
store.close()
|
||||
|
||||
|
||||
def test_gateway_history_names_the_ingress_and_no_other_host(gateway_client) -> None:
|
||||
"""With no `hosts=` the answer is the one host this source reads — not the whole fleet.
|
||||
|
||||
@@ -5853,6 +6107,10 @@ GATEWAY_PAGE_FIELDS = (
|
||||
"connections.id", "connections.source", "connections.in.port",
|
||||
"connections.out.host", "connections.out.port", "connections.out.name",
|
||||
"connections.out.kind", "connections.out.selectors", "connections.out.address",
|
||||
# Phase 32 task 04: the provenance a counted line prints and the dash refuses. `resolved` is what
|
||||
# the filter matches an IP against, `via` + `resolved_ts` are the tooltip's `address from libc,
|
||||
# resolved 3m` — so a rename of any of the three fails by name here, not as a permanent silence.
|
||||
"connections.out.resolved", "connections.out.via", "connections.out.resolved_ts",
|
||||
"connections.connections", "connections.shared", "connections.metric",
|
||||
"warnings.id", "warnings.kind", "warnings.subject", "warnings.message",
|
||||
)
|
||||
@@ -6415,11 +6673,16 @@ def test_the_trunk_pipe_is_an_accessible_image_of_its_port(client, gateway_clien
|
||||
|
||||
|
||||
def _leaf_haystack(row: dict[str, Any]) -> str:
|
||||
"""The one string the filter can see about one route, as `page-gateway.js` composes it."""
|
||||
"""The one string the filter can see about one route, as `page-gateway.js` composes it.
|
||||
|
||||
Phase 32 task 04 adds the **sixth** field: `out.resolved`, the addresses the gateway published for
|
||||
a name target. Typing an IP is how an operator asks "which route owns the socket I am looking at",
|
||||
and a haystack of names alone cannot answer it.
|
||||
"""
|
||||
out = row.get("out") or {}
|
||||
port = (row.get("in") or {}).get("port")
|
||||
parts = ["" if port is None else str(port), out.get("host"), out.get("name"), out.get("kind"),
|
||||
*(out.get("selectors") or [])]
|
||||
*(out.get("selectors") or []), *(out.get("resolved") or [])]
|
||||
return " ".join(p for p in parts if isinstance(p, str) and p).lower()
|
||||
|
||||
|
||||
@@ -6434,7 +6697,8 @@ def filter_view(snapshot: dict[str, Any], query: str) -> dict[str, Any]:
|
||||
|
||||
There is no JS runner (a locked anchor), so this is `rendered_leaf`'s idiom: the rules are
|
||||
re-applied here from the payload, and the served-byte pins in the test below are what keep the
|
||||
mirror honest — `haystack` indexes `in.port`, `out.host`, `out.name`, `out.kind`, `out.selectors`;
|
||||
mirror honest — `haystack` indexes `in.port`, `out.host`, `out.name`, `out.kind`,
|
||||
`out.selectors`, `out.resolved`;
|
||||
`trunkHaystack` indexes `:${port}`, `domains`, `clients`; a trunk that matches keeps **all** its
|
||||
leaves; a group stands when it or one of its routes answers. A rename on either side therefore
|
||||
fails a pin *and* changes what this mirror prints.
|
||||
@@ -6476,7 +6740,8 @@ def filter_view(snapshot: dict[str, Any], query: str) -> dict[str, Any]:
|
||||
return {"groups": groups, "standing": standing, "total": len(snapshot["connections"])}
|
||||
|
||||
|
||||
def test_the_filter_narrows_the_map_without_asking_anything(client, gateway_client) -> None:
|
||||
def test_the_filter_narrows_the_map_without_asking_anything(client, gateway_client,
|
||||
gateway_snapshot: Any) -> None:
|
||||
"""One field, four rules, and not one extra request.
|
||||
|
||||
The operator's complaint is a reading-speed one: *"I want to find a domain."* This ingress routes
|
||||
@@ -6485,9 +6750,12 @@ def test_the_filter_narrows_the_map_without_asking_anything(client, gateway_clie
|
||||
feature could go wrong:
|
||||
|
||||
* **It matches payload text and nothing else.** `in.port`, `out.host`, `out.name`, `out.kind`,
|
||||
`out.selectors` for a route, `:${port}` + `domains` + `clients` for a trunk. No DNS (the module
|
||||
still resolves no name), no re-ranking, no rule about `.reeselink.com` — a query can never
|
||||
invent a route, because there is no other text to match.
|
||||
`out.selectors` and `out.resolved` for a route, `:${port}` + `domains` + `clients` for a trunk.
|
||||
The sixth field is phase 32 task 04's: an address the **gateway** published is text the payload
|
||||
already carries, so typing the far end of a socket finds the route that owns it. No DNS (the
|
||||
page still resolves no name — it matches a string, it never looks one up), no re-ranking, no
|
||||
rule about `.reeselink.com` — a query can never invent a route, because there is no other text
|
||||
to match.
|
||||
* **It costs nothing.** The repaint runs on `state.snapshot`; a keystroke that re-fetched would be
|
||||
a poll per character and a lie about what changed.
|
||||
* **It hides rows, it does not remove them.** `li.hidden` in the patch pass, so a route the query
|
||||
@@ -6498,18 +6766,27 @@ def test_the_filter_narrows_the_map_without_asking_anything(client, gateway_clie
|
||||
"""
|
||||
js = _code(client.get("/static/page-gateway.js").text)
|
||||
|
||||
# (1) the matcher's whole vocabulary: five payload fields for a route, three for a trunk.
|
||||
# (1) the matcher's whole vocabulary: six payload fields for a route, three for a trunk.
|
||||
hay = js[js.index("function haystack("):js.index("function trunkHaystack(")]
|
||||
assert "String((conn.in || {}).port ?? '')" in hay, \
|
||||
"the inbound port is no longer searchable — `4443` is the second thing an operator types"
|
||||
for field in ("out.host", "out.name", "out.kind", "out.selectors"):
|
||||
for field in ("out.host", "out.name", "out.kind", "out.selectors", "out.resolved"):
|
||||
assert field in hay, f"the filter no longer sees {field}"
|
||||
# Phase 32 task 04: an address the **gateway** resolved is payload text like any other, so typing
|
||||
# the far end of a socket finds the route that owns it. Names alone could not answer that question.
|
||||
assert "...(out.resolved || [])" in hay, \
|
||||
"a name-written route stopped being findable by the address it resolves to"
|
||||
assert "toLowerCase()" in hay, "the match became case-sensitive on a page of lowercase domains"
|
||||
for absent in ("conn.connections", "severity", "metric", "getaddrinfo", "split(",
|
||||
"endsWith", "startsWith", "slice("):
|
||||
assert absent not in hay, (
|
||||
f"the matcher reached for {absent} — it may read what the payload calls a route, never "
|
||||
"what it reports about it, and never resolve a name")
|
||||
# …and the three things that would stop it being an eye over the document in hand: a dial, a read
|
||||
# of what is painted rather than of what the payload says, and a literal `null` a query could
|
||||
# match against a row that published nothing.
|
||||
for not_here in ("fetchJSON", "textContent", "innerText", '"null"', "'null'"):
|
||||
assert not_here not in hay, f"the haystack reached for {not_here}"
|
||||
trunk = js[js.index("function trunkHaystack("):js.index("function filtering(")]
|
||||
for field in ("group.port", "listener.domains", "listener.clients"):
|
||||
assert field in trunk, f"the trunk no longer answers for {field}"
|
||||
@@ -6616,6 +6893,25 @@ def test_the_filter_narrows_the_map_without_asking_anything(client, gateway_clie
|
||||
assert all(not leaf["hidden"] for leaf in home["leaves"]), \
|
||||
"a trunk that matched lost leaves of its fan-out"
|
||||
|
||||
# (7) phase 32 task 04: an address the **gateway** resolved is searchable too, which is the field
|
||||
# an operator reaches for when they are holding a socket rather than a domain. Over the 1.2.0
|
||||
# document there is nothing to match (no resolver section, and the page resolves nothing), so
|
||||
# this leg runs on the recorded 1.3.0 one.
|
||||
placed = gateway_source.parse(gateway_snapshot("resolved"), "gateway")
|
||||
assert not filter_view(payload, "10.1.2.64")["standing"], \
|
||||
"the page found an address it was never given — it resolves nothing, and must keep not doing it"
|
||||
brain_ip = filter_view(placed, "10.1.2.64")
|
||||
found = [leaf["id"] for group in brain_ip["groups"] for leaf in group["leaves"]
|
||||
if not leaf["hidden"]]
|
||||
assert found and all("reeseapps-brain-of-reese" in route_id for route_id in found), \
|
||||
f"typing the brain's address left {found}"
|
||||
assert all(group["hidden"] or group["port"] == 443 for group in brain_ip["groups"]), \
|
||||
"an address match let a trunk of another port stand"
|
||||
shared_ip = filter_view(placed, "10.1.1.78")
|
||||
expected = sum(1 for row in placed["connections"] if "10.1.1.78" in (row["out"]["resolved"] or []))
|
||||
assert expected > 1 and shared_ip["standing"] == expected, (
|
||||
f"one address reached by {expected} routes was found by {shared_ip['standing']} of them")
|
||||
|
||||
|
||||
def test_the_filter_field_is_markup_the_sheet_and_nobody_else_uses(client) -> None:
|
||||
"""The field is pinned markup, on one page, wearing the sheet's one field skin.
|
||||
@@ -6972,19 +7268,39 @@ def test_the_page_says_where_a_line_comes_from_and_where_its_count_cannot(client
|
||||
"""Four footnotes, one per claim the page is allowed to make, and not one more.
|
||||
|
||||
Why the page draws a line at all (the config declares the route) and what its motion means (a
|
||||
socket is open this instant, so a resting line is not a dead one); why an endpoint written as a
|
||||
**name** carries no number (nothing here resolves one, so a dash is not a fault); what the switch
|
||||
does **not** do (toggling a route needs a config write path this app does not have); and — since
|
||||
phase 31 task 04 — what the filter box does to the map and what it leaves alone (the tallies, the
|
||||
warnings and the charts keep describing the whole ingress, because the field asks for nothing).
|
||||
Each lives in the footer exactly once — the page does not repeat a why-line per panel, and it
|
||||
does not need to: an uncounted line prints `—` and the footnote is the sentence that explains it
|
||||
socket is open this instant, so a resting line is not a dead one); why a route carries no number
|
||||
(phase 32 task 04: **the gateway could not resolve that name in this reading** — the gateway does
|
||||
the resolving, so the sentence blames the reading and points at the hover that shows the address a
|
||||
counted line was matched to, instead of blaming the config for having written a name or the page
|
||||
for having no resolver); what the switch does **not** do (toggling a route needs a config write
|
||||
path this app does not have); and — since phase 31 task 04 — what the filter box does to the map
|
||||
and what it leaves alone (the tallies, the warnings and the charts keep describing the whole
|
||||
ingress, because the field asks for nothing).
|
||||
Each lives in the footer exactly once — the page does not repeat a why-line per panel, and it does
|
||||
not need to: an uncounted line prints `—` and the footnote is the sentence that explains it
|
||||
(rule 2).
|
||||
"""
|
||||
html = client.get(GATEWAY_PAGE).text
|
||||
footer = html[html.index("<footer"):]
|
||||
assert footer.count("resolves nothing") == 1, (
|
||||
"the no-DNS rule is the page's single footnote about a missing count — printed once")
|
||||
assert footer.count("a dash means") == 1, (
|
||||
"the page's single footnote about a missing count, printed once, in the phase-32 reason")
|
||||
assert "could not resolve that name" in footer, (
|
||||
"a dash means the gateway's reading came back with nothing, not that the config wrote a name")
|
||||
# The two sentences phase 32 made stale may not come back from either file: the blame on the page
|
||||
# (`the page knows no`) and its no-DNS limit as a per-row excuse, which task 04 replaced with the
|
||||
# thing to do about a dash rather than a reason to accept it.
|
||||
assert "the page knows no" not in footer, (
|
||||
"the dash stopped blaming the gateway's reading and went back to blaming the page")
|
||||
# The served source wraps this paragraph, so the two halves are matched separately: what is
|
||||
# pinned is that the sentence names a cause **and** the place to look.
|
||||
flat = " ".join(footer.split())
|
||||
assert "hover a counted line for the address it matched" in flat, (
|
||||
"the footnote names a cause and no longer says where the address and its age are")
|
||||
script = client.get("/static/page-gateway.js").text
|
||||
assert "the gateway could not resolve this name in this reading" in script, (
|
||||
"the per-row tooltip must say the same thing the footnote says")
|
||||
assert "nothing here resolves names" not in script, (
|
||||
"the old tooltip sentence blames the name, which is no longer why a line has no count")
|
||||
assert "animates" in footer, "the animation rule lost its footnote"
|
||||
band = footer[:footer.index("</footer>")]
|
||||
# Four paragraphs and nothing else: one sentence per claim, in one ink band under the panels.
|
||||
@@ -7054,6 +7370,21 @@ def test_the_gateway_page_renders_each_degradation_arm_in_the_payloads_own_words
|
||||
assert sum(1 for field in ("listeners", "connections") if stub[field]) == 0, (
|
||||
"the stub arm's rows are not empty, so the header's `n/a` would not be about the same thing")
|
||||
|
||||
# (5) a **resolver** that did not answer (agent 1.2.0, `--no-resolve`, or a pass that could not
|
||||
# run) is the fifth arm and the quietest: the section is not one of the two config files, so
|
||||
# it raises no banner and no colour, and every name-written line simply carries the dash with
|
||||
# the resolution sentence. `full` is that document — no `resolver` key at all.
|
||||
assert silent["resolver"]["available"] is None and silent["resolver"]["entries"] == [], (
|
||||
"the `full` capture is supposed to be the no-resolver arm this case measures")
|
||||
assert silent["why"] is None, "an absent resolver section became a page-wide complaint"
|
||||
assert "severity" not in json.dumps(silent), "the fifth arm arrived with a verdict on it"
|
||||
dashes = [row for row in silent["connections"] if row["connections"] is None]
|
||||
assert dashes, "a document with no resolver counted a name-written route"
|
||||
assert all(count_surfaces(row)["why"] == COUNT_WHY_UNRESOLVED for row in dashes), \
|
||||
"a line nobody resolved is being explained by something other than resolution"
|
||||
assert not any(count_surfaces(row)["glyph"] == "0" for row in dashes), \
|
||||
"the uncounted became a claimed zero"
|
||||
|
||||
|
||||
def test_the_gateway_page_keeps_its_last_drawing_when_a_poll_fails(client) -> None:
|
||||
"""Invariant 4 on the fifth page: a failed read leaves the last picture on screen.
|
||||
@@ -7072,6 +7403,43 @@ def test_the_gateway_page_keeps_its_last_drawing_when_a_poll_fails(client) -> No
|
||||
"and the reason has to be printed where the operator is already looking"
|
||||
|
||||
|
||||
COUNT_WHY_UNRESOLVED = "the gateway could not resolve this name in this reading"
|
||||
COUNT_WHY_NO_SOCKETS = "the socket table did not answer this reading"
|
||||
|
||||
|
||||
def count_surfaces(row: dict[str, Any]) -> dict[str, Any]:
|
||||
"""The count column's two strings for one route, as `countWhy` / `countTitle` build them.
|
||||
|
||||
A **second** reading of the same decision, with the served-byte pins below as its other half:
|
||||
|
||||
* `glyph` — the em dash for a null, the number otherwise (`0` included, because a resolved
|
||||
endpoint with no socket is a measurement and not a gap);
|
||||
* `why` — for a null, which of the two unknowns the payload describes: an address is **known**
|
||||
when the config wrote one (`out.address is True`) or the gateway published one
|
||||
(`out.resolved` non-empty), and a row with neither is a name nothing could resolve;
|
||||
* `title` — for a counted row, the number plus the provenance the tooltip owes it. The **age**
|
||||
fragment is left out here and pinned as a prefix instead, because it is a clock: the test
|
||||
asserts what comes before it and what the remainder looks like.
|
||||
"""
|
||||
out = row["out"]
|
||||
shown = row["connections"]
|
||||
if shown is None:
|
||||
known = out["address"] is True or bool(out["resolved"])
|
||||
why = COUNT_WHY_NO_SOCKETS if known else COUNT_WHY_UNRESOLVED
|
||||
return {"glyph": "\u2014", "why": why, "title": f"no number: {why}",
|
||||
"aria": f"connections not attributed: {why}", "age": False}
|
||||
title = f"{shown} open connection(s) on this endpoint"
|
||||
via = out["via"] if isinstance(out["via"], str) and out["via"] else None
|
||||
if out["resolved"] is None or via is None:
|
||||
return {"glyph": str(shown), "why": None, "title": title, "age": False,
|
||||
"aria": f"{shown} live connections"}
|
||||
title += f" \u00b7 address from {via}"
|
||||
# `Dash.duration(Dash.ageSeconds(ts))`, whose text is a clock and therefore not this mirror's to
|
||||
# reproduce — only the *decision* to print it is.
|
||||
return {"glyph": str(shown), "why": None, "title": title,
|
||||
"age": out["resolved_ts"] is not None, "aria": f"{shown} live connections"}
|
||||
|
||||
|
||||
def rendered_leaf(row: dict[str, Any]) -> dict[str, str]:
|
||||
"""The two lines and the two long strings one leaf row prints, derived as a **second** reading.
|
||||
|
||||
@@ -7082,6 +7450,10 @@ def rendered_leaf(row: dict[str, Any]) -> dict[str, str]:
|
||||
`leafAria` compose those same two from the literals asserted verbatim below. A rename on either
|
||||
side therefore fails a pin **and** changes what this mirror prints, which is the pair of
|
||||
guarantees a page with no test runner can actually make.
|
||||
|
||||
The row's `aria-label` ends with the count's claim, and phase 32 task 04 made that claim carry its
|
||||
cause: `count_surfaces()` is the one place that sentence is assembled, so the row and the count
|
||||
element cannot disagree about which unknown a dash is.
|
||||
"""
|
||||
out = row["out"]
|
||||
host = out["host"] or "?"
|
||||
@@ -7099,8 +7471,7 @@ def rendered_leaf(row: dict[str, Any]) -> dict[str, str]:
|
||||
if row["shared"] is True:
|
||||
bits.append("this endpoint is shared with another route")
|
||||
bits.append("in the config")
|
||||
bits.append("connections not attributed" if row["connections"] is None
|
||||
else f"{row['connections']} live connections")
|
||||
bits.append(count_surfaces(row)["aria"])
|
||||
return {"line1": inbound,
|
||||
"line2": f"→ {endpoint}" + (f" · {kind}" if kind else ""),
|
||||
"title": pair + shared,
|
||||
@@ -7440,7 +7811,7 @@ def test_the_stream_map_is_rendered_inside_nginx_and_nowhere_else(
|
||||
|
||||
Pinned from both ends: the script ships exactly the two panel definitions and never indexes
|
||||
`nginx.map_targets`, and the live document's map connections are all nginx's, all on one port,
|
||||
and exactly as many as the map has routing entries (17 on this capture — 16 `*.reeselink.com`
|
||||
and exactly as many as the map has routing entries (18 on this capture — 17 `*.reeselink.com`
|
||||
plus the `default` the file writes for SNI nobody mapped).
|
||||
"""
|
||||
html = _code(client.get(GATEWAY_PAGE).text)
|
||||
@@ -7570,22 +7941,31 @@ def test_warnings_render_in_the_overviews_alert_vocabulary_and_hide_when_clean(
|
||||
|
||||
|
||||
def test_an_unattributed_endpoint_prints_a_dash_and_never_a_zero(client, gateway_client) -> None:
|
||||
"""A leaf the join cannot count prints `—`, and the tooltip says why in one sentence.
|
||||
"""A leaf the join cannot count prints `—`, and the tooltip says **which** unknown it is.
|
||||
|
||||
Sixteen of this fleet's 17 map entries name a **domain**, and `/proc` reports addresses, so no
|
||||
count exists for them and none can ever exist (nothing here resolves a name). `0` would be a
|
||||
claim about traffic (rule 2) and a column of `n/a` would be twelve identical words competing with
|
||||
the page's one footnote — so the leaf prints an em dash whose `title` names the reason, while the
|
||||
trunk keeps `n/a`, which is a different fact: the socket table itself went quiet.
|
||||
`/proc` reports addresses, so a route whose far end has no address cannot be counted, and `0` would
|
||||
be a claim about traffic (rule 2) while a column of `n/a` would be twelve identical words
|
||||
competing with the page's one footnote — so the leaf prints an em dash whose `title` names the
|
||||
reason, while the trunk keeps `n/a`, which is a different fact: the socket table itself went quiet.
|
||||
Phase 32 task 04 sharpened the mechanism and not only the wording: the dash now has **two** causes
|
||||
that `countCause` separates from `out.address` / `out.resolved` — a name the gateway could not
|
||||
resolve, and an address that is known beside a table that answered nothing — and the sentence is
|
||||
chosen from data, never guessed. A `0` is neither of them: it is a measurement.
|
||||
"""
|
||||
js = _code(client.get("/static/page-gateway.js").text)
|
||||
head = js[:js.index("function countText(")]
|
||||
assert "const NO_COUNT = '—'" in head, "the unattributed leaf count is no longer an em dash"
|
||||
assert "nothing here resolves names" in head, "the dash stopped explaining itself"
|
||||
assert "the gateway could not resolve this name in this reading" in head, \
|
||||
"the dash stopped explaining itself"
|
||||
assert "function countCause(" in head and "out.address === true" in head \
|
||||
and "out.resolved" in head, \
|
||||
"the dash's cause is no longer read from the two payload fields that decide it"
|
||||
assert "nothing here resolves names" not in head, (
|
||||
"the old sentence blamed the name, which phase 32 task 03 made a false reason")
|
||||
leaf = js[js.index("function renderLeaf("):js.index("function groupElement(")]
|
||||
assert "shown == null ? NO_COUNT : shown" in leaf, "a null count is not the dash any more"
|
||||
assert "count.title = shown == null ? NO_COUNT_WHY" in leaf, \
|
||||
"the dash lost the sentence that separates it from a zero"
|
||||
assert "count.title = shown == null ? countWhy(conn) : countTitle(conn, shown)" in leaf, \
|
||||
"the dash lost its sentence, or a counted line lost the provenance beside its number"
|
||||
trunk = js[js.index("function renderTrunk("):js.index("function renderLeaves(")]
|
||||
assert "live" in trunk and "n/a" not in trunk, (
|
||||
"the trunk now prints the leaf's dash too — a quiet socket table is a different fact, and "
|
||||
@@ -7600,6 +7980,164 @@ def test_an_unattributed_endpoint_prints_a_dash_and_never_a_zero(client, gateway
|
||||
"table's arm, not the name arm, and it prints a different sentence")
|
||||
|
||||
|
||||
def test_an_unresolved_name_and_an_idle_route_say_two_different_sentences(
|
||||
client, gateway_snapshot: Any) -> None:
|
||||
"""The em dash and the measured `0` are two facts, and every surface of the row says so.
|
||||
|
||||
Phase 32 task 03 made a name-written route countable, which left the page's one dash sentence
|
||||
explaining two different things: a name the gateway could not resolve, and a route that **was**
|
||||
resolved and simply has nothing open. Task 04 splits them, and the split has to be on every
|
||||
surface a reader can meet — the glyph, the `title`, the `aria-label` — because a `0` read as a
|
||||
missing measurement (or a dash read as an idle route) sends the operator to the wrong box.
|
||||
|
||||
Three arms, all from the recorded capture and one injection, all rendered through
|
||||
`count_surfaces` (the mirror) with the served-byte pins below as its other half:
|
||||
|
||||
* **resolved and idle** — `brain.reeseapps.com` reaches
|
||||
`reeseapps-brain-of-reese.reeselink.com:443`, the gateway resolved it to `10.1.2.64`, and the
|
||||
kernel reports no socket: the glyph is `0`, and a `0` is a measurement.
|
||||
* **resolved and busy** — the ntfy route, whose glyph is its own number.
|
||||
* **not resolved** — one entry of the recorded document emptied and marked `nxdomain`, exactly
|
||||
what agent 1.3.0 publishes for a name with no record: the glyph is the em dash and the sentence
|
||||
is the resolution one, never the socket one.
|
||||
|
||||
And a fourth, because the third arm's sentence must not become a catch-all: with the kernel's
|
||||
table unreadable, a line whose address the **config wrote** is uncounted too, and blaming
|
||||
resolution there would be false — so it gets the socket-table sentence.
|
||||
"""
|
||||
js = _code(client.get("/static/page-gateway.js").text)
|
||||
head = js[: js.index("function countText(")]
|
||||
|
||||
# (1) the two sentences exist, differ, name no `n/a`, and neither is the other's wording.
|
||||
assert "const WHY_UNRESOLVED = 'the gateway could not resolve this name in this reading'" in head
|
||||
assert "const WHY_NO_SOCKETS = 'the socket table did not answer this reading'" in head
|
||||
assert COUNT_WHY_UNRESOLVED != COUNT_WHY_NO_SOCKETS
|
||||
for sentence in (COUNT_WHY_UNRESOLVED, COUNT_WHY_NO_SOCKETS):
|
||||
assert "n/a" not in sentence, "an unknown that has a cause stopped naming its cause"
|
||||
# The choice is data: the same two fields `sources/gateway.py` joins on, and nothing else.
|
||||
chooser = js[js.index("function countCause("):js.index("function countWhy(")]
|
||||
assert "out.address === true" in chooser and "out.resolved" in chooser, \
|
||||
"the dash's cause is no longer chosen from the address the config wrote or the gateway found"
|
||||
assert "countCause(conn)" in js[js.index("function leafAria("):js.index("function leafTitle(")], \
|
||||
"the `aria-label` no longer carries the cause the tooltip carries"
|
||||
|
||||
# (2) the three arms, rendered.
|
||||
resolved = gateway_source.parse(gateway_snapshot("resolved"), "gateway")
|
||||
|
||||
def route(host: str, port: int) -> dict[str, Any]:
|
||||
return next(row for row in resolved["connections"]
|
||||
if row["out"]["host"] == host and row["in"]["port"] == port)
|
||||
|
||||
idle = count_surfaces(route("reeseapps-brain-of-reese.reeselink.com", 443))
|
||||
busy = count_surfaces(route("reeseapps-ntfy.reeselink.com", 443))
|
||||
assert idle["glyph"] == "0", "a resolved endpoint with nothing open is a measurement, not a gap"
|
||||
assert busy["glyph"] == "3" and busy["why"] is None
|
||||
assert idle["title"].startswith("0 open connection(s) on this endpoint") \
|
||||
and busy["title"].startswith("3 open connection(s) on this endpoint")
|
||||
|
||||
broken = gateway_snapshot("resolved")
|
||||
for entry in broken["resolver"]["entries"]:
|
||||
if entry["name"] == "reeseapps-immich.reeselink.com":
|
||||
entry["addresses"] = []
|
||||
entry["status"] = "nxdomain"
|
||||
unplaced = gateway_source.parse(broken, "gateway")
|
||||
dash = count_surfaces(next(row for row in unplaced["connections"]
|
||||
if row["out"]["host"] == "reeseapps-immich.reeselink.com"))
|
||||
assert dash["glyph"] == "\u2014", "a name with no record grew someone else's number"
|
||||
assert dash["why"] == COUNT_WHY_UNRESOLVED and dash["title"] == f"no number: {dash['why']}"
|
||||
assert dash["aria"] == f"connections not attributed: {COUNT_WHY_UNRESOLVED}"
|
||||
|
||||
# (3) the fourth arm: an address the config wrote, beside a kernel that said nothing.
|
||||
quiet = gateway_source.parse(gateway_snapshot("sockets_silent"), "gateway")
|
||||
unwritten = count_surfaces(next(row for row in quiet["connections"]
|
||||
if row["out"]["address"] is True))
|
||||
assert unwritten["glyph"] == "\u2014" and unwritten["why"] == COUNT_WHY_NO_SOCKETS, (
|
||||
"a line whose address the config wrote was told to go resolve something — the socket table "
|
||||
"is what went quiet, and that is the sentence the trunk already prints as `n/a`")
|
||||
|
||||
# (4) five lines read aloud: distinct claims, no invented number, no `null` leaking as text.
|
||||
spoken = [count_surfaces(row) for row in resolved["connections"][:5]]
|
||||
claims = {entry["title"] for entry in spoken} | {entry["aria"] for entry in spoken}
|
||||
assert len(claims) > 1, "five lines said one thing"
|
||||
for entry in [*spoken, idle, busy, dash, unwritten]:
|
||||
for text in (entry["glyph"], entry["title"] or "", entry["aria"]):
|
||||
assert "undefined" not in text and "NaN" not in text and "null" not in text, \
|
||||
f"a row printed {text!r}"
|
||||
|
||||
|
||||
def test_a_counted_line_names_where_its_address_came_from(client, gateway_snapshot: Any,
|
||||
resolved_gateway_client) -> None:
|
||||
"""Every counted line says, on hover, which address it was counted to and when that was learned.
|
||||
|
||||
A number that arrived through the gateway's own `libc` resolution is a different claim from one
|
||||
the config wrote as an address, and the tooltip is the one place both can say it without growing
|
||||
a column of ink per row. Three arms, one rule: the provenance is the payload's (`out.via`,
|
||||
`out.resolved_ts`) copied, never paraphrased, and a field that is not there prints **nothing**
|
||||
rather than `n/a` or `NaN`.
|
||||
|
||||
* a **resolved name** — `… · address from libc`, and `, resolved <age>` while the entry has a
|
||||
timestamp (the age is `Dash.duration(Dash.ageSeconds(…))`, so its text is a clock and only its
|
||||
presence is pinned here);
|
||||
* the same row with `resolved_ts` removed — the age fragment goes, the rest of the sentence
|
||||
stays, and nothing prints the shared `n/a` where an age belongs;
|
||||
* an **address the config wrote** — the bare sentence. It was never resolved, and stamping the
|
||||
agent's `literal` echo of it as a lookup would be rule 2 with a tooltip's clothes on.
|
||||
"""
|
||||
js = _code(client.get("/static/page-gateway.js").text)
|
||||
builder = js[js.index("function countTitle("):js.index("const NO_TARGET")]
|
||||
for field in ("out.via", "out.resolved", "out.resolved_ts"):
|
||||
assert field in builder, f"the counted line's tooltip stopped reading {field}"
|
||||
for helper in ("duration(", "ageSeconds("):
|
||||
assert helper in builder, f"the age stopped going through the shared helper {helper}"
|
||||
assert "out.resolved_ts == null" in builder, \
|
||||
"a missing timestamp now prints whatever duration() makes of Infinity"
|
||||
assert "address from" in builder and "resolved " in builder
|
||||
assert "n/a" not in builder and "\u2014" not in builder, \
|
||||
"the counted tooltip grew a fallback glyph; a missing fragment is silence, not a mark"
|
||||
|
||||
resolved = gateway_source.parse(gateway_snapshot("resolved"), "gateway")
|
||||
brain = next(row for row in resolved["connections"]
|
||||
if row["out"]["host"] == "reeseapps-brain-of-reese.reeselink.com")
|
||||
printed = count_surfaces(brain)
|
||||
assert printed["title"] == "0 open connection(s) on this endpoint \u00b7 address from libc"
|
||||
assert printed["age"] is True
|
||||
served = client.get("/static/page-gateway.js").text
|
||||
assert "\u00b7 address from ${via}" in served and ", resolved ${age}`" in served, \
|
||||
"the two provenance fragments are no longer composed from the payload's own words"
|
||||
|
||||
aged = dict(brain, out=dict(brain["out"], resolved_ts=None))
|
||||
lost = count_surfaces(aged)
|
||||
assert lost["title"] == printed["title"], "losing the timestamp lost the address it came from"
|
||||
assert lost["age"] is False, "an age was printed for a row that publishes no timestamp"
|
||||
assert "NaN" not in lost["title"] and "n/a" not in lost["title"]
|
||||
|
||||
written = next(row for row in resolved["connections"] if row["out"]["address"] is True)
|
||||
plain = count_surfaces(written)
|
||||
assert plain["title"] == f"{written['connections']} open connection(s) on this endpoint", \
|
||||
"an address the config wrote was given a resolution provenance it never had"
|
||||
assert "address from" not in plain["title"]
|
||||
|
||||
# …and the field table, in **both** directions (the `SLOT_FIELDS` device): the three names the
|
||||
# tooltip and the filter index are named there, and the 1.3.0 document answers each with real
|
||||
# data, so a rename in `sources/gateway.py` fails by name here instead of printing a tooltip that
|
||||
# quietly stopped carrying its provenance.
|
||||
provenance = ("connections.out.resolved", "connections.out.via",
|
||||
"connections.out.resolved_ts")
|
||||
live = resolved_gateway_client.get("/api/gateway/snapshot").json()
|
||||
for path in provenance:
|
||||
assert path in GATEWAY_PAGE_FIELDS, (
|
||||
f"the counted line reads {path.removeprefix('connections.')}, which the page's field "
|
||||
"table does not name")
|
||||
found, values = _payload_field(live, path)
|
||||
assert found and any(value is not None for value in values), (
|
||||
f"{path} is named but the resolved document never answers it, so the pin above would "
|
||||
"pass over a payload that carries nothing")
|
||||
assert any(value is None for path in provenance
|
||||
for value in _payload_field(live, path)[1]), (
|
||||
"every row published provenance, including the addresses the config wrote — the arm that "
|
||||
"must print nothing is no longer distinguishable")
|
||||
|
||||
|
||||
def test_the_docs_name_the_page_its_knob_and_its_gauges() -> None:
|
||||
"""The phase's own completion criterion, pinned so it cannot rot quietly.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user