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:
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user