**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/`.
1420 lines
80 KiB
Python
1420 lines
80 KiB
Python
"""`sources/gateway.py`: the two config files plus the socket table, joined into a connection map.
|
|
|
|
The shape half of these tests runs the **captured** gateway configs and `/proc` tables through
|
|
the real agent `Collector` (the `gateway_snapshot` fixture), so a rename of a section key on
|
|
either side of the wire fails here rather than drawing an empty pipe on a live page. The
|
|
arithmetic half uses synthetic sections, because the point of those tests is which socket lands
|
|
on which pipe and what the source refuses to decide — facts a capture states once and a
|
|
re-capture may lawfully change (the same rule `tests/test_agent.py` holds for the socket tables).
|
|
|
|
Phase 30 task 02 replaced the guessing join with two sound attributions — a **local port** for
|
|
inbound, an exact **(address, port)** for outbound — and deleted the rest. So the tests for
|
|
`by_port`, for `unmatched`, for the ambiguous-fan-out group list and for the flat `outbound` pipe
|
|
list are gone with the code they described, and what replaces them pins the arithmetic the new
|
|
join still owes: a socket counted **at most** once, a total that never exceeds the kernel's, and an
|
|
endpoint shared by several lines counted once. There is deliberately **no** test that a connection
|
|
outside the two configs is *reported*, because it no longer is —
|
|
`test_a_socket_neither_config_describes_is_counted_nowhere` pins the silence instead.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import socket as socket_module
|
|
from collections.abc import Mapping, Sequence
|
|
from typing import Any
|
|
|
|
from dashboards.sources import gateway
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# synthetic builders: a section exactly as the agent serves it, minus the file
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
def bind(port: int | None, addr: str = "::") -> dict[str, Any]:
|
|
return {"addr": addr, "port": port}
|
|
|
|
|
|
def haproxy_section(frontends: Sequence[dict[str, Any]],
|
|
backends: Sequence[dict[str, Any]] = (), *,
|
|
available: bool | None = True) -> dict[str, Any]:
|
|
return {"available": available, "config": "/etc/haproxy/haproxy.cfg",
|
|
"frontends": list(frontends), "backends": list(backends)}
|
|
|
|
|
|
def frontend(name: str, ports: Sequence[int | None], *, default: str | None = None,
|
|
use: Sequence[str] = ()) -> dict[str, Any]:
|
|
return {"name": name, "binds": [bind(port) for port in ports],
|
|
"default_backend": default, "backends": list(use)}
|
|
|
|
|
|
def backend(name: str, servers: Sequence[dict[str, Any]]) -> dict[str, Any]:
|
|
return {"name": name, "servers": list(servers)}
|
|
|
|
|
|
def server(name: str, target: str | None, port: int | None = None) -> dict[str, Any]:
|
|
return {"name": name, "target": target, "port": port}
|
|
|
|
|
|
def nginx_section(servers: Sequence[dict[str, Any]] = (), maps: Sequence[dict[str, Any]] = (), *,
|
|
upstreams: Sequence[dict[str, Any]] = (),
|
|
available: bool | None = True) -> dict[str, Any]:
|
|
return {"available": available, "config": "/etc/nginx/nginx.conf", "files": [],
|
|
"servers": list(servers), "upstreams": list(upstreams), "maps": list(maps)}
|
|
|
|
|
|
def upstream(name: str, members: Sequence[tuple[str, int | None]]) -> dict[str, Any]:
|
|
"""An `upstream <name> { server …; }` block, as the agent publishes it (`{host, port}`)."""
|
|
return {"name": name, "servers": [{"host": host, "port": port} for host, port in members]}
|
|
|
|
|
|
def pool_pass(name: str) -> dict[str, Any]:
|
|
"""`proxy_pass http://<name>;` — a target with **no** port, which is what makes it a pool."""
|
|
return {"location": "/", "scheme": "http", "target": {"host": name, "port": None},
|
|
"variable": None}
|
|
|
|
|
|
def nginx_server(ports: Sequence[int | None], names: Sequence[str] = (),
|
|
passes: Sequence[dict[str, Any]] = ()) -> dict[str, Any]:
|
|
return {"listen": [bind(port, addr="") for port in ports], "server_names": list(names),
|
|
"proxy_passes": list(passes)}
|
|
|
|
|
|
def proxy_pass(target: tuple[str, int] | None, *, variable: str | None = None,
|
|
location: str | None = "/") -> dict[str, Any]:
|
|
host, port = target if target else (None, None)
|
|
return {"location": location, "scheme": "http" if target else None,
|
|
"target": None if host is None else {"host": host, "port": port},
|
|
"variable": variable}
|
|
|
|
|
|
def mapping_block(variable: str,
|
|
entries: Sequence[tuple[str, tuple[str, int | None] | None]]) -> dict[str, Any]:
|
|
return {"variable": variable,
|
|
"entries": [{"key": key,
|
|
"target": None if target is None else {"host": target[0],
|
|
"port": target[1]}}
|
|
for key, target in entries]}
|
|
|
|
|
|
def sockets_section(listens: Sequence[dict[str, Any]] = (),
|
|
established: Sequence[dict[str, Any]] = (), *,
|
|
available: bool | None = True) -> dict[str, Any]:
|
|
return {"available": available, "listens": list(listens), "established": list(established)}
|
|
|
|
|
|
def listen(port: int, addr: str = "0.0.0.0", family: str = "v4") -> dict[str, Any]:
|
|
return {"family": family, "addr": addr, "port": port}
|
|
|
|
|
|
def conn(local: tuple[str, int], remote: tuple[str, int], *,
|
|
family: str = "v4") -> dict[str, Any]:
|
|
return {"family": family, "local": {"addr": local[0], "port": local[1]},
|
|
"remote": {"addr": remote[0], "port": remote[1]}}
|
|
|
|
|
|
def snapshot(*, haproxy: dict[str, Any] | None = None, nginx: dict[str, Any] | None = None,
|
|
sockets: dict[str, Any] | None = None, errors: Sequence[str] = (),
|
|
agent_version: str | None = "1.2.0") -> dict[str, Any]:
|
|
"""The document one agent `/snapshot` would answer, with only what a test names."""
|
|
doc: dict[str, Any] = {"ok": True, "schema": 1, "ts": 1_760_000_000.0, "host": "gateway",
|
|
"errors": list(errors)}
|
|
if agent_version is not None:
|
|
doc["agent_version"] = agent_version
|
|
if haproxy is not None:
|
|
doc["haproxy"] = haproxy
|
|
if nginx is not None:
|
|
doc["nginx"] = nginx
|
|
if sockets is not None:
|
|
doc["sockets"] = sockets
|
|
return doc
|
|
|
|
|
|
def listener_at(payload: Mapping[str, Any], port: int) -> dict[str, Any]:
|
|
"""The trunk row for one port, or a failure naming the ports the payload does have."""
|
|
for row in payload["listeners"]:
|
|
if row["port"] == port:
|
|
return row
|
|
raise AssertionError(f"no listener on port {port}: {[r['port'] for r in payload['listeners']]}")
|
|
|
|
|
|
def routes_to(payload: Mapping[str, Any], host: str) -> list[dict[str, Any]]:
|
|
"""Every route line whose outbound endpoint is `host`, in the graph's own order."""
|
|
return [row for row in payload["connections"] if row["out"]["host"] == host]
|
|
|
|
|
|
def endpoint_count(payload: Mapping[str, Any], host: str, port: int) -> Any:
|
|
"""The count the payload attributes to one **(address, port)** — once, not per line.
|
|
|
|
Asserts the invariant that makes `shared` meaningful while returning the number: every line
|
|
reaching one endpoint carries the *same* count, because that count belongs to the endpoint. A
|
|
join that split it across the lines would fail here rather than print a route carrying a
|
|
fraction nobody measured.
|
|
"""
|
|
rows = [row for row in payload["connections"]
|
|
if row["out"]["host"] == host and row["out"]["port"] == port]
|
|
assert rows, f"no route to {host}:{port} in the graph"
|
|
counts = {row["connections"] for row in rows}
|
|
assert len(counts) == 1, f"lines reaching one endpoint disagree: {counts}"
|
|
return counts.pop()
|
|
|
|
|
|
def out_metrics(payload: Mapping[str, Any]) -> list[str]:
|
|
"""The outbound gauge names the payload would publish — one per **distinct** endpoint."""
|
|
return [row["metric"] for row in payload["connections"] if row["metric"] is not None]
|
|
|
|
|
|
def walk_keys(node: Any) -> set[str]:
|
|
"""Every key name anywhere in a payload — the device the "no severity / no window" pins use."""
|
|
if isinstance(node, Mapping):
|
|
found = set(node.keys())
|
|
for value in node.values():
|
|
found |= walk_keys(value)
|
|
return found
|
|
if isinstance(node, Sequence) and not isinstance(node, str | bytes):
|
|
return set().union(*(walk_keys(item) for item in node)) if node else set()
|
|
return set()
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# the captured gateway: every configured pipe, from the real agent code path
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
def test_the_captured_configs_yield_every_configured_pipe(gateway_snapshot: Any) -> None:
|
|
"""Six haproxy frontends, four nginx listen ports, the stream map's fan-out — all present."""
|
|
payload = gateway.parse(gateway_snapshot("full"), "gateway")
|
|
assert payload["configured"] is True
|
|
assert payload["supported"] is True
|
|
assert payload["why"] is None, "a gateway whose every section answered gets no why-line"
|
|
assert payload["sections"] == {"haproxy": True, "nginx": True, "sockets": True}
|
|
|
|
haproxy_ports = [row["port"] for row in payload["haproxy"]["frontends"]]
|
|
assert haproxy_ports == [22, 2000, 5201, 8080, 25565, 25566], "config order, one row per bind"
|
|
assert {row["backend"] for row in payload["haproxy"]["frontends"]} == {
|
|
"gitea-2222", "sec-gitea-2000", "iperf3-5201", "unifi-external-8080",
|
|
"testcraft-25565", "nimcraft-25566"}
|
|
|
|
assert [row["port"] for row in payload["nginx"]["servers"]] == [80, 443, 4443, 8443]
|
|
names = {row["port"]: row["server_names"] for row in payload["nginx"]["servers"]}
|
|
assert names[80] == ["_"], "the catch-all is the config's own text, kept verbatim"
|
|
assert names[443] == [], "the stream server routes by SNI, so it names no domain of its own"
|
|
assert "nextcloud.reeseapps.com" in names[4443] and "gitea.reeseapps.com" in names[4443], (
|
|
"five vhosts share 4443 and their domains belong to the one pipe for that port")
|
|
|
|
assert payload["nginx"]["map_targets"], "the stream map is the fan-out the page draws"
|
|
assert len(payload["nginx"]["map_targets"]) == 17
|
|
homepage = next(row for row in payload["nginx"]["map_targets"]
|
|
if row["key"] == "homepage.reeseapps.com")
|
|
assert homepage == {"key": "homepage.reeseapps.com",
|
|
"host": "reeseapps-homepage.reeselink.com", "port": 443}
|
|
|
|
|
|
def test_the_source_reads_the_keys_the_agent_actually_publishes(gateway_snapshot: Any,
|
|
host_agent_module: Any) -> None:
|
|
"""The coupling is pinned: `binds`/`port`, `servers`/`target`, `listen`/`server_names`.
|
|
|
|
The fixture runs the real parser, so this compares the source's output against the agent's
|
|
own document rather than against a copy of its field names — which is what makes a rename
|
|
fail here instead of printing `n/a` beside a healthy gateway (AGENTS.md rule 7).
|
|
"""
|
|
snap = gateway_snapshot("full")
|
|
payload = gateway.parse(snap, "gateway")
|
|
agent_bind_ports = {bind["port"] for frontend in snap["haproxy"]["frontends"]
|
|
for bind in frontend["binds"]}
|
|
assert {row["port"] for row in payload["haproxy"]["frontends"]} == agent_bind_ports
|
|
agent_servers = {(row["name"], row["target"], row["port"])
|
|
for block in snap["haproxy"]["backends"] for row in block["servers"]}
|
|
assert {(row["out"]["name"], row["out"]["host"], row["out"]["port"])
|
|
for row in payload["connections"] if row["source"] == "haproxy"} == agent_servers
|
|
agent_listen_ports = {listen["port"] for server in snap["nginx"]["servers"]
|
|
for listen in server["listen"]}
|
|
assert {row["port"] for row in payload["nginx"]["servers"]} == agent_listen_ports
|
|
# and the three section names themselves, as the snapshot publishes them
|
|
assert {"haproxy", "nginx", "sockets"} <= set(snap)
|
|
assert host_agent_module.AGENT_VERSION == "1.2.0", "the sections these keys belong to"
|
|
|
|
|
|
def test_the_live_counts_reconcile_with_the_socket_table(gateway_snapshot: Any) -> None:
|
|
"""The arithmetic phase 29 task 06 pinned as an equality is now an **inequality**, on purpose.
|
|
|
|
`sum(listeners) + sum(distinct endpoint counts) <= established`. Two things took the equality
|
|
away and neither is a leak:
|
|
|
|
* a socket neither file describes is counted nowhere (the `unmatched` arm is deleted, not
|
|
hidden), and this gateway has plenty of that — the agent's own `:9120`, the outbound SSH and
|
|
NTP-style sessions, the loopback pairs;
|
|
* the endpoints are summed **once each**, so a line reaching an address another line already
|
|
reached adds nothing a second time.
|
|
|
|
What stays pinned is the direction of the error and the absence of a residual: the reported
|
|
figures are always **under** the kernel's number, and there is no field anywhere that quietly
|
|
absorbs the difference to make the two agree.
|
|
"""
|
|
snap = gateway_snapshot("full")
|
|
payload = gateway.parse(snap, "gateway")
|
|
established = len(snap["sockets"]["established"])
|
|
configured = {row["port"] for row in payload["listeners"]}
|
|
assert configured == {22, 2000, 5201, 8080, 25565, 25566, 80, 443, 4443, 8443}
|
|
assert all(row["port"] in configured for row in payload["listeners"]), (
|
|
"an inbound count on a port no config names cannot exist in this payload")
|
|
in_total = sum(row["connections"] for row in payload["listeners"])
|
|
endpoints = {(row["out"]["host"], row["out"]["port"]): row["connections"]
|
|
for row in payload["connections"] if row["metric"] is not None}
|
|
out_total = sum(value or 0 for value in endpoints.values())
|
|
|
|
assert payload["sockets"] == {"listens": len(snap["sockets"]["listens"]),
|
|
"established": established}
|
|
assert in_total + out_total <= established
|
|
assert in_total + out_total < established, (
|
|
"this capture has traffic outside both config files; if it ever stops having any, this "
|
|
"test is proving nothing and should be re-written against a capture that does")
|
|
assert "unmatched" not in payload and "partial" not in payload
|
|
# the loopback hop is the case that used to double count: `127.0.0.1:4443` is both a listener's
|
|
# remote end and an outbound endpoint, and it may be booked on only one of them
|
|
assert sum(row["connections"] or 0 for row in payload["connections"]) == out_total
|
|
|
|
|
|
def test_a_socket_neither_config_describes_is_counted_nowhere(gateway_snapshot: Any) -> None:
|
|
"""Phase 28's "an unclaimed socket stays visible as one" is **not** a live count.
|
|
|
|
The agent's own `:9120` is listening and dialled by nobody in either file. It stays in
|
|
`sockets.listens` — a raw fact about the host, which is what the page prints beside the pipes —
|
|
and appears in no pipe and no total. That is the whole simplification: an unexplained socket is
|
|
a reason to look at `/system`, not a number to file under `unmatched`.
|
|
"""
|
|
snap = gateway_snapshot("full")
|
|
payload = gateway.parse(snap, "gateway")
|
|
live = {row["port"] for row in snap["sockets"]["listens"]}
|
|
configured = {row["port"] for row in payload["listeners"]}
|
|
assert 9120 in live - configured, "the agent's own port, unclaimed by either config"
|
|
assert all(row["port"] in configured for row in payload["listeners"])
|
|
assert "unmatched" not in payload
|
|
|
|
|
|
def test_every_connection_the_captured_configs_declare_is_drawn(gateway_snapshot: Any) -> None:
|
|
"""The graph as a **set**, so a missing or invented route fails by name.
|
|
|
|
The join this task rewrote stamps counts onto these rows, so the rows themselves are pinned
|
|
here too: 6 haproxy `server` lines, 17 stream-`map` entries (the 16 `*.reeselink.com:443` fan-
|
|
out plus the literal `default` → `127.0.0.1:4443`), and 9 vhost `proxy_pass` targets across
|
|
`:4443` and `:8443`. `:80` is the refusal that lives on this fleet: the catch-all vhost declares
|
|
no `proxy_pass`, so it is a trunk with no connection and no invented line beside it.
|
|
"""
|
|
payload = gateway.parse(gateway_snapshot("full"), "gateway")
|
|
got = {(row["in"]["port"], row["out"]["host"], row["out"]["port"])
|
|
for row in payload["connections"]}
|
|
assert got == {
|
|
# haproxy: one `server` per frontend, the backend name as the selector
|
|
(22, "reeseapps-gitea.reeselink.com", 2222),
|
|
(2000, "stackexpected-gitea.reeselink.com", 2222),
|
|
(5201, "web.reeselink.com", 5201),
|
|
(8080, "unifi-external.reeselink.com", 8080),
|
|
(25565, "minecraft.reeselink.com", 25565),
|
|
(25566, "minecraft.reeselink.com", 25566),
|
|
# nginx `:443`: the stream map's fan-out, `default` kept verbatim
|
|
(443, "127.0.0.1", 4443),
|
|
(443, "reeseapps-brain-of-code.reeselink.com", 443),
|
|
(443, "reeseapps-brain-of-reese.reeselink.com", 443),
|
|
(443, "reeseapps-forgejo.reeselink.com", 443),
|
|
(443, "reeseapps-gitea.reeselink.com", 443),
|
|
(443, "reeseapps-homepage.reeselink.com", 443),
|
|
(443, "reeseapps-immich.reeselink.com", 443),
|
|
(443, "reeseapps-keycloak.reeselink.com", 443),
|
|
(443, "reeseapps-litellm.reeselink.com", 443),
|
|
(443, "reeseapps-ntfy.reeselink.com", 443),
|
|
(443, "reeseapps-openwebui.reeselink.com", 443),
|
|
(443, "stackexpected-gitea.reeselink.com", 443),
|
|
(443, "stackexpected-hedgedoc.reeselink.com", 443),
|
|
(443, "stackexpected-keycloak.reeselink.com", 443),
|
|
(443, "stackexpected-nextcloud.reeselink.com", 443),
|
|
(443, "stackexpected.reeselink.com", 443),
|
|
(443, "wfc.reeselink.com", 443),
|
|
# nginx http vhosts: `:4443` and `:8443`, each with its own `server_name`
|
|
(4443, "bricktracker.reeselink.com", 3333),
|
|
(4443, "emmaleaf.reeselink.com", 80),
|
|
(4443, "gitea.reeselink.com", 3000),
|
|
(4443, "jellyfin.reeselink.com", 8096),
|
|
(4443, "keycloak.reeselink.com", 8080),
|
|
(4443, "nextcloud.reeselink.com", 11000),
|
|
(8443, "127.0.0.1", 8081),
|
|
(8443, "keycloak.reeselink.com", 8080),
|
|
(8443, "nextcloud.reeselink.com", 11001),
|
|
}, "the captured configs declare exactly these routes — no more, no fewer"
|
|
assert len(payload["connections"]) == len(got), "and no two rows collapse into one key"
|
|
|
|
# `default` is the config's own word for "everything nobody mapped", and its endpoint is an
|
|
# address — which is why it is one of the two lines this capture can count at all.
|
|
default = next(row for row in payload["connections"] if row["out"]["selectors"] == ["default"])
|
|
assert (default["out"]["host"], default["out"]["port"]) == ("127.0.0.1", 4443)
|
|
assert default["out"]["address"] is True and default["out"]["kind"] == gateway.KIND_MAP
|
|
|
|
# refusal rule 5 on live config text: a trunk with nothing behind it stays a trunk alone
|
|
assert 80 in {row["port"] for row in payload["listeners"]}
|
|
assert 80 not in {row["in"]["port"] for row in payload["connections"]}, (
|
|
"the `server_name _` vhost declares no proxy_pass, so it draws no connection")
|
|
|
|
|
|
def test_the_map_fanout_is_routes_and_never_a_variable_host(gateway_snapshot: Any) -> None:
|
|
"""Every stream-map entry is a line the page can draw, and `$var` never becomes a host."""
|
|
payload = gateway.parse(gateway_snapshot("full"), "gateway")
|
|
map_routes = [row for row in payload["connections"] if row["out"]["kind"] == "map"]
|
|
assert len(map_routes) == len(payload["nginx"]["map_targets"])
|
|
assert not [row for row in payload["connections"]
|
|
if row["out"]["host"] is None or "$" in str(row["out"]["host"])], (
|
|
"a proxy_pass $var carries no address, so it must not become a line of its own")
|
|
brain = next(row for row in map_routes
|
|
if row["out"]["host"] == "reeseapps-brain-of-reese.reeselink.com")
|
|
assert brain["out"]["address"] is False, "the config wrote a name, and this stack resolves none"
|
|
assert brain["connections"] is None, "so the line carries no count"
|
|
assert brain["metric"] is None, "and publishes no series"
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# the inbound join
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
def test_a_socket_lands_in_the_frontend_that_owns_its_local_port() -> None:
|
|
snap = snapshot(
|
|
haproxy=haproxy_section([frontend("ssh", [22], default="ssh-be")]),
|
|
nginx=nginx_section([nginx_server([443], ["a.example.test"])]),
|
|
sockets=sockets_section(
|
|
listens=[listen(22), listen(443)],
|
|
established=[conn(("10.1.1.27", 443), ("203.0.113.7", 51000)),
|
|
conn(("10.1.1.27", 443), ("203.0.113.9", 51001)),
|
|
conn(("10.1.1.27", 22), ("198.51.100.4", 33000))]))
|
|
payload = gateway.parse(snap, "gateway")
|
|
https = listener_at(payload, 443)
|
|
assert https["source"] == "nginx"
|
|
assert https["connections"] == 2
|
|
assert https["clients"] == ["203.0.113.7", "203.0.113.9"], "the client is the remote end"
|
|
assert https["domains"] == ["a.example.test"]
|
|
assert https["metric"] == "gateway.in_conns@443"
|
|
assert listener_at(payload, 22)["source"] == "haproxy"
|
|
|
|
|
|
def test_a_frontend_that_is_idle_is_still_a_pipe_with_zero() -> None:
|
|
"""A configured port with no connections is `0`, not `n/a` — the socket table did answer."""
|
|
snap = snapshot(haproxy=haproxy_section([frontend("ssh", [22])]),
|
|
sockets=sockets_section(listens=[listen(22)]))
|
|
payload = gateway.parse(snap, "gateway")
|
|
assert listener_at(payload, 22)["connections"] == 0
|
|
|
|
|
|
def test_a_port_no_config_names_is_neither_counted_nor_reported() -> None:
|
|
"""The arm that used to be `unmatched.inbound` is now simply **not a number anywhere**.
|
|
|
|
Two sockets on `:8081`, which neither file binds. They are not attributed to the `:443` pipe,
|
|
not summed into it, and not tallied beside it — the listener reads `0` and the payload carries
|
|
no residual. That is a deliberate loss of information: an unclaimed socket says something about
|
|
the host, not about the two config files, and the honest place to read it is `/system`.
|
|
"""
|
|
snap = snapshot(
|
|
nginx=nginx_section([nginx_server([443], ["a.example.test"])]),
|
|
sockets=sockets_section(
|
|
listens=[listen(443), listen(8081)],
|
|
established=[conn(("10.1.1.27", 8081), ("10.1.0.9", 41000)),
|
|
conn(("10.1.1.27", 8081), ("10.1.0.9", 41001))]))
|
|
payload = gateway.parse(snap, "gateway")
|
|
assert listener_at(payload, 443)["connections"] == 0
|
|
assert 8081 not in [row["port"] for row in payload["listeners"]]
|
|
assert sum(row["connections"] or 0 for row in payload["listeners"]) < payload["sockets"]["established"]
|
|
assert "unmatched" not in payload
|
|
|
|
|
|
def test_one_port_named_by_both_configs_is_one_pipe_carrying_both_domains() -> None:
|
|
"""A host cannot bind a port twice, so the socket space is one pipe and the facts merge."""
|
|
snap = snapshot(
|
|
haproxy=haproxy_section([frontend("http", [80])]),
|
|
nginx=nginx_section([nginx_server([80], ["a.example.test"])]),
|
|
sockets=sockets_section(listens=[listen(80)],
|
|
established=[conn(("10.1.1.27", 80), ("203.0.113.1", 1234))]))
|
|
payload = gateway.parse(snap, "gateway")
|
|
ports = [row["port"] for row in payload["listeners"]]
|
|
assert ports == [80], "no second pipe, and no socket counted down both"
|
|
assert listener_at(payload, 80)["connections"] == 1
|
|
assert listener_at(payload, 80)["domains"] == ["a.example.test"]
|
|
|
|
|
|
def test_the_client_list_is_unique_sorted_and_bounded() -> None:
|
|
ports = [80]
|
|
many = [conn(("10.1.1.27", 80), (f"203.0.113.{i % 256}", 40000 + i))
|
|
for i in range(gateway.MAX_CLIENTS + 12)]
|
|
many.append(conn(("10.1.1.27", 80), ("203.0.113.1", 45678)))
|
|
snap = snapshot(nginx=nginx_section([nginx_server(ports, ["a.example.test"])]),
|
|
sockets=sockets_section(listens=[listen(80)], established=many))
|
|
pipe = listener_at(gateway.parse(snap, "gateway"), 80)
|
|
assert len(pipe["clients"]) == gateway.MAX_CLIENTS
|
|
assert pipe["clients"] == sorted(pipe["clients"])
|
|
assert pipe["connections"] == len(many), "the count stays the truth when the list is capped"
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# the outbound join
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
def test_an_endpoint_written_as_an_address_is_matched_exactly_and_carries_its_count(
|
|
) -> None:
|
|
"""`/proc` writes `(remote address, remote port)`, so a config that writes both can be joined."""
|
|
snap = snapshot(
|
|
nginx=nginx_section([nginx_server([8443], ["app.example.test"],
|
|
[proxy_pass(("127.0.0.1", 8081))])]),
|
|
sockets=sockets_section(
|
|
listens=[listen(8443)],
|
|
established=[conn(("10.1.1.27", 39000), ("127.0.0.1", 8081)),
|
|
conn(("10.1.1.27", 39002), ("10.9.9.9", 8081))]))
|
|
payload = gateway.parse(snap, "gateway")
|
|
exact = routes_to(payload, "127.0.0.1")[0]
|
|
assert exact["out"]["address"] is True
|
|
assert exact["connections"] == 1, "the one socket dialled to that address"
|
|
assert exact["metric"] == "gateway.out_conns@127.0.0.1:8081"
|
|
assert endpoint_count(payload, "127.0.0.1", 8081) == 1, (
|
|
"the same port on a different address is not this target's connection")
|
|
assert "by_port" not in json.dumps(payload), (
|
|
"the port-only arm of the join is deleted, not merely unused: a payload that grew the field "
|
|
"back has grown the ambiguity back with it")
|
|
|
|
|
|
def test_a_name_target_is_never_credited_for_a_socket_on_its_port() -> None:
|
|
"""The rule that replaced `by_port`, stated as a fact about the payload and not a chip.
|
|
|
|
`proxy_pass https://nextcloud.example.test:11000` and the socket to `10.1.0.254:11000` are the
|
|
same connection in the operator's head and two unrelated strings in this stack. Attributing one
|
|
to the other needed a resolver, and the port-only guess the old join made in its place was a
|
|
guess about a **port**, not about a host — which is why its count was the port's and its chip
|
|
said so. Now the line simply has no number, no gauge, and no apology to render.
|
|
"""
|
|
snap = snapshot(
|
|
nginx=nginx_section([nginx_server([4443], ["cloud.example.test"],
|
|
[proxy_pass(("nextcloud.example.test", 11000))])]),
|
|
sockets=sockets_section(
|
|
listens=[listen(4443)],
|
|
established=[conn(("10.1.1.27", 4443), ("10.1.0.4", 55555)),
|
|
conn(("10.1.1.27", 54340), ("10.1.0.254", 11000))]))
|
|
payload = gateway.parse(snap, "gateway")
|
|
row = routes_to(payload, "nextcloud.example.test")[0]
|
|
assert row["out"]["address"] is False
|
|
assert row["connections"] is None, "unknown, and not the port's 2 either"
|
|
assert row["metric"] is None, "and no series for a number that can never exist"
|
|
assert listener_at(payload, 4443)["connections"] == 1, (
|
|
"the socket that faces the gateway is still counted where it is measurable")
|
|
assert "by_port" not in json.dumps(payload)
|
|
# the socket to `10.1.0.254:11000` is credited to **nothing**: the sum over the outbound side
|
|
# is unchanged by its presence, which is the difference between "unknown" and "filed somewhere"
|
|
assert sum(row["connections"] or 0 for row in payload["connections"]) == 0
|
|
assert out_metrics(payload) == [], "and it publishes no gauge under any name"
|
|
|
|
|
|
def test_a_port_shared_by_several_names_still_resolves_none_of_them() -> None:
|
|
"""The real `stream.conf` shape: sixteen names on 443, none of them counted.
|
|
|
|
The answer is the same `null` the old join gave, minus the machinery that produced it and the
|
|
`unmatched.outbound` total that used to double-count the same sockets.
|
|
"""
|
|
entries = [(f"site{i}.example.test", (f"backend{i}.example.test", 443)) for i in range(4)]
|
|
snap = snapshot(
|
|
nginx=nginx_section([nginx_server([443], [], [proxy_pass(None, variable="targetBackend")])],
|
|
maps=[mapping_block("$targetBackend", entries)]),
|
|
sockets=sockets_section(
|
|
listens=[listen(443)],
|
|
established=[conn(("10.1.1.27", 443), ("203.0.113.5", 4000)),
|
|
conn(("10.1.1.27", 50000), ("10.2.2.2", 443)),
|
|
conn(("10.1.1.27", 50002), ("10.2.2.3", 443))]))
|
|
payload = gateway.parse(snap, "gateway")
|
|
assert len(payload["nginx"]["map_targets"]) == 4
|
|
for _key, (host, _port) in entries:
|
|
assert routes_to(payload, host)[0]["connections"] is None, "unknown, never 0 and never 2"
|
|
assert out_metrics(payload) == []
|
|
assert sum(row["connections"] or 0 for row in payload["listeners"]) == 1
|
|
assert "unmatched" not in payload
|
|
|
|
|
|
def test_no_join_ever_asks_a_resolver() -> None:
|
|
"""The whole point of `by_port`: this module must not turn a name into an address.
|
|
|
|
Spied on the live `socket` module the stdlib resolvers use, and run over the captured
|
|
gateway as well as a synthetic one, so a "helpful" future `socket.getaddrinfo` call inside
|
|
the join fails here rather than making 33 DNS queries every 30 seconds from the poll loop.
|
|
"""
|
|
calls: list[tuple[Any, ...]] = []
|
|
|
|
def spy(*args: Any, **kwargs: Any) -> Any: # pragma: no cover - must never run
|
|
calls.append((args, kwargs))
|
|
raise AssertionError("the gateway join must never resolve a name")
|
|
|
|
original = socket_module.getaddrinfo
|
|
socket_module.getaddrinfo = spy
|
|
try:
|
|
gateway.parse(snapshot(
|
|
haproxy=haproxy_section([frontend("mc", [25565])],
|
|
[backend("mc-be", [server("mc1", "minecraft.example.test",
|
|
25565)])]),
|
|
nginx=nginx_section([nginx_server([443], ["a.example.test"],
|
|
[proxy_pass(("app.example.test", 8080))])]),
|
|
sockets=sockets_section(listens=[listen(25565), listen(443)],
|
|
established=[conn(("10.1.1.27", 443), ("203.0.113.5", 900)),
|
|
conn(("10.1.1.27", 1), ("10.2.2.2", 8080))])),
|
|
"gateway")
|
|
finally:
|
|
socket_module.getaddrinfo = original
|
|
assert calls == []
|
|
|
|
|
|
def test_two_services_on_one_host_are_two_pipes_not_one_sum() -> None:
|
|
"""The reason the outbound series name carries its port (`minecraft` 25565 and 25566)."""
|
|
snap = snapshot(
|
|
haproxy=haproxy_section([frontend("mc1", [25565], default="testcraft"),
|
|
frontend("mc2", [25566], default="nimcraft")],
|
|
[backend("testcraft", [server("mc1", "mc.example.test", 25565)]),
|
|
backend("nimcraft", [server("mc2", "mc.example.test", 25566)])]),
|
|
sockets=sockets_section(
|
|
established=[conn(("10.1.1.27", 40000), ("10.5.5.5", 25565)),
|
|
conn(("10.1.1.27", 40002), ("10.5.5.5", 25566)),
|
|
conn(("10.1.1.27", 40004), ("10.5.5.5", 25566))]))
|
|
payload = gateway.parse(snap, "gateway")
|
|
rows = routes_to(payload, "mc.example.test")
|
|
assert [row["out"]["port"] for row in rows] == [25565, 25566]
|
|
assert [row["connections"] for row in rows] == [None, None], "a name is never counted"
|
|
assert [row["metric"] for row in rows] == [None, None]
|
|
names = {row["out"]["name"] for row in payload["connections"]}
|
|
assert names == {"mc1", "mc2"}, "the config's own server names survive the dedup"
|
|
|
|
|
|
def test_two_services_on_one_address_are_two_endpoints_and_two_gauges() -> None:
|
|
"""The same two Minecraft servers written as **addresses**: now both are measurable.
|
|
|
|
The port belongs in the gauge name because one host answering `:25565` and `:25566` is two
|
|
services, and two gauges on `gateway.out_conns@10.5.5.5` would make the second insert
|
|
overwrite the first in silence.
|
|
"""
|
|
snap = snapshot(
|
|
haproxy=haproxy_section([frontend("mc1", [25565], default="testcraft"),
|
|
frontend("mc2", [25566], default="nimcraft")],
|
|
[backend("testcraft", [server("mc1", "10.5.5.5", 25565)]),
|
|
backend("nimcraft", [server("mc2", "10.5.5.5", 25566)])]),
|
|
sockets=sockets_section(
|
|
established=[conn(("10.1.1.27", 40000), ("10.5.5.5", 25565)),
|
|
conn(("10.1.1.27", 40002), ("10.5.5.5", 25566)),
|
|
conn(("10.1.1.27", 40004), ("10.5.5.5", 25566))]))
|
|
payload = gateway.parse(snap, "gateway")
|
|
assert endpoint_count(payload, "10.5.5.5", 25565) == 1
|
|
assert endpoint_count(payload, "10.5.5.5", 25566) == 2
|
|
assert out_metrics(payload) == ["gateway.out_conns@10.5.5.5:25565",
|
|
"gateway.out_conns@10.5.5.5:25566"]
|
|
|
|
|
|
def test_a_target_with_no_port_is_a_line_with_no_count() -> None:
|
|
"""`server x host` names no port, so there is nothing to join on and nothing to invent."""
|
|
snap = snapshot(
|
|
haproxy=haproxy_section([frontend("be", [8080], default="be")],
|
|
[backend("be", [server("solo", "solo.example.test")])]),
|
|
sockets=sockets_section(established=[conn(("10.1.1.27", 40000), ("10.5.5.5", 8080))]))
|
|
row = routes_to(gateway.parse(snap, "gateway"), "solo.example.test")[0]
|
|
assert row["out"]["port"] is None
|
|
assert row["out"]["address"] is False
|
|
assert row["connections"] is None
|
|
assert row["metric"] is None, "an endpoint with no port has no gauge either"
|
|
|
|
|
|
def test_an_address_with_no_port_is_a_line_with_no_count() -> None:
|
|
"""`out.address` says the string is an address; the **join needs the pair**, not one half."""
|
|
snap = snapshot(
|
|
haproxy=haproxy_section([frontend("be", [8080], default="be")],
|
|
[backend("be", [server("solo", "10.5.5.5")])]),
|
|
sockets=sockets_section(established=[conn(("10.1.1.27", 40000), ("10.5.5.5", 8080))]))
|
|
row = routes_to(gateway.parse(snap, "gateway"), "10.5.5.5")[0]
|
|
assert row["out"]["address"] is True
|
|
assert row["connections"] is None, "the socket's port is nobody's guess (rule 2)"
|
|
assert row["metric"] is None
|
|
|
|
|
|
def test_two_connections_sharing_an_endpoint_say_so_and_repeat_the_count_never_split_it(
|
|
) -> None:
|
|
"""`:4443 -> 10.5.5.5:8080` and `:8443 -> 10.5.5.5:8080` are two lines and **one** count.
|
|
|
|
Each line carries the endpoint's whole number — a line showing half of it would be a split
|
|
nobody measured — and `shared: true` is what tells the totals, the page and any future notifier
|
|
that the two are the same sockets. Summing the lines doubles the traffic; summing the distinct
|
|
gauges is the number the kernel has.
|
|
"""
|
|
snap = snapshot(
|
|
nginx=nginx_section([nginx_server([4443], ["a.example.test"],
|
|
[proxy_pass(("10.5.5.5", 8080))]),
|
|
nginx_server([8443], ["admin.a.example.test"],
|
|
[proxy_pass(("10.5.5.5", 8080))])]),
|
|
sockets=sockets_section(
|
|
established=[conn(("10.1.1.27", 40000), ("10.5.5.5", 8080)),
|
|
conn(("10.1.1.27", 40002), ("10.5.5.5", 8080))]))
|
|
payload = gateway.parse(snap, "gateway")
|
|
rows = routes_to(payload, "10.5.5.5")
|
|
assert [row["in"]["port"] for row in rows] == [4443, 8443]
|
|
assert [row["connections"] for row in rows] == [2, 2], "the endpoint's total, twice"
|
|
assert [row["shared"] for row in rows] == [True, True]
|
|
assert sum(row["connections"] or 0 for row in rows) == 4, "the lines double-count on purpose"
|
|
outbound = [g for g in gateway.samples(payload) if g.metric.startswith("gateway.out_conns@")]
|
|
assert [g.metric for g in outbound] == ["gateway.out_conns@10.5.5.5:8080"], "published once"
|
|
assert outbound[0].value == 2.0
|
|
|
|
|
|
def test_two_names_one_target_one_port_is_one_connection() -> None:
|
|
"""The merge rule, which is what `shared` and the page's line count both depend on.
|
|
|
|
One `(source, in_port, host, port)` is **one** line: a vhost with two `server_name`s reaching
|
|
one `proxy_pass` is one pipe with two selectors, and a second vhost on the same port arriving
|
|
at the same endpoint adds its name to that line rather than drawing a second one. Selectors
|
|
union in **first-seen order**, because the config's order is the only ordering there is.
|
|
"""
|
|
snap = snapshot(nginx=nginx_section([
|
|
nginx_server([4443], ["a.example.test", "www.a.example.test"],
|
|
[proxy_pass(("10.5.5.5", 8080))]),
|
|
nginx_server([4443], ["b.example.test"], [proxy_pass(("10.5.5.5", 8080))])]))
|
|
rows = gateway.parse(snap, "gateway")["connections"]
|
|
assert len(rows) == 1, "two vhosts, one port, one endpoint: one line"
|
|
assert rows[0]["out"]["selectors"] == [
|
|
"a.example.test", "www.a.example.test", "b.example.test"], (
|
|
"selectors are the union in the order the configs wrote them")
|
|
|
|
|
|
def test_the_same_target_on_two_listen_ports_is_two_connections() -> None:
|
|
"""Never merge across `in_port`: `:4443` and `:8443` are two socket spaces.
|
|
|
|
The real `http.conf` pair — `admin.keycloak` on 8443 and `keycloak` on 4443 both land on
|
|
`keycloak:8080` — and the `minecraft` 25565/25566 lesson from the other side. One line for
|
|
both would draw one pipe where the operator has two, and the page could not say which inbound
|
|
port a connection came in on.
|
|
"""
|
|
snap = snapshot(nginx=nginx_section([
|
|
nginx_server([4443], ["keycloak.example.test"], [proxy_pass(("10.5.5.5", 8080))]),
|
|
nginx_server([8443], ["admin.keycloak.example.test"],
|
|
[proxy_pass(("10.5.5.5", 8080))])]))
|
|
rows = gateway.parse(snap, "gateway")["connections"]
|
|
assert [row["in"]["port"] for row in rows] == [4443, 8443]
|
|
assert [row["id"] for row in rows] == [
|
|
"nginx:4443->10.5.5.5:8080", "nginx:8443->10.5.5.5:8080"]
|
|
assert [row["out"]["selectors"] for row in rows] == [
|
|
["keycloak.example.test"], ["admin.keycloak.example.test"]]
|
|
|
|
|
|
def test_the_first_name_a_config_writes_for_an_endpoint_is_the_one_kept() -> None:
|
|
"""A later claim never displaces a name the config already gave the endpoint.
|
|
|
|
haproxy claims are built before nginx's, and its `server` line is the only place a name comes
|
|
from, so the rule has to be "first name wins" rather than "last write wins": a second route to
|
|
the same endpoint that carries no name must not blank the one that does.
|
|
"""
|
|
snap = snapshot(haproxy=haproxy_section(
|
|
[frontend("one", [80], default="be"), frontend("two", [80], default="be")],
|
|
[backend("be", [server("", "10.5.5.5", 8080), server("app1", "10.5.5.5", 8080)])]))
|
|
rows = gateway.parse(snap, "gateway")["connections"]
|
|
assert len(rows) == 1, "both frontends route the same server: one line"
|
|
assert rows[0]["out"]["name"] == "app1", "the name the config wrote, not the blank that came first"
|
|
|
|
|
|
def test_the_loopback_hop_counts_once_on_each_side() -> None:
|
|
"""A connection to a service on the router itself is **two** `/proc` rows and two lines.
|
|
|
|
`nginx :4443 → 127.0.0.1:4443` is the fleet's real shape (`stream.conf`'s `default`). One row
|
|
has local port `4443` (the side that was accepted, whose client is `127.0.0.1`) and another has
|
|
remote `127.0.0.1:4443` (the side the router dialled). Each belongs on its own pipe — inbound
|
|
and outbound — and **no single row may be drawn down both**: the inbound arm runs first and
|
|
`continue`s, which is the whole enforcement, so the two counts here are two each and not four
|
|
on one side. Summed against the table, the four rows are four counted and zero left over.
|
|
"""
|
|
snap = snapshot(
|
|
nginx=nginx_section([nginx_server([4443], ["a.example.test"],
|
|
[proxy_pass(("127.0.0.1", 4443))])]),
|
|
sockets=sockets_section(
|
|
listens=[listen(4443)],
|
|
established=[conn(("127.0.0.1", 41000), ("127.0.0.1", 4443)),
|
|
conn(("127.0.0.1", 41002), ("127.0.0.1", 4443)),
|
|
conn(("127.0.0.1", 4443), ("127.0.0.1", 41000)),
|
|
conn(("127.0.0.1", 4443), ("127.0.0.1", 41002))]))
|
|
payload = gateway.parse(snap, "gateway")
|
|
trunk = listener_at(payload, 4443)
|
|
route = routes_to(payload, "127.0.0.1")[0]
|
|
assert trunk["connections"] == 2, "the two accepted-side rows, and only those"
|
|
assert route["connections"] == 2, "the two dialled-side rows, and only those"
|
|
assert route["in"]["port"] == 4443 and route["out"]["port"] == 4443
|
|
assert trunk["clients"] == ["127.0.0.1"], "the loopback peer is the client of its own row"
|
|
total = trunk["connections"] + endpoint_count(payload, "127.0.0.1", 4443)
|
|
assert total == payload["sockets"]["established"] == 4, (
|
|
"each row counted at most once; the ≤ identity is an equality here because every row of "
|
|
"this table belongs to the one configured route")
|
|
|
|
|
|
def test_an_endpoint_reached_by_one_line_is_not_shared() -> None:
|
|
"""`shared` is a fact about the graph, not a flag meaning "I did not decide"."""
|
|
snap = snapshot(
|
|
nginx=nginx_section([nginx_server([4443], ["a.example.test"],
|
|
[proxy_pass(("10.5.5.5", 8080))])]),
|
|
sockets=sockets_section(established=[conn(("10.1.1.27", 40000), ("10.5.5.5", 8080))]))
|
|
row = routes_to(gateway.parse(snap, "gateway"), "10.5.5.5")[0]
|
|
assert row["shared"] is False
|
|
assert row["connections"] == 1
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# the map: which maps route, and which are just header words
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
def test_a_map_no_proxy_pass_references_contributes_nothing() -> None:
|
|
"""`$connection_upgrade`'s values are `upgrade`/`close` — header words, never a host."""
|
|
snap = snapshot(
|
|
nginx=nginx_section([nginx_server([443], [], [proxy_pass(None, variable="targetBackend")])],
|
|
maps=[mapping_block("connection_upgrade",
|
|
[("default", ("upgrade", None)),
|
|
("", ("close", None))]),
|
|
mapping_block("targetBackend",
|
|
[("a.example.test", ("be.example.test", 443))])]),
|
|
sockets=sockets_section(listens=[listen(443)]))
|
|
payload = gateway.parse(snap, "gateway")
|
|
assert [row["key"] for row in payload["nginx"]["map_targets"]] == ["a.example.test"]
|
|
assert [row["out"]["host"] for row in payload["connections"]] == ["be.example.test"]
|
|
|
|
|
|
def test_a_map_entry_with_no_address_is_skipped() -> None:
|
|
snap = snapshot(
|
|
nginx=nginx_section([nginx_server([443], [], [proxy_pass(None, variable="be")])],
|
|
maps=[mapping_block("be", [("a.example.test", None),
|
|
("b.example.test", ("be2.example.test", 443))])]))
|
|
payload = gateway.parse(snap, "gateway")
|
|
assert [row["key"] for row in payload["nginx"]["map_targets"]] == ["b.example.test"]
|
|
|
|
|
|
def test_one_endpoint_reached_from_both_configs_is_two_lines_of_one_number() -> None:
|
|
"""haproxy routes `:80` to `app.example.test:9000` and an nginx vhost routes `:8443` there too.
|
|
|
|
Two files, two inbound ports, one endpoint: the graph keeps **both lines** (each is a route a
|
|
reader has to see, and merging them would erase which file declares which) and stamps each with
|
|
the same endpoint count, `shared`. What the merge rule inside `config_connections` still does is
|
|
keep the name a claim carries within one `(source, in_port, host, port)` key — tested here on
|
|
the haproxy line, whose `server` name no other claim has.
|
|
"""
|
|
snap = snapshot(
|
|
haproxy=haproxy_section([frontend("app", [80], default="app-be", use=["app-be"])],
|
|
[backend("app-be", [server("app1", "app.example.test", 9000)])]),
|
|
nginx=nginx_section([nginx_server([8443], ["app.example.test"],
|
|
[proxy_pass(("app.example.test", 9000))])]),
|
|
sockets=sockets_section(established=[conn(("10.1.1.27", 40000), ("10.5.5.5", 9000))]))
|
|
payload = gateway.parse(snap, "gateway")
|
|
rows = routes_to(payload, "app.example.test")
|
|
assert [row["source"] for row in rows] == ["haproxy", "nginx"]
|
|
assert rows[0]["out"]["name"] == "app1", "the haproxy `server` name, kept verbatim"
|
|
assert rows[0]["in"]["port"] == 80, "one line despite two routes naming the same backend"
|
|
assert rows[1]["out"]["selectors"] == ["app.example.test"]
|
|
|
|
|
|
def test_a_pool_names_its_upstream_servers_and_an_undeclared_pool_draws_nothing() -> None:
|
|
"""Derivation 3: `proxy_pass http://<name>` (no port) is an `upstream` block, and its members
|
|
are the endpoints. No block of that name is **no connection** — and no invented host either.
|
|
|
|
The payload cannot say where the traffic lands, so it claims nothing (rule 2). Task 03 decides
|
|
whether an undeclared pool is a config-integrity warning; this task draws no line for it, and
|
|
says so by pinning an empty graph rather than a line to `backend_pool:80`.
|
|
"""
|
|
snap = snapshot(nginx=nginx_section(
|
|
[nginx_server([4443], ["app.example.test"], [pool_pass("backend_pool")])],
|
|
upstreams=[upstream("backend_pool", [("10.0.0.1", 3000), ("10.0.0.2", 3000)])]))
|
|
rows = gateway.parse(snap, "gateway")["connections"]
|
|
assert [(row["out"]["host"], row["out"]["port"], row["out"]["kind"]) for row in rows] == [
|
|
("10.0.0.1", 3000, gateway.KIND_POOL), ("10.0.0.2", 3000, gateway.KIND_POOL)]
|
|
assert [row["out"]["selectors"] for row in rows] == [["backend_pool"], ["backend_pool"]], (
|
|
"the pool name is the config's own word for why these two endpoints are here")
|
|
assert [row["metric"] for row in rows] == [
|
|
"gateway.out_conns@10.0.0.1:3000", "gateway.out_conns@10.0.0.2:3000"], (
|
|
"a pool member is an address, so both lines are joinable")
|
|
|
|
orphans = snapshot(nginx=nginx_section(
|
|
[nginx_server([4443], ["app.example.test"], [pool_pass("no_such_pool")])]))
|
|
payload = gateway.parse(orphans, "gateway")
|
|
assert payload["connections"] == [], "an undeclared pool names no endpoint, so it draws nothing"
|
|
assert listener_at(payload, 4443), "and the trunk it hangs off still exists"
|
|
|
|
|
|
def test_a_haproxy_connection_carries_the_backend_and_the_server_name(gateway_snapshot: Any) -> None:
|
|
"""Both of the config's own names for a haproxy route survive into the graph.
|
|
|
|
`out.name` is the `server` line's name and `out.selectors` the backend the frontend routes to —
|
|
the two words an operator greps for in `haproxy.cfg`, printed rather than paraphrased.
|
|
"""
|
|
payload = gateway.parse(gateway_snapshot("full"), "gateway")
|
|
row = next(row for row in payload["connections"] if row["in"]["port"] == 22)
|
|
assert row["out"]["name"] == "gitea"
|
|
assert row["out"]["kind"] == gateway.KIND_BACKEND
|
|
assert row["out"]["selectors"] == ["gitea-2222"]
|
|
assert row["source"] == "haproxy"
|
|
|
|
|
|
def test_a_frontend_that_routes_by_expression_is_a_listener_with_no_connection() -> None:
|
|
"""Refusal rule 5, synthetic half: a fetch expression names no backend, so nothing is joined.
|
|
|
|
The agent reports `use_backend %[req.hdr(host),map_dom(...)]` as **no** name (it interprets no
|
|
config), and "no name" and "no routing directive" are one value in this payload. Inventing a
|
|
route here would be rule 2, and inventing a *warning* about it would blame the operator for a
|
|
thing the payload cannot distinguish.
|
|
"""
|
|
snap = snapshot(haproxy=haproxy_section([{"name": "expr", "binds": [bind(80)],
|
|
"default_backend": None, "backends": []}]))
|
|
payload = gateway.parse(snap, "gateway")
|
|
assert listener_at(payload, 80), "the trunk exists"
|
|
assert payload["connections"] == [], "and no line is drawn out of it"
|
|
|
|
|
|
def test_a_proxy_pass_with_neither_target_nor_variable_draws_nothing() -> None:
|
|
"""The other half of refusal rule 5: a unix socket or a bare word names no endpoint."""
|
|
snap = snapshot(nginx=nginx_section([nginx_server(
|
|
[443], ["a.example.test"],
|
|
[{"location": "/", "scheme": None, "target": None, "variable": None}])]))
|
|
payload = gateway.parse(snap, "gateway")
|
|
assert payload["connections"] == []
|
|
assert listener_at(payload, 443), "the listen port is still a trunk the page draws"
|
|
|
|
|
|
def test_a_proxy_pass_naming_no_host_is_no_line() -> None:
|
|
"""`target: {host: ""}` is drift, and drift yields no line rather than a line to nowhere."""
|
|
snap = snapshot(nginx=nginx_section([nginx_server(
|
|
[443], [], [{"location": "/", "scheme": "http", "target": {"host": " ", "port": 80},
|
|
"variable": None}])]))
|
|
payload = gateway.parse(snap, "gateway")
|
|
assert payload["connections"] == []
|
|
assert payload["nginx"]["servers"] == [{"port": 443, "server_names": []}]
|
|
|
|
|
|
def test_the_readers_refuse_the_shapes_that_are_not_values() -> None:
|
|
"""The three guards every section read goes through, on the inputs that are not data.
|
|
|
|
Pinned directly (the way `tests/test_config.py` pins `_flag`/`_num`) because each one is the
|
|
difference between a pipe and no pipe: an empty string is not an address, a bare string is
|
|
not a list of names, and whitespace is not a host.
|
|
"""
|
|
assert gateway._is_ip_literal("") is False
|
|
assert gateway._is_ip_literal(None) is False
|
|
assert gateway._is_ip_literal("203.0.113.7") is True
|
|
assert gateway._is_ip_literal("[::1]") is False, "/proc spells addresses without brackets"
|
|
assert gateway._is_ip_literal("app.example.test") is False
|
|
assert gateway._names("not a list") == []
|
|
assert gateway._names(None) == []
|
|
assert gateway._names([None, "a-be", "a-be", " ", "b-be"]) == ["a-be", "b-be"]
|
|
assert gateway._target_of({"host": " ", "port": 80}) is None
|
|
assert gateway._target_of(["host", 80]) is None
|
|
assert gateway._target_of({"host": "be.example.test", "port": "nope"}) == (
|
|
"be.example.test", None)
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# the degradation arms: three ways to have nothing, none of them red
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
def test_an_agent_without_the_sections_predates_gateway_support(gateway_snapshot: Any) -> None:
|
|
"""The 1.1.0 / `--no-proxy` arm: same keys, empty and null, one sentence, no verdict."""
|
|
payload = gateway.parse(gateway_snapshot("predates"), "gateway")
|
|
assert payload["supported"] is False
|
|
assert payload["why"] == gateway.WHY_PREDATES
|
|
assert "predates gateway support" in payload["why"]
|
|
assert payload["haproxy"] == {"frontends": []}
|
|
assert payload["nginx"] == {"servers": [], "map_targets": []}
|
|
assert payload["listeners"] == [] and payload["connections"] == []
|
|
assert payload["sockets"] == {"listens": None, "established": None}
|
|
assert payload["sections"] == {"haproxy": None, "nginx": None, "sockets": None}
|
|
assert gateway.samples(payload, 1.0) == [], "an unknown count publishes no gauge"
|
|
|
|
|
|
def test_a_host_running_no_haproxy_says_so_and_keeps_the_nginx_pipes(gateway_snapshot: Any) -> None:
|
|
payload = gateway.parse(gateway_snapshot("no_haproxy"), "gateway")
|
|
assert payload["supported"] is True, "the agent knows the feature; the host has no haproxy"
|
|
assert payload["sections"]["haproxy"] is False
|
|
assert "no haproxy" in (payload["why"] or "")
|
|
assert "not found" not in (payload["why"] or ""), "the page's sentence, not the agent's log"
|
|
assert payload["section_errors"], "the agent's own line still travels, verbatim"
|
|
assert any("not found" in line for line in payload["section_errors"])
|
|
assert [row["port"] for row in payload["nginx"]["servers"]] == [80, 443, 4443, 8443]
|
|
assert [row["port"] for row in payload["listeners"] if row["source"] == "nginx"] == \
|
|
[80, 443, 4443, 8443]
|
|
assert payload["haproxy"]["frontends"] == []
|
|
assert [row for row in payload["connections"] if row["source"] == "haproxy"] == [], (
|
|
"no haproxy file, so no haproxy route — and no invented one to fill the gap")
|
|
assert [row for row in payload["connections"] if row["source"] == "nginx"], (
|
|
"the nginx half of the map is untouched by the other file being absent")
|
|
|
|
|
|
def test_an_unreadable_config_is_a_different_sentence_from_no_config() -> None:
|
|
snap = snapshot(haproxy=haproxy_section([], available=None),
|
|
nginx=nginx_section([nginx_server([80], ["a.example.test"])]),
|
|
sockets=sockets_section(listens=[listen(80)]))
|
|
payload = gateway.parse(snap, "gateway")
|
|
assert payload["sections"]["haproxy"] is None
|
|
assert "could not be read or parsed" in (payload["why"] or "")
|
|
assert [row["port"] for row in payload["listeners"]] == [80]
|
|
|
|
|
|
def test_a_socket_table_that_did_not_answer_leaves_every_count_null(
|
|
gateway_snapshot: Any) -> None:
|
|
"""`available: null` is "no answer", and the only honest counts are `null` (rule 2)."""
|
|
payload = gateway.parse(gateway_snapshot("sockets_silent"), "gateway")
|
|
assert payload["sections"]["sockets"] is None
|
|
assert "sockets:" in (payload["why"] or "")
|
|
assert payload["sockets"] == {"listens": None, "established": None}
|
|
assert payload["listeners"], "the configured ports are still drawn — the config did answer"
|
|
assert all(row["connections"] is None for row in payload["listeners"])
|
|
assert payload["connections"], "and so is every route the two files declare"
|
|
assert all(row["connections"] is None for row in payload["connections"]), (
|
|
"a silent socket table says nothing about any route, not even about the joinable ones")
|
|
assert gateway.samples(payload, 1.0) == []
|
|
|
|
|
|
def test_a_partial_snapshot_names_the_missing_section_only() -> None:
|
|
"""A hypothetical agent publishing `sockets` but no configs: supported, with a gap named."""
|
|
snap = snapshot(sockets=sockets_section(listens=[listen(9999)]))
|
|
payload = gateway.parse(snap, "gateway")
|
|
assert payload["supported"] is True
|
|
assert payload["sections"] == {"haproxy": None, "nginx": None, "sockets": True}
|
|
assert payload["listeners"] == [], "9999 is a listener nothing claims, so it is not a pipe"
|
|
assert payload["sockets"] == {"listens": 1, "established": 0}, "the kernel's number still travels"
|
|
assert "haproxy" in (payload["why"] or "") and "nginx" in (payload["why"] or "")
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# what the payload must never contain, and the gauges it must
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
def test_no_count_carries_a_verdict_or_a_window(gateway_snapshot: Any) -> None:
|
|
"""`net.*` precedent for colour, rule 2 for the `Δ` label: a count is a count.
|
|
|
|
Both arms are walked, so a `severity` bolted onto a pipe or a `window_seconds` copied from
|
|
`sources/host.py` fails here — the first would be a judgement `severity.py` never made, the
|
|
second would claim a measurement this source does not take.
|
|
"""
|
|
for mode in ("full", "predates", "sockets_silent"):
|
|
keys = walk_keys(gateway.parse(gateway_snapshot(mode), "gateway"))
|
|
assert "severity" not in keys, mode
|
|
assert not {key for key in keys if "window" in key or key.startswith("Δ")}, mode
|
|
|
|
|
|
def test_the_payload_is_json_serialisable_end_to_end(gateway_snapshot: Any) -> None:
|
|
"""It goes out over `/api/gateway/snapshot` verbatim, so nothing in it may be exotic."""
|
|
json.dumps(gateway.parse(gateway_snapshot("full"), "gateway"))
|
|
|
|
|
|
def test_samples_are_one_gauge_per_pipe_under_the_gateway_family(gateway_snapshot: Any) -> None:
|
|
snap = gateway_snapshot("full")
|
|
payload = gateway.parse(snap, "gateway")
|
|
rows = gateway.samples(payload, 1_760_000_000.0)
|
|
assert rows, "a live gateway publishes gauges"
|
|
assert {row.source for row in rows} == {gateway.SOURCE}
|
|
assert {row.host for row in rows} == {"gateway"}
|
|
named = {row.metric: row.value for row in rows}
|
|
assert named["gateway.in_conns@443"] == float(listener_at(payload, 443)["connections"])
|
|
# one gauge per **metric name** that has a number: shared endpoints appear on several lines and
|
|
# must be stored once, or the history doubles the traffic for one reading
|
|
expected = {row["metric"]: float(row["connections"])
|
|
for group in ("listeners", "connections") for row in payload[group]
|
|
if row["metric"] is not None and row["connections"] is not None}
|
|
assert named == expected, "one gauge per measured endpoint, and none for an unmeasured one"
|
|
assert len(rows) == len(named), "no metric is emitted twice by one poll"
|
|
for row in payload["connections"]:
|
|
if row["metric"] is None:
|
|
assert row["connections"] is None, "a line with no gauge has no number either"
|
|
|
|
|
|
def test_parse_gateway_snapshot_stamps_its_samples_from_the_snapshot() -> None:
|
|
"""The x-axis is when the agent took the snapshot, the way `sources/disks.py` does it."""
|
|
result = gateway.parse_gateway_snapshot(
|
|
"gateway", snapshot(haproxy=haproxy_section([frontend("ssh", [22])]),
|
|
sockets=sockets_section(listens=[listen(22)])), now=999.0)
|
|
assert result.events == [], "a connection count has no transition worth an event line"
|
|
assert [sample.ts for sample in result.samples] == [1_760_000_000.0]
|
|
assert [sample.metric for sample in result.samples] == ["gateway.in_conns@22"]
|
|
|
|
|
|
def test_a_snapshot_with_no_timestamp_falls_back_to_the_callers_clock() -> None:
|
|
doc = snapshot(haproxy=haproxy_section([frontend("ssh", [22])]),
|
|
sockets=sockets_section(listens=[listen(22)]))
|
|
doc.pop("ts")
|
|
result = gateway.parse_gateway_snapshot("gateway", doc, now=42.0)
|
|
assert [sample.ts for sample in result.samples] == [42.0]
|
|
|
|
|
|
def test_the_gauges_skip_a_pipe_with_no_number() -> None:
|
|
"""No sample for an unknown count — a chart of zeros for a dead socket table is the
|
|
failure mode rule 2 names, and `sources/disks.py` already refuses it for unreadable drives."""
|
|
payload = gateway.parse(snapshot(haproxy=haproxy_section([frontend("ssh", [22])])), "gateway")
|
|
assert payload["listeners"][0]["connections"] is None
|
|
assert gateway.samples(payload, 1.0) == []
|
|
|
|
|
|
def test_a_snapshot_whose_sections_are_empty_is_an_honest_empty_page() -> None:
|
|
"""A 1.2.0 agent on a box with two empty configs: supported, and nothing drawn."""
|
|
snap = snapshot(haproxy=haproxy_section([]), nginx=nginx_section(),
|
|
sockets=sockets_section())
|
|
payload = gateway.parse(snap, "gateway")
|
|
assert payload["supported"] is True and payload["why"] is None
|
|
assert payload["listeners"] == [] and payload["connections"] == []
|
|
assert payload["sockets"] == {"listens": 0, "established": 0}
|
|
|
|
|
|
def test_junk_in_the_sections_yields_no_pipes_and_no_exception() -> None:
|
|
"""The source is a projection of a document it does not control — drift is `n/a`, not a crash."""
|
|
snap: dict[str, Any] = {"ts": 1.0, "host": "gateway",
|
|
"haproxy": {"available": True, "frontends": [3, None],
|
|
"backends": {"nope": 1}},
|
|
"nginx": {"available": True, "servers": "not a list", "maps": None},
|
|
"sockets": {"available": True, "listens": [None],
|
|
"established": [{"local": 5, "remote": None}]}}
|
|
payload = gateway.parse(snap, "gateway")
|
|
assert payload["listeners"] == [] and payload["connections"] == []
|
|
assert payload["sockets"] == {"listens": 0, "established": 1}
|
|
|
|
|
|
def test_a_port_of_zero_or_a_name_of_nothing_is_no_match() -> None:
|
|
"""`0` is not a port and an empty target is not a host, so neither becomes a pipe (rule 2)."""
|
|
snap = snapshot(
|
|
haproxy=haproxy_section([frontend("u", [None, 0]), frontend("ok", [22], default="b")],
|
|
[backend("b", [server("?", "", 22), server("z", "z.example.test",
|
|
0)])]),
|
|
sockets=sockets_section(listens=[listen(0), listen(22)]))
|
|
payload = gateway.parse(snap, "gateway")
|
|
assert [row["port"] for row in payload["haproxy"]["frontends"]] == [22]
|
|
assert [row["out"]["host"] for row in payload["connections"]] == ["z.example.test"]
|
|
row = payload["connections"][0]
|
|
assert row["out"]["port"] is None, "a 0 is not a port, so the line carries no port"
|
|
assert row["metric"] is None and row["connections"] is None, (
|
|
"and therefore nothing to join on and nothing to publish")
|
|
assert [entry["port"] for entry in payload["listeners"]] == [22], (
|
|
"a 0 bind is not a listening port either")
|
|
|
|
|
|
def test_a_use_backend_map_is_reported_as_routes_not_one_chosen_backend() -> None:
|
|
"""haproxy picks per request here; the payload names every candidate and chooses none."""
|
|
snap = snapshot(haproxy=haproxy_section(
|
|
[frontend("fe", [80], default="base-be", use=("blue-be", "green-be"))]))
|
|
row = gateway.parse(snap, "gateway")["haproxy"]["frontends"][0]
|
|
assert row["backend"] == "base-be"
|
|
assert row["routes"] == ["base-be", "blue-be", "green-be"]
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# config-integrity warnings: seven kinds, each a config that does not connect to itself
|
|
#
|
|
# Each kind gets a synthetic config written in the shape the agent publishes (the builders above
|
|
# are the section document, not a fake tree: `warnings()` is a pure function over sections, and a
|
|
# file round-trip would only re-test the agent's parser). The captured configs then pin that the
|
|
# seven checks are not a lie about THIS fleet: zero warnings, and a stated reason per kind.
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
def kinds(payload_warnings: Sequence[Mapping[str, str]]) -> list[str]:
|
|
"""The `kind` of every warning, in the order `warnings()` produced them."""
|
|
return [row["kind"] for row in payload_warnings]
|
|
|
|
|
|
def warning_for(payload_warnings: Sequence[Mapping[str, str]], kind: str) -> Mapping[str, str]:
|
|
"""The one warning of `kind`, failing if there is not exactly one of them."""
|
|
rows = [row for row in payload_warnings if row["kind"] == kind]
|
|
assert len(rows) == 1, f"expected exactly one {kind}, got {rows}"
|
|
return rows[0]
|
|
|
|
|
|
def test_a_dangling_use_backend_warns_by_frontend_and_backend() -> None:
|
|
"""`haproxy_route_dangling`: a route naming a backend nobody declares.
|
|
|
|
Synthetic sections (the pure-function route): the check is over the two lists the agent
|
|
publishes, and the fault is one line of a file, not a file's layout on disk.
|
|
"""
|
|
section = haproxy_section(
|
|
[frontend("haproxy-22", [22], default="gitea-2222"),
|
|
frontend("haproxy-443", [443], use=("live-be", "gone-be"))],
|
|
[backend("live-be", [server("s", "10.0.0.1", 80)])])
|
|
found = gateway.haproxy_warnings(section)
|
|
assert kinds(found) == [gateway.WARN_ROUTE_DANGLING, gateway.WARN_ROUTE_DANGLING]
|
|
row = found[0]
|
|
assert row["id"] == "haproxy_route_dangling:haproxy-22->gitea-2222"
|
|
assert row["kind"] == gateway.WARN_ROUTE_DANGLING
|
|
assert row["subject"] == "haproxy-22->gitea-2222"
|
|
assert row["message"] == ('frontend haproxy-22 routes to backend "gitea-2222", '
|
|
"which no backend block declares")
|
|
assert found[1]["id"] == "haproxy_route_dangling:haproxy-443->gone-be", (
|
|
"every dangling name is its own warning — collapsing them would hide the second one")
|
|
|
|
|
|
def test_a_backend_no_frontend_routes_to_warns_with_its_server_count() -> None:
|
|
"""`haproxy_backend_orphan`: servers with nowhere to come from, counted as the file wrote it."""
|
|
section = haproxy_section(
|
|
[frontend("haproxy-22", [22], default="used-be")],
|
|
[backend("used-be", [server("s", "10.0.0.1", 80)]),
|
|
backend("old-tunnel", [server("a", "10.0.0.2", 22), server("b", "10.0.0.3", 22)])])
|
|
row = warning_for(gateway.haproxy_warnings(section), gateway.WARN_BACKEND_ORPHAN)
|
|
assert row["message"] == ('backend "old-tunnel" has 2 servers and no frontend routes to it')
|
|
assert row["id"] == "haproxy_backend_orphan:old-tunnel"
|
|
# the singular, because the message prints the config's own count
|
|
one = haproxy_section([frontend("f", [22], default="used-be")],
|
|
[backend("used-be", [server("s", "10.0.0.1", 80)]),
|
|
backend("solo", [server("a", "10.0.0.9", 22)])])
|
|
assert "has 1 server and no frontend routes to it" in \
|
|
warning_for(gateway.haproxy_warnings(one), gateway.WARN_BACKEND_ORPHAN)["message"]
|
|
|
|
|
|
def test_a_backend_with_no_server_warns_that_it_routes_nowhere() -> None:
|
|
"""`haproxy_backend_empty`: the block exists, so a route to it is not dangling — it just ends."""
|
|
section = haproxy_section([frontend("haproxy-22", [22], default="gitea-2222")],
|
|
[backend("gitea-2222", [])])
|
|
found = gateway.haproxy_warnings(section)
|
|
assert kinds(found) == [gateway.WARN_BACKEND_EMPTY], (
|
|
"an empty backend is not also an orphan: `orphan` needs a server to have anywhere to go")
|
|
assert found[0]["message"] == 'backend "gitea-2222" declares no server — it routes nowhere'
|
|
|
|
|
|
def test_a_server_block_that_never_listens_warns() -> None:
|
|
"""`nginx_server_unlistened`: the vhost is configured and unreachable, in both its shapes."""
|
|
section = nginx_section([
|
|
nginx_server([], ["nextcloud.example.com"], [proxy_pass(("10.0.0.5", 11000))]),
|
|
nginx_server([], ["named-only.example.com"]),
|
|
nginx_server([443], ["fine.example.com"], [proxy_pass(("10.0.0.6", 443))]),
|
|
])
|
|
found = gateway.nginx_warnings(section)
|
|
assert kinds(found) == [gateway.WARN_SERVER_UNLISTENED, gateway.WARN_SERVER_UNLISTENED]
|
|
assert found[0]["message"] == ('nginx server "nextcloud.example.com" proxy_passes '
|
|
"but never listens")
|
|
assert found[1]["message"] == ('nginx server "named-only.example.com" '
|
|
"declares a server_name but never listens")
|
|
|
|
|
|
def test_a_proxy_pass_naming_an_undeclared_pool_warns() -> None:
|
|
"""`nginx_pool_undeclared`: task 01 drew no connection for it, and this says why."""
|
|
section = nginx_section(
|
|
[nginx_server([4443], ["app.example.com"], [pool_pass("backend_pool")]),
|
|
nginx_server([8443], ["other.example.com"],
|
|
[pool_pass("backend_pool")])],
|
|
upstreams=[upstream("a_real_pool", [("10.0.0.7", 3000)])])
|
|
found = gateway.nginx_warnings(section)
|
|
assert kinds(found) == [gateway.WARN_POOL_UNDECLARED, gateway.WARN_POOL_UNDECLARED]
|
|
assert found[0]["message"] == ('nginx listen 4443 proxy_passes to pool "backend_pool", '
|
|
"which no upstream block declares")
|
|
assert found[0]["id"] == "nginx_pool_undeclared:backend_pool@4443"
|
|
assert found[1]["id"] == "nginx_pool_undeclared:backend_pool@8443", (
|
|
"two listen ports reaching the one missing pool are two broken routes and two subjects")
|
|
# and the same block naming two ports is ONE warning, because the repair is one `proxy_pass`
|
|
both = nginx_section([nginx_server([4443, 8443], ["app.example.com"],
|
|
[pool_pass("backend_pool")])])
|
|
row = warning_for(gateway.nginx_warnings(both), gateway.WARN_POOL_UNDECLARED)
|
|
assert row["id"] == "nginx_pool_undeclared:backend_pool@4443+8443"
|
|
assert row["message"].startswith("nginx listen 4443, 8443 proxy_passes to pool")
|
|
|
|
|
|
def test_a_proxy_pass_variable_no_map_defines_warns() -> None:
|
|
"""`nginx_variable_unmapped`: the stream server routes to a variable nothing fills."""
|
|
section = nginx_section(
|
|
[nginx_server([443], [], [proxy_pass(None, variable="$targetBackend")]),
|
|
nginx_server([8443], [], [proxy_pass(None, variable="mapped")])],
|
|
maps=[mapping_block("mapped", [("a.example.com", ("10.0.0.8", 443))])])
|
|
row = warning_for(gateway.nginx_warnings(section), gateway.WARN_VARIABLE_UNMAPPED)
|
|
assert row["id"] == "nginx_variable_unmapped:targetBackend@443"
|
|
assert row["message"] == ("nginx listen 443 proxy_passes $targetBackend, "
|
|
"which no map block defines"), (
|
|
"the `$` is nginx's spelling and the message keeps it, whatever the agent publishes")
|
|
|
|
|
|
def test_a_referenced_map_entry_with_no_address_warns() -> None:
|
|
"""`nginx_map_route_missing`: the SNI key is routed to nothing."""
|
|
section = nginx_section(
|
|
[nginx_server([443], [], [proxy_pass(None, variable="targetBackend")])],
|
|
maps=[mapping_block("targetBackend",
|
|
[("x.example.com", None),
|
|
("ok.example.com", ("10.0.0.9", 443))])])
|
|
row = warning_for(gateway.nginx_warnings(section), gateway.WARN_MAP_ROUTE_MISSING)
|
|
assert row["id"] == "nginx_map_route_missing:targetBackend:x.example.com"
|
|
assert row["message"] == 'map $targetBackend entry "x.example.com" names no address to route to'
|
|
|
|
|
|
# --- the three refusals: each produces NO warning while still producing its other output --- #
|
|
|
|
def test_a_listen_with_no_proxy_pass_is_not_a_warning() -> None:
|
|
"""A static or redirect vhost is legal and looks identical here, so nothing is said about it.
|
|
|
|
This fleet's own `:80` catch-all is a `return 301`, and the agent publishes it as a `server`
|
|
with a `listen`, a `server_name` and no `proxy_pass` — exactly the shape a warning would blame.
|
|
Pinned in the direction that matters: the listener still exists, and the warning list stays
|
|
empty.
|
|
"""
|
|
section = nginx_section([nginx_server([80], ["_"])])
|
|
assert gateway.nginx_warnings(section) == []
|
|
payload = gateway.parse(snapshot(nginx=section, sockets=sockets_section(listens=[listen(80)])),
|
|
"gateway")
|
|
assert [row["port"] for row in payload["listeners"]] == [80], "the trunk is still drawn"
|
|
assert payload["connections"] == [], "and it is drawn alone"
|
|
assert payload["warnings"] == []
|
|
|
|
|
|
def test_a_frontend_routing_by_fetch_expression_is_not_a_warning() -> None:
|
|
""""no name" and "no routing directive" are one value on this wire, so neither is a fault.
|
|
|
|
`use_backend %[req.hdr(host),map_dom(...)]` arrives as a frontend with no backend name — the
|
|
same document a frontend with no routing at all produces. Warning about it would blame a config
|
|
this agent cannot read; the fix is an agent change, not a guess here. The listener survives and
|
|
the warning list stays empty.
|
|
"""
|
|
section = haproxy_section([frontend("dynamic", [443], use=())])
|
|
assert gateway.haproxy_warnings(section) == []
|
|
payload = gateway.parse(snapshot(haproxy=section,
|
|
sockets=sockets_section(listens=[listen(443)])), "gateway")
|
|
assert [row["port"] for row in payload["listeners"]] == [443]
|
|
assert payload["connections"] == []
|
|
assert payload["warnings"] == []
|
|
|
|
|
|
def test_a_proxy_pass_with_neither_target_nor_variable_is_not_a_warning() -> None:
|
|
"""A unix socket or a bare word is legitimate, undrawable, and not broken."""
|
|
bare = {"location": "/", "scheme": None, "target": None, "variable": None}
|
|
section = nginx_section([nginx_server([9000], ["sock.example.com"], [bare])])
|
|
assert gateway.nginx_warnings(section) == []
|
|
assert gateway.config_connections(None, section) == []
|
|
|
|
|
|
def test_an_unreferenced_map_with_no_address_entries_warns_about_nothing() -> None:
|
|
"""The `$connection_upgrade` map's `upgrade`/`close` are header words, not missing routes."""
|
|
section = nginx_section(
|
|
[nginx_server([80], ["_"], [{"location": "/", "scheme": "http",
|
|
"target": {"host": "10.0.0.4", "port": 80},
|
|
"variable": None}])],
|
|
maps=[mapping_block("connection_upgrade", [("default", None), ("", None)])])
|
|
assert gateway.nginx_warnings(section) == [], (
|
|
"a map no proxy_pass names is not a routing table, and its null targets are its business")
|
|
|
|
|
|
# --- the arms that must not be judged, and the pins on this fleet's real configs --- #
|
|
|
|
def test_an_unreadable_or_absent_section_warns_about_nothing(gateway_snapshot: Any) -> None:
|
|
"""A host that cannot be read has not been found guilty of anything (invariant 3).
|
|
|
|
Three arms, driven from the **real** captured tree through the real agent parser, and each one
|
|
still reports the half it *can* report: `no_haproxy` keeps its nginx warnings-free record,
|
|
`predates` warns about nothing because nothing was read.
|
|
"""
|
|
for mode in ("no_haproxy", "no_nginx", "predates"):
|
|
payload = gateway.parse(gateway_snapshot(mode), "gateway")
|
|
assert payload["warnings"] == [], mode
|
|
|
|
# and the same rule stated on hand-built sections, so it holds for `available: null` beside
|
|
# `available: false` (the fixture has no arm for "the file is there and unreadable")
|
|
assert gateway.haproxy_warnings(haproxy_section(
|
|
[frontend("f", [22], default="nowhere")], available=False)) == []
|
|
assert gateway.haproxy_warnings(haproxy_section(
|
|
[frontend("f", [22], default="nowhere")], available=None)) == []
|
|
assert gateway.haproxy_warnings(None) == []
|
|
assert gateway.nginx_warnings(nginx_section(
|
|
[nginx_server([], ["x.example.com"])])) != [], "the control: an available section IS judged"
|
|
assert gateway.nginx_warnings(nginx_section([nginx_server([], ["x.example.com"])],
|
|
available=None)) == []
|
|
assert gateway.nginx_warnings(None) == []
|
|
|
|
|
|
def test_the_captured_configs_produce_no_warnings(gateway_snapshot: Any) -> None:
|
|
"""This fleet's own ingress is clean, and the reason per kind is part of the pin.
|
|
|
|
Seven checks that never fire on a real config would be a lie about this fleet and a source of
|
|
phantom alerts, so each kind is answered with the fact in the capture that stops it: the six
|
|
frontends each name a declared backend, the six backends are each routed and each carry a
|
|
`server`, every nginx `server` block listens, no `proxy_pass` names a pool, the one
|
|
`proxy_pass $var` has its `map`, and every entry of that referenced map carries an address
|
|
(the *unreferenced* `$connection_upgrade` map's null targets are not routes at all).
|
|
"""
|
|
snap = gateway_snapshot("full")
|
|
payload = gateway.parse(snap, "gateway")
|
|
assert payload["warnings"] == []
|
|
|
|
haproxy, nginx = snap["haproxy"], snap["nginx"]
|
|
declared = {block["name"] for block in haproxy["backends"]}
|
|
routed = {frontend["default_backend"] for frontend in haproxy["frontends"]}
|
|
for frontend in haproxy["frontends"]:
|
|
routed.update(frontend["backends"])
|
|
assert declared == routed == {
|
|
"gitea-2222", "sec-gitea-2000", "iperf3-5201", "unifi-external-8080",
|
|
"testcraft-25565", "nimcraft-25566"}, "no dangling route and no orphan backend"
|
|
assert all(block["servers"] for block in haproxy["backends"]), "no backend routes nowhere"
|
|
|
|
assert all(server["listen"] for server in nginx["servers"]), "every vhost listens"
|
|
assert not [proxy for server in nginx["servers"] for proxy in server["proxy_passes"]
|
|
if proxy["target"] is not None and proxy["target"]["port"] is None], (
|
|
"no `proxy_pass` names a pool, declared or otherwise, on this gateway")
|
|
used = gateway.proxy_variables(nginx)
|
|
defined = {block["variable"].lstrip("$") for block in nginx["maps"]}
|
|
assert used == {"targetBackend"} and used <= defined, (
|
|
"the one variable a `proxy_pass` passes IS defined by a map")
|
|
referenced = [block for block in nginx["maps"] if block["variable"].lstrip("$") in used]
|
|
assert len(referenced) == 1 and len(referenced[0]["entries"]) == 17
|
|
assert all(entry["target"] for entry in referenced[0]["entries"]), (
|
|
"all 17 stream routes name an address, `default` included")
|
|
unreferenced = [block for block in nginx["maps"] if block not in referenced]
|
|
assert any(entry["target"] is None for block in unreferenced for entry in block["entries"]), (
|
|
"the capture must still hold null-target entries on the UNREFERENCED map, or this pin "
|
|
"proves the refusal rule about nothing")
|
|
|
|
|
|
def test_warnings_are_deterministic_deduplicated_and_json_serialisable() -> None:
|
|
"""Same input twice, one output; the same fault twice, one row; every row a plain JSON object.
|
|
|
|
The page keys its DOM on `id` and `alerts.py` quotes `warnings[0]`, so a list that reordered
|
|
between polls would reshuffle the panel and a row that carried a non-JSON value would 500 the
|
|
route. Deduplication is exercised the way the real configs do it: one frontend with two `bind`
|
|
lines is one broken route, not two, and two frontends with the same broken name are two.
|
|
"""
|
|
section_h = haproxy_section(
|
|
[frontend("twice", [22, 2222], default="gone"),
|
|
frontend("also", [33], default="gone")],
|
|
[backend("live", [server("s", "10.0.0.1", 80)])])
|
|
section_n = nginx_section([nginx_server([], ["x.example.com"], [pool_pass("nope")])])
|
|
first = gateway.warnings(section_h, section_n)
|
|
assert gateway.warnings(section_h, section_n) == first, "deterministic"
|
|
assert gateway.warnings(section_h, section_n) is not first
|
|
assert len(first) == len({row["id"] for row in first}), "deduplicated by id"
|
|
assert kinds(first) == [gateway.WARN_ROUTE_DANGLING, gateway.WARN_ROUTE_DANGLING,
|
|
gateway.WARN_BACKEND_ORPHAN, gateway.WARN_SERVER_UNLISTENED,
|
|
gateway.WARN_POOL_UNDECLARED], (
|
|
"haproxy kinds first and in config order, then nginx kinds — the order the page lists")
|
|
assert first[0]["id"] == "haproxy_route_dangling:twice->gone", (
|
|
"one warning per broken line, never one per bind")
|
|
assert first[1]["id"] == "haproxy_route_dangling:also->gone"
|
|
json.dumps(first)
|
|
assert all(set(row) == {"id", "kind", "subject", "message"} for row in first), (
|
|
"a fourth key would reach the page and the event feed unnoticed")
|
|
assert all(isinstance(value, str) for row in first for value in row.values())
|
|
assert not walk_keys(first) & {"severity", "window_seconds"}, (
|
|
"a config warning is a structural fact, never a graded number")
|
|
|
|
|
|
def test_a_row_that_names_nothing_contributes_nothing_and_a_repeated_row_is_one() -> None:
|
|
"""The two defensive arms of `warnings()`, driven from the shapes a real file can produce.
|
|
|
|
A `backend` line with no name is not a backend anybody can repair, and a config that declares
|
|
the same empty block twice (a copy-paste, an `include` that lands twice on one file) is one
|
|
fault with one fix — so the deduplication the docstring promises is measured here rather than
|
|
assumed, and the row that cannot be named is dropped instead of warning as `""`.
|
|
"""
|
|
section = haproxy_section(
|
|
[frontend("haproxy-22", [22], default="empty-be")],
|
|
[{"servers": [server("s", "10.0.0.1", 22)]}, # a backend with no name at all
|
|
backend("empty-be", []), backend("empty-be", [])]) # the same block declared twice
|
|
assert len(gateway.haproxy_warnings(section)) == 2, (
|
|
"the per-section check reports what the file says — it is `warnings()` that owns the dedup")
|
|
found = gateway.warnings(section, None)
|
|
assert kinds(found) == [gateway.WARN_BACKEND_EMPTY], found
|
|
assert found[0]["id"] == "haproxy_backend_empty:empty-be", (
|
|
"one row for the duplicated block — the page keys its DOM on `id` and two identical rows "
|
|
"would be one fault counted twice")
|
|
|
|
|
|
def test_warnings_travel_in_the_payload_and_the_stub_share_the_key(gateway_snapshot: Any) -> None:
|
|
"""`payload["warnings"]` is there in every arm, and the socket half cannot silence it.
|
|
|
|
The stub parity matters because the page renders both arms from one renderer; the sockets arm
|
|
matters because a config is just as broken when `/proc` will not answer — a warning is a config
|
|
fact, not a socket fact, and `[]` in the `predates` arm is the same key holding the honest value
|
|
rather than a missing one.
|
|
"""
|
|
broken = haproxy_section([frontend("haproxy-22", [22], default="gone")])
|
|
snap = snapshot(haproxy=broken, nginx=nginx_section(),
|
|
sockets=sockets_section(available=None))
|
|
payload = gateway.parse(snap, "gateway")
|
|
assert kinds(payload["warnings"]) == [gateway.WARN_ROUTE_DANGLING], (
|
|
"a silent socket table says nothing about the config, so it changes no warning")
|
|
assert payload["sockets"] == {"listens": None, "established": None}
|
|
|
|
assert gateway.stub("gateway")["warnings"] == []
|
|
assert gateway.parse(gateway_snapshot("predates"), "gateway")["warnings"] == []
|
|
assert set(gateway.stub("gateway")) - {"unreachable"} <= set(gateway.parse(snap, "gateway"))
|