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:
+741
-26
@@ -18,13 +18,18 @@ outside the two configs is *reported*, because it no longer is —
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
import json
|
||||
import socket as socket_module
|
||||
from collections.abc import Mapping, Sequence
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from dashboards.sources import gateway
|
||||
|
||||
#: This repo's recorded captures, for the pins that read a fixture directly.
|
||||
FIXTURES = Path(__file__).resolve().parent / "fixtures"
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# synthetic builders: a section exactly as the agent serves it, minus the file
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -111,10 +116,31 @@ def conn(local: tuple[str, int], remote: tuple[str, int], *,
|
||||
"remote": {"addr": remote[0], "port": remote[1]}}
|
||||
|
||||
|
||||
def resolver_section(entries: Sequence[dict[str, Any]] = (), *, available: bool | None = True,
|
||||
via: str | None = "libc", config_directive: str | None = "10.1.0.1",
|
||||
ts: float = 1_760_000_000.0,
|
||||
errors: Sequence[str] = ()) -> dict[str, Any]:
|
||||
"""Agent 1.3.0's fourth section, in the shape `host_agent.Collector.resolver` publishes."""
|
||||
return {"available": available, "via": via, "config_directive": config_directive, "ts": ts,
|
||||
"entries": list(entries), "errors": list(errors)}
|
||||
|
||||
|
||||
def resolved(name: str, port: int | None, addresses: Sequence[str], *,
|
||||
status: str = "ok", ts: float | None = 1_760_000_000.0) -> dict[str, Any]:
|
||||
"""One resolver entry: what libc answered for `(name, port)`, and when."""
|
||||
return {"name": name, "port": port, "status": status, "addresses": list(addresses), "ts": ts}
|
||||
|
||||
|
||||
def snapshot(*, haproxy: dict[str, Any] | None = None, nginx: dict[str, Any] | None = None,
|
||||
sockets: dict[str, Any] | None = None, errors: Sequence[str] = (),
|
||||
sockets: dict[str, Any] | None = None, resolver: 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."""
|
||||
"""The document one agent `/snapshot` would answer, with only what a test names.
|
||||
|
||||
No `resolver` key is the default, because that is the arm most of this file predates: a 1.2.0
|
||||
agent, or a 1.3.0 one run with `--no-resolve`. A test that wants a name to be countable passes
|
||||
`resolver=resolver_section([...])` and says so.
|
||||
"""
|
||||
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:
|
||||
@@ -125,6 +151,8 @@ def snapshot(*, haproxy: dict[str, Any] | None = None, nginx: dict[str, Any] | N
|
||||
doc["nginx"] = nginx
|
||||
if sockets is not None:
|
||||
doc["sockets"] = sockets
|
||||
if resolver is not None:
|
||||
doc["resolver"] = resolver
|
||||
return doc
|
||||
|
||||
|
||||
@@ -162,6 +190,52 @@ def out_metrics(payload: Mapping[str, Any]) -> list[str]:
|
||||
return [row["metric"] for row in payload["connections"] if row["metric"] is not None]
|
||||
|
||||
|
||||
def endpoint_keys(row: Mapping[str, Any]) -> set[tuple[str, int]]:
|
||||
"""The normalised `(address, port)` keys one route reaches, derived **in the test**.
|
||||
|
||||
Read straight off `out.address` / `out.resolved` with `ipaddress`, deliberately not by calling
|
||||
`gateway.endpoint_of`: a total a test compares against must not come from the function under
|
||||
test. The normalisation is the same one the join makes, because `::ffff:10.1.2.64` and
|
||||
`10.1.2.64` are one endpoint and a ledger that counted them apart would reconcile to nothing.
|
||||
"""
|
||||
out = row["out"]
|
||||
port = out.get("port")
|
||||
if not isinstance(port, int):
|
||||
return set()
|
||||
spellings = [out["host"]] if out.get("address") is True else list(out.get("resolved") or [])
|
||||
keys: set[tuple[str, int]] = set()
|
||||
for spelling in spellings:
|
||||
try:
|
||||
address = ipaddress.ip_address(str(spelling))
|
||||
except ValueError:
|
||||
continue
|
||||
mapped = getattr(address, "ipv4_mapped", None)
|
||||
keys.add((str(mapped if mapped is not None else address), port))
|
||||
return keys
|
||||
|
||||
|
||||
def endpoint_groups(payload: Mapping[str, Any]) -> dict[tuple[str, int], list[Mapping[str, Any]]]:
|
||||
"""The route lines grouped by the **distinct endpoint** they reach, keyed by min(key).
|
||||
|
||||
Lines whose key sets are identical share a group, which is every case a capture can produce
|
||||
(one address per name, or one name written by two vhosts). `gateway._endpoint_groups` merges
|
||||
*overlapping* sets as well, for a multi-homed name sharing one address with a single-homed one;
|
||||
no capture has that shape, and if one ever does this helper disagrees by keeping two groups —
|
||||
which fails the totals test below loudly rather than quietly.
|
||||
"""
|
||||
groups: dict[tuple[str, int], list[Mapping[str, Any]]] = {}
|
||||
for row in payload["connections"]:
|
||||
keys = endpoint_keys(row)
|
||||
if keys:
|
||||
groups.setdefault(min(keys), []).append(row)
|
||||
return groups
|
||||
|
||||
|
||||
def distinct_endpoint_totals(payload: Mapping[str, Any]) -> dict[tuple[str, int], Any]:
|
||||
"""One count per distinct endpoint — the figure a total may add, never one per line."""
|
||||
return {key: rows[0]["connections"] for key, rows in endpoint_groups(payload).items()}
|
||||
|
||||
|
||||
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):
|
||||
@@ -200,7 +274,7 @@ def test_the_captured_configs_yield_every_configured_pipe(gateway_snapshot: Any)
|
||||
"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
|
||||
assert len(payload["nginx"]["map_targets"]) == 18
|
||||
homepage = next(row for row in payload["nginx"]["map_targets"]
|
||||
if row["key"] == "homepage.reeseapps.com")
|
||||
assert homepage == {"key": "homepage.reeseapps.com",
|
||||
@@ -229,7 +303,12 @@ def test_the_source_reads_the_keys_the_agent_actually_publishes(gateway_snapshot
|
||||
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"
|
||||
# At least 1.2.0, the version that shipped these three keys. It is a floor and not an
|
||||
# equality on purpose: phase 32 added a fourth section and bumped the constant, and the
|
||||
# coupling this test pins is that the three the source joins still exist in what an agent
|
||||
# publishes, whatever the number on it has since grown.
|
||||
version = tuple(int(part) for part in host_agent_module.AGENT_VERSION.split("."))
|
||||
assert version >= (1, 2, 0), "the sections these keys belong to"
|
||||
|
||||
|
||||
def test_the_live_counts_reconcile_with_the_socket_table(gateway_snapshot: Any) -> None:
|
||||
@@ -247,29 +326,60 @@ def test_the_live_counts_reconcile_with_the_socket_table(gateway_snapshot: Any)
|
||||
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.
|
||||
|
||||
Run against the **`resolved`** capture, because that is what the deployed ingress serves since
|
||||
phase 32 task 05: the widened match set is the one the arithmetic has to hold for. Three
|
||||
figures, read three ways:
|
||||
|
||||
* `in_total` — the sum over the listeners (each socket at most once, inbound);
|
||||
* `out_total` — the sum over **distinct endpoints**, derived here from `out.address` /
|
||||
`out.resolved` rather than from `gateway.endpoint_of`;
|
||||
* `lines_total` — the sum over the **lines**, which is deliberately larger wherever a line
|
||||
repeats a number (`shared`), and is pinned to be exactly `out_total` plus the repetitions.
|
||||
|
||||
The `127.0.0.1:4443` loopback hop is the boundary case: it is booked **once inbound** (the rows
|
||||
whose local port is 4443, on the `:4443` trunk) and **once outbound** (the rows whose remote is
|
||||
4443, on the `:443 → 127.0.0.1:4443` pipe) and no single `/proc` row is in both.
|
||||
"""
|
||||
snap = gateway_snapshot("full")
|
||||
snap = gateway_snapshot("resolved")
|
||||
payload = gateway.parse(snap, "gateway")
|
||||
established = len(snap["sockets"]["established"])
|
||||
established = 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())
|
||||
totals = distinct_endpoint_totals(payload)
|
||||
out_total = sum(value or 0 for value in totals.values())
|
||||
lines_total = sum(row["connections"] or 0 for row in payload["connections"])
|
||||
repeated = sum((len(rows) - 1) * (rows[0]["connections"] or 0)
|
||||
for rows in endpoint_groups(payload).values() if len(rows) > 1)
|
||||
|
||||
rows_in_table = len(established)
|
||||
assert payload["sockets"] == {"listens": len(snap["sockets"]["listens"]),
|
||||
"established": established}
|
||||
assert in_total + out_total <= established
|
||||
assert in_total + out_total < established, (
|
||||
"established": rows_in_table}
|
||||
assert in_total + out_total <= rows_in_table
|
||||
assert in_total + out_total < rows_in_table, (
|
||||
"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
|
||||
assert lines_total == out_total + repeated, (
|
||||
"the only difference between the lines and the endpoints is a repeated count — no socket "
|
||||
"is booked twice and no endpoint is booked once per name that reaches it")
|
||||
|
||||
# the loopback hop, both sides, from the raw table rather than from either counter
|
||||
accepted = [row for row in established if (row["local"]["port"]) == 4443]
|
||||
dialled = [row for row in established
|
||||
if row["local"]["port"] != 4443 and (row["remote"]["addr"], row["remote"]["port"])
|
||||
== ("127.0.0.1", 4443)]
|
||||
assert accepted and dialled, "this capture is the loop the stream map's `default` produces"
|
||||
assert listener_at(payload, 4443)["connections"] == len(accepted)
|
||||
assert endpoint_count(payload, "127.0.0.1", 4443) == len(dialled)
|
||||
assert not [row for row in established
|
||||
if row["local"]["port"] == 4443
|
||||
and (row["remote"]["addr"], row["remote"]["port"]) == ("127.0.0.1", 4443)], (
|
||||
"a row that was both would be counted on two pipes by two rules, and the ≤ above would "
|
||||
"still hold while every figure in the payload was wrong")
|
||||
|
||||
|
||||
def test_a_socket_neither_config_describes_is_counted_nowhere(gateway_snapshot: Any) -> None:
|
||||
@@ -293,7 +403,7 @@ def test_every_connection_the_captured_configs_declare_is_drawn(gateway_snapshot
|
||||
"""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-
|
||||
here too: 6 haproxy `server` lines, 18 stream-`map` entries (the 17 `*.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.
|
||||
@@ -317,6 +427,7 @@ def test_every_connection_the_captured_configs_declare_is_drawn(gateway_snapshot
|
||||
(443, "reeseapps-gitea.reeselink.com", 443),
|
||||
(443, "reeseapps-homepage.reeselink.com", 443),
|
||||
(443, "reeseapps-immich.reeselink.com", 443),
|
||||
(443, "reeseapps-whiteboard.reeselink.com", 443),
|
||||
(443, "reeseapps-keycloak.reeselink.com", 443),
|
||||
(443, "reeseapps-litellm.reeselink.com", 443),
|
||||
(443, "reeseapps-ntfy.reeselink.com", 443),
|
||||
@@ -362,9 +473,9 @@ def test_the_map_fanout_is_routes_and_never_a_variable_host(gateway_snapshot: An
|
||||
"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"
|
||||
assert brain["out"]["address"] is False, "the config wrote a name, and the config is verbatim"
|
||||
assert brain["connections"] is None, "and on this arm (no resolver section) nothing placed it"
|
||||
assert brain["metric"] is None, "so it publishes no series"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -476,10 +587,12 @@ 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.
|
||||
same connection in the operator's head and two unrelated strings **in this stack**. The bridge is
|
||||
the gateway's own resolution (phase 32, tested below), 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. With nothing to bridge them the line has no number, no gauge, and no
|
||||
apology to render; with the agent's document it is counted through the address, and this test
|
||||
stays true because the snapshot it builds has no resolver section.
|
||||
"""
|
||||
snap = snapshot(
|
||||
nginx=nginx_section([nginx_server([4443], ["cloud.example.test"],
|
||||
@@ -503,7 +616,7 @@ def test_a_name_target_is_never_credited_for_a_socket_on_its_port() -> None:
|
||||
|
||||
|
||||
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 real `stream.conf` shape: sixteen names on 443, none of them counted **without DNS**.
|
||||
|
||||
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.
|
||||
@@ -557,6 +670,68 @@ def test_no_join_ever_asks_a_resolver() -> None:
|
||||
assert calls == []
|
||||
|
||||
|
||||
def test_the_recorded_resolver_document_reaches_every_name_written_route(
|
||||
gateway_snapshot: Any) -> None:
|
||||
"""What phase 32 buys, priced against the captures — and spent, since task 03.
|
||||
|
||||
31 of this ingress's 33 routes carried an em dash because this module joins sockets by
|
||||
**address** and the two config files write those endpoints as **names**. The recorded
|
||||
`gateway_resolver.json` is the thing that removed that reason, so this pins the two properties
|
||||
the join needs and no more:
|
||||
|
||||
* every name-written route has an entry in the document, for that name **and that port**, with
|
||||
at least one address — so no route on `/gateway` is dashed forever because the resolver had
|
||||
nothing to say about its endpoint;
|
||||
* the addresses are the same currency the socket table is written in: the two captures overlap
|
||||
on live remote endpoints, which is what a join has to have to join anything.
|
||||
|
||||
and, in the last few lines, that spending it is a fact about the **document** rather than a
|
||||
coincidence of the port table: the name-written routes are counted exactly where the entry puts
|
||||
an address and nowhere else. This module still resolves nothing — the spy pin above stays exactly
|
||||
as it was, and is the reason the fixture has to exist.
|
||||
"""
|
||||
snap = gateway_snapshot("full")
|
||||
payload = gateway.parse(snap, "gateway")
|
||||
document = json.loads((FIXTURES / "gateway_resolver.json").read_text())
|
||||
answered = {(row["name"], row["port"]) for row in document["entries"] if row["addresses"]}
|
||||
|
||||
written = {(row["out"]["host"], row["out"]["port"]) for row in payload["connections"]
|
||||
if not row["out"]["address"]}
|
||||
assert written, "this capture writes its targets as names, which is the gap the fixture fills"
|
||||
unreached = written - answered
|
||||
assert not unreached, ("name-written routes the resolver document cannot reach: "
|
||||
f"{sorted(unreached)} — re-run ./scripts/capture-gateway-config.sh")
|
||||
|
||||
resolved = {address for row in document["entries"] for address in row["addresses"]}
|
||||
remote = {row["remote"]["addr"] for row in snap["sockets"]["established"]}
|
||||
assert resolved & remote, (
|
||||
"the resolver document and the socket table share no address, so a join built on it could "
|
||||
"attribute nothing — the two captures are from different boxes or one scrub moved a number")
|
||||
assert all(row["connections"] is None for row in payload["connections"]
|
||||
if (row["out"]["host"], row["out"]["port"]) in written), (
|
||||
"this arm is the snapshot with no `resolver` key at all, so a name-written route still "
|
||||
"carries no count here — and adding one address to that section is what changes it")
|
||||
|
||||
spent = gateway.parse(gateway_snapshot("resolved"), "gateway")
|
||||
rows = {(row["out"]["host"], row["out"]["port"]): row for row in spent["connections"]}
|
||||
trunks = {row["port"] for row in spent["listeners"]}
|
||||
for name, port in sorted(written):
|
||||
row = rows[(name, port)]
|
||||
entry = next(item for item in document["entries"]
|
||||
if (item["name"], item["port"]) == (name, port))
|
||||
assert row["out"]["resolved"] == entry["addresses"], f"{name}:{port} rewrites the capture"
|
||||
assert row["connections"] is not None, f"{name}:{port} is placed and still carries no count"
|
||||
# the exact figure, re-counted from the raw table by this test: the rows that reach this
|
||||
# endpoint, minus the ones the inbound arm claimed first (a socket is counted at most once)
|
||||
keys = {(address, port) for address in entry["addresses"]}
|
||||
live = [item for item in snap["sockets"]["established"]
|
||||
if item["local"]["port"] not in trunks
|
||||
and (item["remote"]["addr"], item["remote"]["port"]) in keys]
|
||||
assert row["connections"] == len(live), (
|
||||
f"{name}:{port} resolves to {sorted(keys)} and the table holds {len(live)} such "
|
||||
f"sockets, but the line reads {row['connections']}")
|
||||
|
||||
|
||||
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(
|
||||
@@ -750,6 +925,546 @@ def test_an_endpoint_reached_by_one_line_is_not_shared() -> None:
|
||||
assert row["connections"] == 1
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# phase 32: the resolver section — a name counted through the address the gateway dialled
|
||||
#
|
||||
# Everything above this line runs on a snapshot with **no** `resolver` key, which is agent 1.2.0 (or
|
||||
# 1.3.0 with `--no-resolve`) and therefore phase 30 verbatim. Everything below it spends the
|
||||
# document `scripts/capture-gateway-config.sh` recorded with `getent` on the router: the resolution
|
||||
# haproxy and nginx perform **on the box that opens the connection**, which is the only vantage that
|
||||
# can join `reeseapps-brain-of-reese.reeselink.com` to the `10.1.2.64` `/proc` reports. The
|
||||
# dashboard asking the same name through its own `resolv.conf` would be a different vantage
|
||||
# answering a different question — and `test_the_dashboard_still_never_resolves` is the spy that
|
||||
# keeps it that way.
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def test_a_name_route_counts_through_the_address_the_gateway_published(
|
||||
gateway_snapshot: Any) -> None:
|
||||
"""The whole point of phase 32, measured against the captures.
|
||||
|
||||
`stream.conf` routes `brain.reeseapps.com` to `reeseapps-brain-of-reese.reeselink.com:443`,
|
||||
`/proc` would report a socket to `10.1.2.64:443`, and until this section arrived nothing could
|
||||
join the two. Now the line carries the config's text and the agent's answer side by side, and
|
||||
the count comes through the answer.
|
||||
"""
|
||||
snap = gateway_snapshot("resolved")
|
||||
payload = gateway.parse(snap, "gateway")
|
||||
brain = next(row for row in payload["connections"]
|
||||
if row["out"]["host"] == "reeseapps-brain-of-reese.reeselink.com")
|
||||
assert brain["in"] == {"port": 443}
|
||||
assert brain["out"]["selectors"] == ["brain.reeseapps.com"], "the config's word, untouched"
|
||||
assert brain["out"]["address"] is False, "the config wrote a name, and still says so"
|
||||
assert brain["out"]["resolved"] == ["10.1.2.64"], "the agent's answer, verbatim"
|
||||
assert brain["out"]["via"] == "libc", "the vantage is the agent's own string, copied"
|
||||
assert brain["out"]["resolved_ts"] == 1_792_886_400.0, "that entry's own timestamp"
|
||||
assert brain["metric"] == "gateway.out_conns@reeseapps-brain-of-reese.reeselink.com:443", (
|
||||
"the gauge keeps the config's spelling, so history continues under one name")
|
||||
assert brain["connections"] == 0, "resolved and idle is `0`, never the dash"
|
||||
|
||||
ntfy = next(row for row in payload["connections"]
|
||||
if row["out"]["host"] == "reeseapps-ntfy.reeselink.com")
|
||||
live = [row for row in snap["sockets"]["established"]
|
||||
if (row["remote"]["addr"], row["remote"]["port"]) == ("10.1.1.176", 443)]
|
||||
assert live, "the recorded table has live ntfy sockets for the join to find"
|
||||
assert ntfy["connections"] == len(live), "every socket to the address the name became"
|
||||
assert ntfy["shared"] is False, "one route reaches that endpoint, so nothing to repeat"
|
||||
|
||||
|
||||
def test_an_endpoint_the_config_wrote_as_an_address_claims_no_lookup(
|
||||
gateway_snapshot: Any) -> None:
|
||||
"""`resolved` answers *what did this name become*, and an address was never a name.
|
||||
|
||||
The agent publishes a `literal` entry for a target that is already an address, and the resolver
|
||||
block lists it verbatim. Stamping that echo onto the route as `resolved` beside `via: "libc"`
|
||||
would report a `getaddrinfo` this gateway never ran, so the three provenance fields stay `null`
|
||||
on an `address: true` row while the count and the gauge keep working exactly as phase 30 left
|
||||
them. Two facts stay two sentences: `address` is what the config wrote, `resolved` is what the
|
||||
gateway looked up.
|
||||
"""
|
||||
payload = gateway.parse(gateway_snapshot("resolved"), "gateway")
|
||||
written = [row for row in payload["connections"] if row["out"]["address"] is True]
|
||||
assert written, "this capture writes two targets as addresses, which is phase 30's joinable pair"
|
||||
for row in written:
|
||||
assert row["out"]["host"] in {entry["name"]
|
||||
for entry in payload["resolver"]["entries"]}, (
|
||||
"the agent did publish this literal, and the block still lists it")
|
||||
assert row["out"]["resolved"] is None and row["out"]["via"] is None \
|
||||
and row["out"]["resolved_ts"] is None, (
|
||||
f"{row['id']}: an address row must not borrow a lookup claim")
|
||||
assert row["metric"] == f"gateway.out_conns@{row['out']['host']}:{row['out']['port']}"
|
||||
assert row["connections"] is not None, "phase 30's address join is untouched by this"
|
||||
|
||||
|
||||
def test_a_socket_lands_on_the_name_route_that_resolved_to_its_address() -> None:
|
||||
"""The synthetic version, so the claim does not depend on what a capture happened to contain."""
|
||||
snap = snapshot(
|
||||
nginx=nginx_section([nginx_server([443], [], [proxy_pass(None, variable="targetBackend")])],
|
||||
maps=[mapping_block("$targetBackend",
|
||||
[("brain.example.test",
|
||||
("brain-backend.example.test", 443))])]),
|
||||
sockets=sockets_section(
|
||||
listens=[listen(443)],
|
||||
established=[conn(("::", 51000), ("10.1.2.64", 443), family="v6"),
|
||||
conn(("10.1.1.27", 443), ("203.0.113.7", 900))]),
|
||||
resolver=resolver_section([resolved("brain-backend.example.test", 443, ["10.1.2.64"])]))
|
||||
payload = gateway.parse(snap, "gateway")
|
||||
row = routes_to(payload, "brain-backend.example.test")[0]
|
||||
assert row["out"]["address"] is False and row["out"]["resolved"] == ["10.1.2.64"]
|
||||
assert row["connections"] == 1
|
||||
assert row["metric"] == "gateway.out_conns@brain-backend.example.test:443"
|
||||
assert listener_at(payload, 443)["connections"] == 1, "the inbound half is untouched"
|
||||
gauge = [g for g in gateway.samples(payload, 5.0)
|
||||
if g.metric == "gateway.out_conns@brain-backend.example.test:443"]
|
||||
assert [(g.metric, g.value) for g in gauge] == [
|
||||
("gateway.out_conns@brain-backend.example.test:443", 1.0)]
|
||||
|
||||
|
||||
def test_the_match_is_an_address_not_a_string() -> None:
|
||||
"""One address, two spellings, in either direction: the join still lands.
|
||||
|
||||
`/proc`'s `inet_ntop` writes an IPv4 peer seen through the IPv6 table as `::ffff:10.1.2.64`
|
||||
while a resolver answers `10.1.2.64`, and an IPv6 in one table can carry leading zeros and
|
||||
capitals the other does not. `ipaddress` canonicalises both, so the **match key** is one address
|
||||
while the **payload** keeps publishing whatever the config and the agent wrote — a page that
|
||||
printed the normalised form would be quoting a string nobody configured.
|
||||
|
||||
A join that compared strings instead would report a resolved, dialled route as idle forever:
|
||||
the exact failure this phase exists to remove, arriving back through a spelling nobody thought
|
||||
about. The two directions are asserted separately because each half is a different table.
|
||||
"""
|
||||
def build(written: str, socketed: str) -> dict[str, Any]:
|
||||
snap = snapshot(
|
||||
haproxy=haproxy_section([frontend("peer", [8001], default="peer-be")],
|
||||
[backend("peer-be", [server("p", "peer.example.test", 443)])]),
|
||||
sockets=sockets_section(
|
||||
established=[conn(("::", 40000), (socketed, 443), family="v6")]),
|
||||
resolver=resolver_section([resolved("peer.example.test", 443, [written])]))
|
||||
return gateway.parse(snap, "gateway")
|
||||
|
||||
for written, socketed in (("10.1.2.64", "::ffff:10.1.2.64"),
|
||||
("::ffff:10.1.2.64", "10.1.2.64"),
|
||||
("2001:db8::1", "2001:0DB8::0001"),
|
||||
("2001:0DB8::0001", "2001:db8::1")):
|
||||
payload = build(written, socketed)
|
||||
row = routes_to(payload, "peer.example.test")[0]
|
||||
assert row["connections"] == 1, f"{written} vs {socketed} is one address and did not match"
|
||||
assert row["out"]["resolved"] == [written], "the payload keeps the agent's own spelling"
|
||||
|
||||
|
||||
def test_a_zero_padded_ipv4_is_no_address_to_anyone_and_matches_nothing() -> None:
|
||||
"""The one spelling this module refuses to fold, and why refusing is the safe half.
|
||||
|
||||
`ipaddress` rejects `010.1.2.64` because `inet_aton` reads a leading `0` as **octal**: the
|
||||
decimal and octal readings are two different addresses, and picking one here would book a
|
||||
socket on a route by coin flip. So a padded target is a **name** to this module — and the
|
||||
resolver section, which is libc's own answer, is what turns it into an address the kernel agrees
|
||||
with. Nothing is guessed in between.
|
||||
"""
|
||||
snap = snapshot(
|
||||
haproxy=haproxy_section([frontend("be", [8080], default="be")],
|
||||
[backend("be", [server("odd", "010.1.2.64", 443)])]),
|
||||
sockets=sockets_section(
|
||||
established=[conn(("10.1.1.27", 40000), ("10.1.2.64", 443)),
|
||||
conn(("10.1.1.27", 40002), ("8.1.2.64", 443))]))
|
||||
row = routes_to(gateway.parse(snap, "gateway"), "010.1.2.64")[0]
|
||||
assert row["out"]["address"] is False, "not an address here, so it is a name"
|
||||
assert row["connections"] is None and row["metric"] is None, (
|
||||
"and with no resolver entry it is counted nowhere — neither reading is chosen for it")
|
||||
|
||||
# …and the fix is the resolver's, not this module's: libc's own answer places the same route,
|
||||
# the padded spelling travels in the payload untouched, and neither socket is read as an octet.
|
||||
placed = snapshot(
|
||||
haproxy=haproxy_section([frontend("be", [8080], default="be")],
|
||||
[backend("be", [server("odd", "010.1.2.64", 443)])]),
|
||||
sockets=sockets_section(
|
||||
established=[conn(("10.1.1.27", 40000), ("10.1.2.64", 443)),
|
||||
conn(("10.1.1.27", 40002), ("8.1.2.64", 443))]),
|
||||
resolver=resolver_section([resolved("010.1.2.64", 443, ["10.1.2.64"])]))
|
||||
fixed = routes_to(gateway.parse(placed, "gateway"), "010.1.2.64")[0]
|
||||
assert fixed["out"]["resolved"] == ["10.1.2.64"]
|
||||
assert fixed["connections"] == 1, (
|
||||
"the one socket to the address libc returned, not the octal-reading 8.1.2.64 and not both")
|
||||
|
||||
|
||||
def test_two_names_one_endpoint_is_one_count_shared() -> None:
|
||||
"""Two **names** the gateway placed on one address are one endpoint and one number.
|
||||
|
||||
Phase 30 knew this shape only for one address written twice; resolving makes it ordinary, and
|
||||
the rule did not change: the endpoint's whole count is repeated on both lines with
|
||||
`shared: true`, never halved and never attributed to one of them.
|
||||
|
||||
Two readers then differ, and both are right. **Every total** books the endpoint once
|
||||
(`distinct_endpoint_counts`, which is what `/api/summary`'s `live_out` reads), while the gauge
|
||||
is per line-family — one series per name the config wrote, each carrying that endpoint's number,
|
||||
because a route's own history is asked for by the name on the route. Two names therefore store
|
||||
two series of one figure; lines that share a **name** (`:4443` and `:8443` reaching one target)
|
||||
still store exactly one.
|
||||
"""
|
||||
snap = snapshot(
|
||||
nginx=nginx_section([nginx_server([4443], ["a.example.test"],
|
||||
[proxy_pass(("one.example.test", 8080))]),
|
||||
nginx_server([8443], ["b.example.test"],
|
||||
[proxy_pass(("two.example.test", 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))]),
|
||||
resolver=resolver_section([resolved("one.example.test", 8080, ["10.5.5.5"]),
|
||||
resolved("two.example.test", 8080, ["10.5.5.5"])]))
|
||||
payload = gateway.parse(snap, "gateway")
|
||||
rows = [row for row in payload["connections"] if row["in"]["port"] in (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 gateway.distinct_endpoint_counts(payload["connections"]) == [2], (
|
||||
"and one figure for anything that adds the outbound side up")
|
||||
assert out_metrics(payload) == ["gateway.out_conns@one.example.test:8080",
|
||||
"gateway.out_conns@two.example.test:8080"]
|
||||
|
||||
|
||||
def test_two_spellings_of_one_multi_homed_name_are_one_endpoint() -> None:
|
||||
"""A name with three addresses is **one** route, not three, and its sockets are one count.
|
||||
|
||||
Every key the resolver published points at the same counter row, so a client round-robin across
|
||||
`10.5.5.5`/`10.5.5.6` reads as three connections on one pipe. The case union-find exists for is
|
||||
two names whose address sets *overlap* — a multi-homed host and a single-homed alias for one of
|
||||
its addresses — which would otherwise count the same endpoint twice in one total.
|
||||
"""
|
||||
snap = snapshot(
|
||||
nginx=nginx_section([nginx_server([4443], ["a.example.test"],
|
||||
[proxy_pass(("multi.example.test", 443))]),
|
||||
nginx_server([8443], ["b.example.test"],
|
||||
[proxy_pass(("alias.example.test", 443))])]),
|
||||
sockets=sockets_section(
|
||||
established=[conn(("10.1.1.27", 40000), ("10.5.5.5", 443)),
|
||||
conn(("10.1.1.27", 40002), ("10.5.5.6", 443))]),
|
||||
resolver=resolver_section([resolved("multi.example.test", 443,
|
||||
["10.5.5.5", "10.5.5.6"]),
|
||||
resolved("alias.example.test", 443, ["10.5.5.6"])]))
|
||||
payload = gateway.parse(snap, "gateway")
|
||||
rows = [row for row in payload["connections"] if row["in"]["port"] in (4443, 8443)]
|
||||
assert [row["connections"] for row in rows] == [2, 2], (
|
||||
"the shared endpoint's two sockets, on both lines — not one socket each")
|
||||
assert [row["shared"] for row in rows] == [True, True]
|
||||
assert gateway.distinct_endpoint_counts(payload["connections"]) == [2], (
|
||||
"overlapping address sets are one endpoint, so a total books them once")
|
||||
|
||||
|
||||
def test_a_name_that_bridges_two_others_merges_all_three_lines_into_one_endpoint() -> None:
|
||||
"""A→{x}, B→{x,y}, C→{y}: three routes, one endpoint, one figure — transitivity included.
|
||||
|
||||
The bridging name is what makes this a *merge* rather than three pairwise comparisons: the first
|
||||
and third share no address, and yet the same kernel endpoint sits behind both, so counting them
|
||||
separately would book two endpoints where `/proc` has one. **The config order is load-bearing**
|
||||
beyond the transitivity: the bridge arrives second and its lowest address (`10.5.5.5`) is lower
|
||||
than the root the first route already built, so that whole group is re-parented, and the third
|
||||
route's lookup then has to walk through two links to find it — the path the union-find compresses
|
||||
so a fan-out of this size stays cheap on the next poll.
|
||||
"""
|
||||
snap = snapshot(
|
||||
nginx=nginx_section([nginx_server([4443], ["a.example.test"],
|
||||
[proxy_pass(("multi.example.test", 443))]),
|
||||
nginx_server([8443], ["b.example.test"],
|
||||
[proxy_pass(("bridge.example.test", 443))]),
|
||||
nginx_server([8444], ["c.example.test"],
|
||||
[proxy_pass(("alias.example.test", 443))])]),
|
||||
sockets=sockets_section(
|
||||
established=[conn(("10.1.1.27", 40000), ("10.5.5.6", 443)),
|
||||
conn(("10.1.1.27", 40002), ("10.5.5.7", 443))]),
|
||||
resolver=resolver_section([resolved("multi.example.test", 443,
|
||||
["10.5.5.6", "10.5.5.7"]),
|
||||
resolved("bridge.example.test", 443,
|
||||
["10.5.5.5", "10.5.5.6"]),
|
||||
resolved("alias.example.test", 443, ["10.5.5.7"])]))
|
||||
payload = gateway.parse(snap, "gateway")
|
||||
rows = [row for row in payload["connections"] if row["in"]["port"] in (4443, 8443, 8444)]
|
||||
assert len(rows) == 3
|
||||
assert [row["connections"] for row in rows] == [2, 2, 2], (
|
||||
"one endpoint's two sockets, repeated on all three lines")
|
||||
assert [row["shared"] for row in rows] == [True, True, True]
|
||||
assert gateway.distinct_endpoint_counts(payload["connections"]) == [2], (
|
||||
"and one figure for any total, however many names bridged their way to it")
|
||||
|
||||
|
||||
def test_a_resolver_entry_that_publishes_nothing_address_like_places_nothing() -> None:
|
||||
"""A malformed answer is a name with no address, not an address that matches by luck.
|
||||
|
||||
The agent publishes strings it got from `getaddrinfo`, and this module has no reason to believe
|
||||
them: an entry whose `addresses` are not addresses yields no endpoint key, so the route keeps its
|
||||
dash and its missing gauge exactly as an `nxdomain` does. The alternative — comparing the junk
|
||||
text against socket addresses — is how a typo in one file could credit a socket to a route.
|
||||
"""
|
||||
snap = snapshot(
|
||||
nginx=nginx_section([nginx_server([4443], ["odd.example.test"],
|
||||
[proxy_pass(("odd.example.test", 443))])]),
|
||||
sockets=sockets_section(established=[conn(("10.1.1.27", 40000), ("10.5.5.5", 443))]),
|
||||
resolver=resolver_section([
|
||||
resolved("odd.example.test", 443, ["localhost", "10.5.5.5.6", ""])]))
|
||||
row = routes_to(gateway.parse(snap, "gateway"), "odd.example.test")[0]
|
||||
assert row["out"]["resolved"] == ["localhost", "10.5.5.5.6"], (
|
||||
"the agent's bytes verbatim, minus the empty string — the payload is not where this is fixed")
|
||||
assert row["connections"] is None and row["metric"] is None, (
|
||||
"and not one of them is an address to match a socket with")
|
||||
|
||||
|
||||
def test_a_line_with_no_endpoint_contributes_nothing_to_a_total() -> None:
|
||||
"""`distinct_endpoint_counts` skips the unplaced line instead of offering it as a zero.
|
||||
|
||||
The caller turns "no figures at all" into `null` (`api._live_total`), which is the arm an
|
||||
ingress of nothing-but-unresolved-names lands in. A `null` inside the list instead would make
|
||||
every total on the page `null` because one route has a name nobody could place — and rule 2's
|
||||
complaint about that shape is precisely that it invents a fault in the healthy routes beside it.
|
||||
"""
|
||||
snap = snapshot(
|
||||
nginx=nginx_section([nginx_server([4443], ["up.example.test"],
|
||||
[proxy_pass(("up.example.test", 443))]),
|
||||
nginx_server([8443], ["gone.example.test"],
|
||||
[proxy_pass(("gone.example.test", 443))])]),
|
||||
sockets=sockets_section(established=[conn(("10.1.1.27", 40000), ("10.5.5.5", 443))]),
|
||||
resolver=resolver_section([resolved("up.example.test", 443, ["10.5.5.5"]),
|
||||
resolved("gone.example.test", 443, [], status="nxdomain")]))
|
||||
payload = gateway.parse(snap, "gateway")
|
||||
placed, unplaced = (routes_to(payload, "up.example.test")[0],
|
||||
routes_to(payload, "gone.example.test")[0])
|
||||
assert (placed["connections"], unplaced["connections"]) == (1, None)
|
||||
assert gateway.distinct_endpoint_counts(payload["connections"]) == [1], (
|
||||
"the placed endpoint alone: no second entry, no None, no zero")
|
||||
assert gateway.distinct_endpoint_counts([unplaced]) == [], (
|
||||
"and a payload of nothing-but-dashes is no figures at all, which the API renders as null")
|
||||
|
||||
|
||||
def test_an_unresolved_name_publishes_no_metric_and_no_sample() -> None:
|
||||
"""`nxdomain`, an error, and no entry at all are one answer here: drawn, never counted.
|
||||
|
||||
Three arms in one snapshot — a name the resolver placed, a name it looked up and got nothing
|
||||
for, and a name it never mentioned — so the difference between "no record" and "nobody asked"
|
||||
stays visible in the resolver block while the **route rows** behave identically: `connections`
|
||||
`null`, `metric` `null`, no sample, and never a `0` that reads as an idle route.
|
||||
"""
|
||||
snap = snapshot(
|
||||
nginx=nginx_section([
|
||||
nginx_server([4443], ["up.example.test"], [proxy_pass(("up.example.test", 443))]),
|
||||
nginx_server([8443], ["gone.example.test"], [proxy_pass(("gone.example.test", 443))]),
|
||||
nginx_server([9443], ["silent.example.test"],
|
||||
[proxy_pass(("silent.example.test", 443))])]),
|
||||
sockets=sockets_section(
|
||||
established=[conn(("10.1.1.27", 40000), ("10.5.5.5", 443)),
|
||||
conn(("10.1.1.27", 40002), ("10.5.5.9", 443))]),
|
||||
resolver=resolver_section([
|
||||
resolved("up.example.test", 443, ["10.5.5.5"]),
|
||||
resolved("gone.example.test", 443, [], status="nxdomain")]))
|
||||
payload = gateway.parse(snap, "gateway")
|
||||
assert routes_to(payload, "up.example.test")[0]["connections"] == 1
|
||||
for name in ("gone.example.test", "silent.example.test"):
|
||||
row = routes_to(payload, name)[0]
|
||||
assert row["out"]["resolved"] is None, "never `[]`: unknown is one test, not a length"
|
||||
assert row["out"]["via"] is None and row["out"]["resolved_ts"] is None
|
||||
assert row["connections"] is None, "no count, and no 0 standing in for one"
|
||||
assert row["metric"] is None, "no series for a number that cannot exist"
|
||||
stored = [g.metric for g in gateway.samples(payload, 5.0)
|
||||
if g.metric.startswith("gateway.out_conns@")]
|
||||
assert stored == ["gateway.out_conns@up.example.test:443"]
|
||||
assert sum(row["connections"] or 0 for row in payload["connections"]) == 1, (
|
||||
"the socket to the unresolved target is credited to nothing at all")
|
||||
|
||||
|
||||
def test_a_resolver_section_that_did_not_answer_places_nothing(gateway_snapshot: Any) -> None:
|
||||
"""`available: false` and `available: null` are the agent's answers, and neither joins.
|
||||
|
||||
`false` is "there was nothing to resolve" (no proxy config was read) and `null` is "a pass that
|
||||
could not run"; both arrive with no usable entries, and the payload's `resolver.available` says
|
||||
which one it was rather than leaving a page to infer it from an empty list.
|
||||
"""
|
||||
for available in (False, None):
|
||||
snap = snapshot(
|
||||
nginx=nginx_section([nginx_server([4443], ["a.example.test"],
|
||||
[proxy_pass(("x.example.test", 443))])]),
|
||||
sockets=sockets_section(
|
||||
established=[conn(("10.1.1.27", 40000), ("10.5.5.5", 443))]),
|
||||
resolver=resolver_section([resolved("x.example.test", 443, ["10.5.5.5"])],
|
||||
available=available, via=None))
|
||||
payload = gateway.parse(snap, "gateway")
|
||||
assert payload["resolver"]["available"] is available
|
||||
assert payload["resolver"]["entries"] == [], "nothing was indexed from a dead pass"
|
||||
assert routes_to(payload, "x.example.test")[0]["connections"] is None, (
|
||||
"an entry beside `available: null` is a stale answer, not a joinable address")
|
||||
|
||||
|
||||
def test_the_resolver_block_names_the_entries_the_index_was_built_from(
|
||||
gateway_snapshot: Any) -> None:
|
||||
"""The payload carries the document it was drawn from, so provenance is one read.
|
||||
|
||||
`entries` is every entry the section published — the index filters **within** it, so a page can
|
||||
still say *this name has no record* — and each is projected to the five keys the agent owns.
|
||||
`via` is the section's string. The `stub` arm keeps the same key set with nulls, which is what
|
||||
the parse/stub parity pin in `tests/test_api.py` compares.
|
||||
"""
|
||||
payload = gateway.parse(gateway_snapshot("resolved"), "gateway")
|
||||
block = payload["resolver"]
|
||||
assert set(block) == {"available", "via", "entries"}
|
||||
assert block["available"] is True and block["via"] == "libc"
|
||||
document = json.loads((FIXTURES / "gateway_resolver.json").read_text())
|
||||
assert [row["name"] for row in block["entries"]] == [row["name"] for row in document["entries"]]
|
||||
assert [row["port"] for row in block["entries"]] == [row["port"] for row in document["entries"]]
|
||||
assert all(set(row) == {"name", "port", "status", "addresses", "ts"}
|
||||
for row in block["entries"]), "no agent field reaches a page unreviewed"
|
||||
assert {row["status"] for row in block["entries"]} == {"ok", "literal"}, (
|
||||
"this capture resolves every name it was asked about, plus the two `127.0.0.1` targets it "
|
||||
"recognised as addresses without asking at all")
|
||||
|
||||
empty = gateway.stub("gateway")
|
||||
assert empty["resolver"] == {"available": None, "via": None, "entries": []}, (
|
||||
"a host that has not answered has not said resolution is unavailable")
|
||||
|
||||
|
||||
def test_the_dashboard_still_never_resolves(gateway_snapshot: Any) -> None:
|
||||
"""The spy pin, restated for phase 32 rather than relaxed by it.
|
||||
|
||||
`parse()` may **read** a resolver document and must not produce one, so the spy now runs over
|
||||
three paths: a synthetic snapshot whose every target is a name, the same with a resolver section
|
||||
full of answers, and the captured `resolved` ingress — then `samples()` over the result, since
|
||||
a gauge name is built from a host string and is the other place a resolver could creep in.
|
||||
|
||||
Why the split is the design and not a limitation: the agent resolves with `getaddrinfo` on the
|
||||
host that **opens** these connections (haproxy's `init-addr last,libc,none`), so its answer is
|
||||
the one that decides what `/proc` then says. The dashboard asking the same name through its own
|
||||
`resolv.conf` would answer a different question — a different server, a different view, and on
|
||||
this fleet a name that resolves back through this same router — and would then attribute a
|
||||
socket to a route on the strength of a lookup the router never made.
|
||||
"""
|
||||
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")
|
||||
|
||||
named = 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))]))
|
||||
answered = snapshot(
|
||||
haproxy=named["haproxy"], nginx=named["nginx"], sockets=named["sockets"],
|
||||
resolver=resolver_section([resolved("minecraft.example.test", 25565, ["10.9.9.9"]),
|
||||
resolved("app.example.test", 8080, ["10.2.2.2"])]))
|
||||
|
||||
original = socket_module.getaddrinfo
|
||||
socket_module.getaddrinfo = spy
|
||||
try:
|
||||
for document in (named, answered, gateway_snapshot("resolved")):
|
||||
payload = gateway.parse(document, "gateway")
|
||||
gateway.samples(payload, 5.0)
|
||||
gateway.warnings(gateway._section(document, "haproxy"),
|
||||
gateway._section(document, "nginx"))
|
||||
finally:
|
||||
socket_module.getaddrinfo = original
|
||||
assert calls == []
|
||||
|
||||
|
||||
def test_a_snapshot_without_a_resolver_section_is_phase_30_verbatim(
|
||||
gateway_snapshot: Any) -> None:
|
||||
"""A 1.2.0 agent, or a 1.3.0 one run with `--no-resolve`, is **not** a changed page.
|
||||
|
||||
The topology, the sections, the warnings and the two loopback counts are byte-for-byte what
|
||||
phase 30 produced; what differs is three `null`s on every `out` and an empty `resolver` block,
|
||||
plus the routes phase 32 makes countable. `supported` stays `true` — a missing resolver is a
|
||||
missing measurement, not an unsupported agent, and a fleet running two versions (which this one
|
||||
did until task 05) must not read half-red.
|
||||
"""
|
||||
without = gateway.parse(gateway_snapshot("full"), "gateway")
|
||||
with_resolver = gateway.parse(gateway_snapshot("resolved"), "gateway")
|
||||
assert without["supported"] is True and with_resolver["supported"] is True
|
||||
assert without["resolver"] == {"available": None, "via": None, "entries": []}
|
||||
assert all(row["out"]["resolved"] is None and row["out"]["via"] is None
|
||||
and row["out"]["resolved_ts"] is None for row in without["connections"])
|
||||
|
||||
def shape(payload: Mapping[str, Any]) -> Any:
|
||||
"""Everything except the counts, which is the one thing the section may move."""
|
||||
return {
|
||||
"routes": [row["id"] for row in payload["connections"]],
|
||||
"out": [(row["out"]["host"], row["out"]["port"], row["out"]["name"],
|
||||
row["out"]["kind"], tuple(row["out"]["selectors"]), row["out"]["address"])
|
||||
for row in payload["connections"]],
|
||||
"trunks": [(row["port"], row["source"], tuple(row["domains"]))
|
||||
for row in payload["listeners"]],
|
||||
"sections": payload["sections"],
|
||||
"why": payload["why"],
|
||||
"warnings": payload["warnings"],
|
||||
"sockets": payload["sockets"],
|
||||
}
|
||||
|
||||
assert shape(without) == shape(with_resolver), (
|
||||
"the resolver section counts through an address; it moves no route, trunk or warning")
|
||||
counted = {row["id"] for row in without["connections"] if row["connections"] is not None}
|
||||
assert counted == {"nginx:443->127.0.0.1:4443", "nginx:8443->127.0.0.1:8081"}, (
|
||||
"phase 30 counted exactly the two endpoints the config wrote as addresses")
|
||||
|
||||
|
||||
def test_resolution_raises_the_counted_route_count_from_two_to_every_name(
|
||||
gateway_snapshot: Any) -> None:
|
||||
"""The change, as one printed number rather than as an implied one.
|
||||
|
||||
Phase 30 could count the two routes whose target the configs wrote as an address. The recorded
|
||||
resolver document places the other thirty-one, so against the captures every route on the
|
||||
ingress now carries a figure — which is what the operator's "the only animated line is `:443 ->
|
||||
127.0.0.1:4443`" was really asking for.
|
||||
"""
|
||||
before = gateway.parse(gateway_snapshot("full"), "gateway")
|
||||
after = gateway.parse(gateway_snapshot("resolved"), "gateway")
|
||||
counted_before = [row for row in before["connections"] if row["connections"] is not None]
|
||||
counted_after = [row for row in after["connections"] if row["connections"] is not None]
|
||||
written_as_name = [row for row in after["connections"] if row["out"]["address"] is not True]
|
||||
unplaced = sorted(row["out"]["host"] for row in written_as_name
|
||||
if row["connections"] is None)
|
||||
report = (f"/gateway counted routes: {len(counted_before)} "
|
||||
f"(phase 30, addresses only) → {len(counted_after)} of {len(after['connections'])}; "
|
||||
f"the resolver placed {len(written_as_name)} name-written routes")
|
||||
print(report)
|
||||
assert len(counted_before) == 2, "phase 30's figure, so the delta is measurable"
|
||||
assert not unplaced, f"{report} — unplaced: {unplaced}"
|
||||
assert len(counted_after) == len(after["connections"]), (
|
||||
f"{report} — a route the document places must carry a count")
|
||||
|
||||
|
||||
def test_two_ports_on_one_host_are_two_gauges_even_when_both_names_resolve() -> None:
|
||||
"""`minecraft:25565` and `minecraft:25566` stay two series — the port earns its place in the name.
|
||||
|
||||
This is why phase 29 put the port in `gateway.out_conns@<host>:<port>` in the first place, and
|
||||
resolution does not remove the reason: this fleet's two haproxy Minecraft backends name the *same*
|
||||
domain on two ports, libc answers one address for both, and a gauge keyed on the name alone would
|
||||
have the second poll overwrite the first in silence. Two endpoints, two counters, two series — and
|
||||
the fact that they share an address is invisible in the names and visible in the traffic.
|
||||
"""
|
||||
snap = snapshot(
|
||||
haproxy=haproxy_section(
|
||||
[frontend("mc", [25565], default="mc-a"), frontend("mc2", [25566], default="mc-b")],
|
||||
[backend("mc-a", [server("mc1", "minecraft.example.test", 25565)]),
|
||||
backend("mc-b", [server("mc2", "minecraft.example.test", 25566)])]),
|
||||
sockets=sockets_section(established=[
|
||||
conn(("10.1.1.27", 40000), ("10.9.9.9", 25565)),
|
||||
conn(("10.1.1.27", 40002), ("10.9.9.9", 25565)),
|
||||
conn(("10.1.1.27", 40004), ("10.9.9.9", 25566))]),
|
||||
resolver=resolver_section([resolved("minecraft.example.test", 25565, ["10.9.9.9"]),
|
||||
resolved("minecraft.example.test", 25566, ["10.9.9.9"])]))
|
||||
payload = gateway.parse(snap, "gateway")
|
||||
by_port = {row["in"]["port"]: row for row in payload["connections"]}
|
||||
assert by_port[25565]["metric"] == "gateway.out_conns@minecraft.example.test:25565"
|
||||
assert by_port[25566]["metric"] == "gateway.out_conns@minecraft.example.test:25566"
|
||||
assert [by_port[25565]["connections"], by_port[25566]["connections"]] == [2, 1], (
|
||||
"two endpoints, two counts — one address does not merge two ports")
|
||||
assert by_port[25565]["shared"] is False and by_port[25566]["shared"] is False, (
|
||||
"sharing an *address* is not sharing an *endpoint*, which is what `shared` means")
|
||||
stored = gateway.samples(payload)
|
||||
assert [(s.metric, s.value) for s in stored if "out_conns" in s.metric] == [
|
||||
("gateway.out_conns@minecraft.example.test:25565", 2),
|
||||
("gateway.out_conns@minecraft.example.test:25566", 1)], (
|
||||
"neither gauge overwrites the other on the way to the store")
|
||||
assert gateway.distinct_endpoint_counts(payload["connections"]) == [2, 1], (
|
||||
"and the total books both, because the kernel has both")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# the map: which maps route, and which are just header words
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -1336,9 +2051,9 @@ def test_the_captured_configs_produce_no_warnings(gateway_snapshot: Any) -> None
|
||||
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 len(referenced) == 1 and len(referenced[0]["entries"]) == 18
|
||||
assert all(entry["target"] for entry in referenced[0]["entries"]), (
|
||||
"all 17 stream routes name an address, `default` included")
|
||||
"all 18 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 "
|
||||
|
||||
Reference in New Issue
Block a user