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:
2026-10-26 14:03:42 -04:00
parent 25fd74b7c5
commit 51772ffef2
58 changed files with 5272 additions and 478 deletions
+265 -4
View File
@@ -27,6 +27,7 @@ from __future__ import annotations
import difflib
import importlib.util
import ipaddress
import json
import os
import re
@@ -2391,6 +2392,23 @@ case "$command" in
[ -f "$file" ] || { echo "cat: $path: No such file or directory" >&2; exit 1; }
cat "$file"
;;
"getent "*)
# libc resolution on the fake box. Answers come from `$FAKE_REMOTE/getent/<name>.<family>`;
# a file that is not there is `getent`'s own "no such record" (exit 2, no output), which the
# script records as a status rather than a failure. Two env switches stand in for the two
# reads that are *not* a fact about the name: `GETENT_MODE=transport` is ssh dying, and
# `GETENT_MODE=missing` is a gateway with no getent to ask.
family="${command#getent }"
family="${family%% *}"
name="${command##* }"
case "${GETENT_MODE:-ok}" in
transport) echo "ssh: connect to host $host port 22: Connection refused" >&2; exit 255 ;;
missing) echo "bash: line 1: getent: command not found" >&2; exit 127 ;;
esac
file="$FAKE_REMOTE/getent/$name.${family#ahosts}"
[ -f "$file" ] || exit 2
cat "$file"
;;
"ls "*)
spec="${command#ls }"
dir="${spec%/*}"
@@ -2444,7 +2462,10 @@ CAPTURE_FAKE_GATEWAY: dict[str, str] = {
"map $ssl_preread_server_name $targetBackend {\n"
" default 127.0.0.1:4443;\n"
" brain.example reeseapps-brain-of-reese.reeselink.com:443;\n}\n"
"server {\n listen 443;\n proxy_pass $targetBackend;\n}\n"
# `resolver` here, inside the stream server, is where this fleet writes it: the fixture's
# `config_directive` must come out of an *included* document, read from the bytes the run
# already captured, with no round trip of its own.
"server {\n listen 443;\n proxy_pass $targetBackend;\n resolver 10.9.8.7;\n}\n"
),
# 8.8.8.8 and 2603:6013::1 are globally routable, `::ffff:8.8.8.8` is a visitor behind a v6
# socket, and 203.0.113.7 is already documentation space. The tx/rx queue pair on the last row
@@ -2470,12 +2491,40 @@ CAPTURE_FAKE_GATEWAY: dict[str, str] = {
}
#: What the fake gateway's libc answers for the names its configs declare. The three arms are the
#: three the capture has to tell apart: an answer (private, so unscrubbed — this is what the real
#: fleet looks like), an answer that is **globally routable** (so it must become documentation
#: space), and no answer at all (a status, not a failure). `nextcloud` deliberately answers a v6
#: that is `::ffff:`-mapped, because `getent ahostsv6` synthesises that spelling for a name with no
#: AAAA record and the agent's own `getaddrinfo` never publishes it.
CAPTURE_FAKE_ANSWERS: dict[str, dict[str, str]] = {
# Private and answered twice over — the A record, and the `::ffff:` spelling `getent ahostsv6`
# synthesises for a name with no AAAA record. One address goes in; nothing moves.
"gitea.reeselink.com": {
"v4": "10.1.0.137 STREAM gitea.reeselink.com\n10.1.0.137 DGRAM \n",
"v6": "::ffff:10.1.0.137 STREAM \n",
},
# Two globally routable answers (one per family) beside one already in documentation space: the
# first two become `203.0.113.1` / `2001:db8::1` and the third stays exactly as it was, which is
# what makes a second run a no-op instead of a renumbering.
"nextcloud.reeselink.com": {
"v4": ("8.8.8.8 STREAM nextcloud.reeselink.com\n"
"203.0.113.9 STREAM nextcloud.reeselink.com\n"),
"v6": "2603:6013:3100:101:5054:ff:fe84:b2e0 STREAM nextcloud.reeselink.com\n",
},
# `reeseapps-brain-of-reese.reeselink.com` is answered by nothing on purpose: the absence of
# the file *is* the nxdomain arm, a status among successes rather than a failed run.
}
def _capture_sandbox(
tmp_path: Path,
gateway: dict[str, str] | None = None,
*,
recorded: dict[str, str] | None = None,
fail_ssh: bool = False,
fail_getent: str | None = None,
answers: dict[str, dict[str, str]] | None = None,
alias: str | None = None,
) -> tuple[Path, Path, Path, dict[str, str]]:
"""A copy of the capture script, a fake gateway, and a PATH whose `ssh` reaches only the fake.
@@ -2483,16 +2532,28 @@ def _capture_sandbox(
Returns (script, fixtures dir, stub call log, env). The script is copied into
`<tmp>/scripts/` because it derives `tests/fixtures/` from its own location, which is what
keeps the repository's fixtures out of reach of a test.
The parser step needs the repo, so `agent/host_agent.py` is copied beside the copied script:
the sandbox is a checkout shape, and the plan step imports `target_names` from it exactly as
it does when the real script runs. That copy is the pin — a script that re-implemented the
parser would not need it, and one that imported a different module would not find it.
"""
script = tmp_path / "scripts" / CAPTURE.name
script.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(CAPTURE, script)
(tmp_path / "agent").mkdir(parents=True, exist_ok=True)
shutil.copyfile(ROOT / "agent/host_agent.py", tmp_path / "agent/host_agent.py")
fake = tmp_path / "gateway"
for path, body in {**CAPTURE_FAKE_GATEWAY, **(gateway or {})}.items():
target = fake / path.lstrip("/")
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(body, encoding="utf-8")
for name, families in (CAPTURE_FAKE_ANSWERS if answers is None else answers).items():
for family, body in families.items():
answer = fake / "getent" / f"{name}.{family}"
answer.parent.mkdir(parents=True, exist_ok=True)
answer.write_text(body, encoding="utf-8")
fixtures = tmp_path / "tests" / "fixtures"
fixtures.mkdir(parents=True, exist_ok=True)
@@ -2511,7 +2572,11 @@ def _capture_sandbox(
"PATH": f"{stubs}{os.pathsep}{os.environ['PATH']}",
"STUB_LOG": str(call_log),
"FAKE_REMOTE": str(fake),
"FAIL_SSH": "1" if fail_ssh else "0"}
"FAIL_SSH": "1" if fail_ssh else "0",
"GETENT_MODE": fail_getent or "ok",
# The script prepends its own root to this, which is how `agent.host_agent` resolves
# when uv has no project to run in (this sandbox is not one).
"PYTHONPATH": str(tmp_path)}
if alias is not None:
env["GATEWAY_SSH"] = alias
return script, fixtures, call_log, env
@@ -2529,12 +2594,23 @@ def _fixture_body(path: Path) -> str:
def test_the_gateway_capture_exists_and_asks_for_no_privilege() -> None:
"""Six world-readable reads over an ssh key: nothing here needs root or a rewrite tool."""
"""Six world-readable reads plus `getent`, over an ssh key: no root, no rewrite tool.
`getent` belongs in this list because it is the seventh read and the only one that is not a
file: it is libc resolution on the box that dials, which is what `via: "libc"` claims, and it
is world-runnable for the same reason the six files are world-readable.
"""
assert CAPTURE.is_file(), "scripts/capture-gateway-config.sh is missing (phase 28 task 04)"
assert os.access(CAPTURE, os.X_OK)
text = CAPTURE.read_text()
for path in CAPTURE_TARGETS:
assert path in text, f"{path} is no longer captured"
for family in ("getent ahostsv4", "getent ahostsv6"):
assert family in text, f"{family} is no longer how the resolver document is recorded"
for tool in ("dig", "nslookup", "resolvectl", "drill"):
assert not re.search(rf"(?<![\w-]){tool}\s+[A-Za-z0-9.*_-]", text), (
f"{tool} assumes a DNS client the gateway may not ship; getent is libc, which is the "
"vantage the agent's own lookup performs")
code = "\n".join(line for line in text.splitlines() if not line.lstrip().startswith("#"))
assert "sudo" not in code, (
"the capture must read exactly what dashboards-agent can read; a pass that needed root "
@@ -2586,7 +2662,9 @@ def test_the_gateway_capture_records_every_document_of_the_tree_it_read(tmp_path
expected = {"gateway_haproxy.cfg", "gateway_nginx_main.conf",
"gateway_nginx_conf_d_http.conf", "gateway_nginx_conf_d_tls.conf",
"gateway_nginx_stream_d_stream.conf",
"gateway_proc_net_tcp.txt", "gateway_proc_net_tcp6.txt"}
"gateway_proc_net_tcp.txt", "gateway_proc_net_tcp6.txt",
# The resolver document, derived from the configs in this same set.
"gateway_resolver.json"}
assert {p.name for p in fixtures.iterdir()} == expected, result.stdout
assert _fixture_body(fixtures / "gateway_haproxy.cfg") == \
CAPTURE_FAKE_GATEWAY["/etc/haproxy/haproxy.cfg"]
@@ -2703,6 +2781,189 @@ def test_the_socket_capture_publishes_no_globally_routable_endpoint(tmp_path: Pa
assert len(v6[2]) == len(raw_v6[2])
# --- the resolver document (phase 32 task 02) ------------------------------------------------
#: One entry per declared target of the fake gateway, in the plan's own order: haproxy's `server`
#: line first, then the nginx vhosts' `proxy_pass` targets, then the stream `map`'s entries — with
#: the map's `default` an address literal, which is why it costs no read on the remote.
CAPTURE_RESOLVER_TARGETS: tuple[tuple[str, int | None], ...] = (
("gitea.reeselink.com", 2222),
("nextcloud.reeselink.com", 11000),
("reeseapps-brain-of-reese.reeselink.com", 443),
("127.0.0.1", 4443),
)
RESOLVER_KEEP = {
"gateway_resolver.json": '{"available": true, "entries": ["keep me"]}\n',
}
def _resolver_fixture(path: Path) -> dict[str, Any]:
"""The recorded document, parsed — and a clear failure when the run refused to write it."""
assert path.is_file(), f"{path.name} was not written"
return json.loads(path.read_text())
def _resolver_run(tmp_path: Path, **kwargs: Any) -> tuple[subprocess.CompletedProcess[str], Path]:
"""One sandboxed capture run, returning its result and the fixture directory it wrote into."""
script, fixtures, _log, env = _capture_sandbox(tmp_path, **kwargs)
result = subprocess.run([BASH, str(script)], capture_output=True, text=True, env=env,
check=False)
return result, fixtures
#: The config documents of the captured tree, the same mapping `tests/conftest.py` lays down for
#: `sources/gateway.py`: an agent `--root` under which the agent can read this repo's captures.
def test_the_capture_asks_its_own_parser_and_not_its_memory() -> None:
"""The name list is `target_names()`, imported — never a second list somebody maintained.
This is the phase's rule 3 in the capture's own hands: the fixture can only ever name what the
agent will resolve, so `target_names()` changing moves the fixture with it instead of leaving a
document of names the agent stopped asking about.
"""
text = CAPTURE.read_text()
assert "from agent.host_agent import" in text, "the plan does not import the agent's parser"
assert "target_names(" in text, "the plan does not ask the agent what its configs declare"
assert "Collector(" in text, "the plan does not read the captured configs the agent reads them"
# And no domain is written into the script: the only domains in it are comments and the fake
# gateway's own fixtures live in the test, not here.
code = "\n".join(line for line in text.splitlines() if not line.lstrip().startswith("#"))
assert ".reeselink.com" not in code and ".reeseapps.com" not in code, (
"a target name is hardcoded in the capture instead of derived from the configs")
def test_the_resolver_document_names_every_target_the_configs_declare(tmp_path: Path) -> None:
"""The happy path: one libc read per declared name, in the agent's order, in its own shape.
Four assertions the fixture's whole usefulness rests on: the entry set is the plan, `via` says
how the addresses were obtained, `config_directive` came out of the **captured** nginx text (so
it matches what the agent will read from the same bytes, with no extra round trip), and the
remote was asked exactly twice per name — and never about the literal the map writes.
"""
result, fixtures = _resolver_run(tmp_path)
assert result.returncode == 0, result.stdout + result.stderr
document = _resolver_fixture(fixtures / "gateway_resolver.json")
assert document["available"] is True
assert document["via"] == "libc"
assert document["config_directive"] == "10.9.8.7", (
"the `resolver` directive did not come out of the included stream config")
assert [(row["name"], row["port"]) for row in document["entries"]] == list(
CAPTURE_RESOLVER_TARGETS), "the entries are the plan, in the agent's order"
assert document["errors"] == []
for row in document["entries"]:
assert set(row) == {"name", "port", "status", "addresses", "ts"}
assert row["ts"] == document["ts"], "a fixed capture ts keeps the fixture byte-stable"
script, _fixtures, log, _env = _capture_sandbox(tmp_path / "ledger")
subprocess.run([BASH, str(script)], capture_output=True, text=True, env=_env, check=False)
asked = [line for line in log.read_text().splitlines() if "getent " in line]
for name, _port in CAPTURE_RESOLVER_TARGETS:
if ipaddress.ip_address(name) if name[0].isdigit() else False:
assert not [line for line in asked if name in line], (
"an address literal cost a lookup on the remote")
continue
families = [line for line in asked if "getent ahostsv" in line and line.endswith(name)]
assert len(families) == 2, f"{name} was not read once per family: {families}"
def test_the_resolver_document_scrubs_global_addresses_and_keeps_everything_else(
tmp_path: Path) -> None:
"""What the document edits, and what it must not, in one run.
Three arms at once, because they are one rule: `8.8.8.8` and a global EUI-64 become
documentation space (invariant 6 — a resolved address is as much an identifier as the socket
table's), `10.1.0.137` and an address already in documentation space stay exactly as they were
(which is what makes a re-run a no-op), and the `::ffff:` spelling `getent ahostsv6` synthesises
for a name with no AAAA record is dropped — the agent's one `getaddrinfo` never publishes it, so
recording it would claim an address the agent does not. A name nobody answers is `nxdomain`, the
status the page splits out from "resolved and idle".
"""
result, fixtures = _resolver_run(tmp_path)
assert result.returncode == 0, result.stdout + result.stderr
entries = {row["name"]: row for row in
_resolver_fixture(fixtures / "gateway_resolver.json")["entries"]}
private = entries["gitea.reeselink.com"]
assert private["status"] == "ok"
assert private["addresses"] == ["10.1.0.137"], private
assert not any(a.startswith("::ffff:") for a in private["addresses"]), (
"the synthesised v4-mapped spelling survived: the agent never publishes it")
moved = entries["nextcloud.reeselink.com"]
assert moved["status"] == "ok"
assert moved["addresses"] == ["203.0.113.1", "203.0.113.9", "2001:db8::1"], moved
assert "8.8.8.8" not in json.dumps(moved), "a globally routable answer is in a tracked file"
assert "2603:6013" not in json.dumps(moved), "an EUI-64 address is in a tracked file"
missing = entries["reeseapps-brain-of-reese.reeselink.com"]
assert missing["status"] == "nxdomain", "one name failing among successes is a fact, kept"
assert missing["addresses"] == [], "an unresolved name publishes no address, not 0 and not a guess"
literal = entries["127.0.0.1"]
assert literal["status"] == "literal" and literal["addresses"] == ["127.0.0.1"]
def test_the_resolver_document_is_idempotent_so_a_re_run_does_not_churn(tmp_path: Path) -> None:
"""Two runs over the same answers write the same bytes.
The `ts` is a constant and the scrub is first-seen order over a deterministic plan, so a
re-capture of a gateway nobody changed is a no-op — the property that makes `git diff` on this
directory a statement about the ingress rather than about the clock.
"""
once, first = _resolver_run(tmp_path)
assert once.returncode == 0, once.stdout + once.stderr
again, second = _resolver_run(tmp_path / "again")
assert again.returncode == 0, again.stdout + again.stderr
assert (second / "gateway_resolver.json").read_bytes() == (
first / "gateway_resolver.json").read_bytes()
@pytest.mark.parametrize("mode, needle", [
pytest.param("transport", "did not answer", id="ssh-dies"),
pytest.param("missing", "getent is not available", id="no-getent"),
])
def test_a_broken_resolver_vantage_is_not_a_fixture(tmp_path: Path, mode: str,
needle: str) -> None:
"""The configs capture fine and only the resolver is broken — and it still refuses.
This is the arm the socket rule cannot cover: `getent` answering *nothing* for every name is a
fact about the box's resolver, not about the names, and a document of `nxdomain` rows would
make every live route on the page a permanent dash and every join test a test of nothing.
"""
result, fixtures = _resolver_run(tmp_path, recorded=dict(RESOLVER_KEEP), fail_getent=mode)
assert result.returncode == 1, result.stdout
assert needle in result.stdout, result.stdout
assert "gateway_resolver.json" in result.stdout
assert (fixtures / "gateway_resolver.json").read_text() == RESOLVER_KEEP[
"gateway_resolver.json"], "a broken vantage overwrote the recorded document"
def test_not_one_name_answering_is_refused_the_way_a_dead_transport_is(tmp_path: Path) -> None:
"""Every name answered `nxdomain`: each individual read succeeded, and the run still refuses.
A resolver that returns "no records" for the whole config is a firewall, a dead upstream DNS,
or a box that has never been pointed at one — and the fixture it would produce is the same
nothing the other two arms produce.
"""
result, fixtures = _resolver_run(tmp_path, recorded=dict(RESOLVER_KEEP), answers={})
assert result.returncode == 1, result.stdout
assert "none of the" in result.stdout and "vantage is broken" in result.stdout, result.stdout
assert (fixtures / "gateway_resolver.json").read_text() == RESOLVER_KEEP[
"gateway_resolver.json"]
def test_a_dead_gateway_does_not_attempt_the_resolver_document(tmp_path: Path) -> None:
"""With no configs there is no name list, and a document of the wrong question is worse than none."""
result = _capture(tmp_path, recorded=dict(RESOLVER_KEEP), fail_ssh=True)
fixtures = tmp_path / "tests" / "fixtures"
assert result.returncode == 1, result.stdout
assert "not attempted" in result.stdout, result.stdout
assert (fixtures / "gateway_resolver.json").read_text() == RESOLVER_KEEP[
"gateway_resolver.json"]
assert sorted(p.name for p in fixtures.iterdir()) == ["gateway_resolver.json"], (
"a refused resolver capture left a temp file in tests/fixtures/")
def test_the_socket_capture_is_idempotent_so_a_re_run_does_not_churn(tmp_path: Path) -> None:
"""Re-recording the same moment must not diff, or every capture moves the fixture twice.