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:
+712
-15
@@ -12,6 +12,7 @@ Four things are asserted here:
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import http.client
|
||||
import importlib.util
|
||||
import ipaddress
|
||||
@@ -331,13 +332,14 @@ def write_fake_disk(root: Path, name: str, model: str | None = None) -> None:
|
||||
write_file(root, f"sys/block/{name}/device/model", f"{model}\n")
|
||||
|
||||
|
||||
#: The add-on sections report their own absence in `errors` (`smart:`, `haproxy:`), because
|
||||
#: "this box has no smartmontools / no haproxy" is a fact the page that owns the add-on has to
|
||||
#: be able to print. Tests that assert a snapshot is otherwise clean filter the same prefixes
|
||||
#: the dashboard does — `collectors.ADDON_ERROR_PREFIXES`, pinned equal to this list by
|
||||
#: `test_the_addon_error_prefixes_are_the_same_list_the_dashboard_filters`, so neither side can
|
||||
#: The add-on sections report their own absence in `errors` (`smart:`, `haproxy:`, `resolver:`),
|
||||
#: because
|
||||
#: "this box has no smartmontools / no haproxy / nothing to resolve" is a fact the page that owns
|
||||
#: the add-on has to be able to print. Tests that assert a snapshot is otherwise clean filter the
|
||||
#: same prefixes the dashboard does — `collectors.ADDON_ERROR_PREFIXES`, pinned equal to this list
|
||||
#: by `test_the_addon_error_prefixes_are_the_same_list_the_dashboard_filters`, so neither side can
|
||||
#: quietly grow a fourth opinion.
|
||||
ADDON_ERROR_PREFIXES = ("smart", "haproxy", "nginx", "sockets")
|
||||
ADDON_ERROR_PREFIXES = ("smart", "haproxy", "nginx", "sockets", "resolver")
|
||||
|
||||
|
||||
def test_the_addon_error_prefixes_are_the_same_list_the_dashboard_filters() -> None:
|
||||
@@ -357,6 +359,36 @@ def core_errors(snapshot: dict[str, Any]) -> list[str]:
|
||||
if not str(line).lstrip().lower().startswith(ADDON_ERROR_PREFIXES)]
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def no_real_dns(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Not one test in this file resolves a name across the network (phase 32's hermetic mandate).
|
||||
|
||||
The patch is module-wide on purpose — `host_agent.socket` *is* the `socket` module — and the
|
||||
stub answers a hostname with a deterministic pair of **documentation-space** addresses
|
||||
(RFC 5737 / RFC 3849, the same substitution `scripts/capture-gateway-config.sh` makes to a
|
||||
socket table, so nothing a resolver test prints could carry a real endpoint either). Every IP
|
||||
literal and `localhost` goes to the real function: this file's HTTP and TLS tests connect to
|
||||
numeric loopback, and a stub that answered those too would break them for a reason nobody is
|
||||
testing. A test that wants its own answer — `nxdomain`, a slow resolver, a call ledger —
|
||||
patches the same attribute in its own body, which runs after this fixture.
|
||||
"""
|
||||
real = socket.getaddrinfo
|
||||
|
||||
def stub(name: Any, port: Any = None, *args: Any, **kwargs: Any) -> Any:
|
||||
text = str(name)
|
||||
if host_agent.address_family(text) is not None or text in ("", "localhost"):
|
||||
return real(name, port, *args, **kwargs)
|
||||
digest = hashlib.sha256(text.encode("utf-8")).hexdigest()
|
||||
number = int(digest[:4], 16)
|
||||
host_port = port if isinstance(port, int) else 0
|
||||
return [(socket.AF_INET, socket.SOCK_STREAM, 0, "",
|
||||
(f"203.0.113.{number % 256}", host_port)),
|
||||
(socket.AF_INET6, socket.SOCK_STREAM, 0, "",
|
||||
(f"2001:db8::{digest[4:8]}", host_port, 0, 0))]
|
||||
|
||||
monkeypatch.setattr(host_agent.socket, "getaddrinfo", stub)
|
||||
|
||||
|
||||
def fake_calls(tmp_path: Path) -> list[str]:
|
||||
called = tmp_path / "smart-out" / "_called"
|
||||
return called.read_text().split() if called.exists() else []
|
||||
@@ -1585,11 +1617,16 @@ def test_a_corrupt_nginx_config_leaves_the_rest_of_the_snapshot_intact(tmp_path:
|
||||
def test_an_nginx_failure_inside_the_collector_still_yields_a_section(tmp_path: Path,
|
||||
monkeypatch) -> None:
|
||||
"""Whatever raises in here, the document keeps the key with `available: null` — a snapshot
|
||||
that loses the key would read as a pre-1.2.0 agent, not as a failed add-on."""
|
||||
that loses the key would read as a pre-1.2.0 agent, not as a failed add-on.
|
||||
|
||||
`nginx_document` is the seam `snapshot()` calls (it is the pair of the section and the
|
||||
`resolver` directive); `Collector.nginx` is its one-line wrapper, so patching the document is
|
||||
patching the real path a snapshot takes.
|
||||
"""
|
||||
def explode(*args: object) -> dict[str, Any]:
|
||||
raise RuntimeError("boom")
|
||||
|
||||
monkeypatch.setattr(host_agent.Collector, "nginx", explode)
|
||||
monkeypatch.setattr(host_agent.Collector, "nginx_document", explode)
|
||||
snap = host_agent.Collector(str(tmp_path / "root")).snapshot()
|
||||
assert snap["nginx"] == {"available": None, "config": host_agent.NGINX_CONFIG_PATH,
|
||||
"files": [], "servers": [], "upstreams": [], "maps": []}
|
||||
@@ -2767,15 +2804,13 @@ def test_the_tls_fields_are_documented_in_the_schema() -> None:
|
||||
assert "--cert-bundle" in (host_agent.__doc__ or "")
|
||||
|
||||
|
||||
def test_1_2_0_is_the_version_that_ships_the_three_proxy_sections() -> None:
|
||||
"""The version bump is the fleet's way of saying "this file has the gateway sections".
|
||||
def test_the_schema_names_the_three_proxy_sections() -> None:
|
||||
"""Phase 28's three sections are all in the SCHEMA block, each with its three-way `available`.
|
||||
|
||||
Phase 28 added three sections and one flag to the payload, so an install that predates them
|
||||
is distinguishable by one string: `--status`, `/health` and this test all read the same
|
||||
constant. The SCHEMA block is `GET /schema`'s body, so it names all three, each with the
|
||||
three-way `available` the dashboard reads, and `--no-proxy`, the flag that removes the family.
|
||||
The SCHEMA block is `GET /schema`'s body, so a deployment can read what an agent publishes
|
||||
from the agent itself. The version constant is pinned by the newest phase's test below — one
|
||||
place names the number, so a bump moves one assertion and not a file's worth of history.
|
||||
"""
|
||||
assert host_agent.AGENT_VERSION == "1.2.0"
|
||||
doc = host_agent.__doc__ or ""
|
||||
for section in ("haproxy", "nginx", "sockets"):
|
||||
assert schema_documents(section), f"{section} is not documented in the SCHEMA block"
|
||||
@@ -2784,6 +2819,24 @@ def test_1_2_0_is_the_version_that_ships_the_three_proxy_sections() -> None:
|
||||
assert "--no-proxy" in doc and "--haproxy-config" in doc and "--nginx-config" in doc
|
||||
|
||||
|
||||
def test_the_agent_version_is_1_3_0_and_the_schema_names_the_resolver_section() -> None:
|
||||
"""1.3.0 is the version that resolves the names the configs declare.
|
||||
|
||||
The bump is how the fleet tells an agent that publishes `resolver` from one that does not —
|
||||
`--status`, `/health` and this test all read the same constant, and `sources/gateway.py`'s
|
||||
"the agent is too old" arm keys on the sections the version belongs to. The SCHEMA block is
|
||||
`GET /schema`'s body, so it names the section, its three-way `available`, and `--no-resolve`,
|
||||
the flag that removes it in silence.
|
||||
"""
|
||||
assert host_agent.AGENT_VERSION == "1.3.0"
|
||||
doc = host_agent.__doc__ or ""
|
||||
assert schema_documents("resolver"), "resolver is not documented in the SCHEMA block"
|
||||
block = doc.split(" resolver ", 1)[-1]
|
||||
assert "available" in block, "the resolver block does not document its three-way answer"
|
||||
assert "--no-resolve" in doc, "the flag that removes the section is undocumented"
|
||||
assert "libc" in block, "how the addresses were obtained is not published in the schema"
|
||||
|
||||
|
||||
# ---- the recorded gateway: the captures themselves (phase 28 task 04) -------------------- #
|
||||
|
||||
#: Every document `scripts/capture-gateway-config.sh` records, at the tree path the agent reads
|
||||
@@ -2983,6 +3036,151 @@ def test_the_recorded_configs_and_the_recorded_sockets_agree(tmp_path: Path) ->
|
||||
"the agent's own listener should be in the socket table and claimed by neither config"
|
||||
|
||||
|
||||
#: Every top-level key a **1.2.0** snapshot published, recorded here so this phase's central claim
|
||||
#: outlives the script it was measured with (`data/logs/verify-32-additive.py`, which imports the
|
||||
#: released file out of git and compares the two documents value for value). `data/` is gitignored;
|
||||
#: this table is not, and it is what makes "purely additive" a permanent pin.
|
||||
SNAPSHOT_KEYS_BEFORE_1_3_0 = frozenset({
|
||||
"agent_version", "cpu", "cpu_temperature_celsius", "disk_io", "duration_ms", "errors",
|
||||
"filesystems", "gpus", "haproxy", "host", "memory", "network", "nginx", "processes",
|
||||
"schema", "sockets", "ts", "temperatures",
|
||||
})
|
||||
|
||||
|
||||
def test_the_resolver_section_is_the_only_key_1_3_0_adds(tmp_path: Path) -> None:
|
||||
"""1.3.0 = 1.2.0 + `resolver`, and `--no-resolve` is exactly the old document.
|
||||
|
||||
The phase's safety property: an agent upgrade must not move a number on a page that never
|
||||
asked about name resolution, and a host that opts out must not be left with an empty shell of a
|
||||
key. Compared as key **sets** against the recorded pre-1.3.0 shape rather than against another
|
||||
module, so the pin is hermetic and permanent; the value-for-value half of the claim runs in the
|
||||
script named above, which loads the released file from git and calls both against one `--root`.
|
||||
"""
|
||||
root = gateway_tree(tmp_path / "root")
|
||||
with_resolver = host_agent.Collector(str(root), smartctl="/nonexistent/smartctl").snapshot()
|
||||
assert set(with_resolver) == SNAPSHOT_KEYS_BEFORE_1_3_0 | {"resolver"}, (
|
||||
f"1.3.0 changed the document beyond adding one section: "
|
||||
f"{sorted(set(with_resolver) ^ (SNAPSHOT_KEYS_BEFORE_1_3_0 | {'resolver'}))}")
|
||||
assert with_resolver["resolver"]["available"] is True
|
||||
|
||||
opted_out = host_agent.Collector(str(root), smartctl="/nonexistent/smartctl",
|
||||
resolve=False).snapshot()
|
||||
assert set(opted_out) == SNAPSHOT_KEYS_BEFORE_1_3_0, "--no-resolve leaves a key behind"
|
||||
assert not [line for line in opted_out["errors"]
|
||||
if str(line).lstrip().lower().startswith("resolver")], (
|
||||
"opting out is silence, the `--no-smart` precedent")
|
||||
|
||||
no_proxy = host_agent.Collector(str(root), smartctl="/nonexistent/smartctl",
|
||||
proxy=False).snapshot()
|
||||
assert set(no_proxy) == SNAPSHOT_KEYS_BEFORE_1_3_0 - {"haproxy", "nginx", "sockets"}, (
|
||||
"the resolver belongs to the proxy family and is removed with it")
|
||||
|
||||
|
||||
def test_the_recorded_resolver_document_is_the_agents_own_section(tmp_path: Path) -> None:
|
||||
"""`gateway_resolver.json` is a section of the payload, not notes about one.
|
||||
|
||||
Nothing here is a hand-written expectation: the entry set and the `resolver` directive are
|
||||
recomputed from the config fixtures in this directory with the agent's own `target_names()` and
|
||||
`nginx_document()`, so the two captures cannot drift apart. A route added to the gateway shows
|
||||
up in both on the next run; a fixture naming a target the parser no longer derives fails here
|
||||
instead of teaching `sources/gateway.py` to join a route the config does not declare.
|
||||
|
||||
The per-entry assertions are the ones the page will rely on: a status out of the four the
|
||||
section publishes, an `ok` with no address, an `nxdomain` with one, or a list the agent's own
|
||||
ordering rule would reshuffle, all mean the fixture is not what a poll would serve.
|
||||
"""
|
||||
document = json.loads((FIXTURES / "gateway_resolver.json").read_text())
|
||||
assert set(document) == {"available", "via", "config_directive", "ts", "entries", "errors"}
|
||||
assert document["available"] is True, "the recorded gateway reads, so the section is available"
|
||||
assert document["via"] == host_agent.RESOLVE_VIA
|
||||
assert document["errors"] == []
|
||||
|
||||
collector = gateway_collector(gateway_tree(tmp_path / "root"))
|
||||
errors: list[str] = []
|
||||
haproxy, nginx = collector.haproxy(errors), collector.nginx(errors)
|
||||
assert [(row["name"], row["port"]) for row in document["entries"]] == (
|
||||
host_agent.target_names(haproxy, nginx)), (
|
||||
"the recorded names are not the ones these configs declare — re-run "
|
||||
"./scripts/capture-gateway-config.sh and re-tally in the same commit")
|
||||
_section, directive = collector.nginx_document(errors)
|
||||
assert document["config_directive"] == directive, (
|
||||
"the fixture's `resolver` line is not what the agent reads out of the same text")
|
||||
|
||||
for row in document["entries"]:
|
||||
assert set(row) == {"name", "port", "status", "addresses", "ts"}
|
||||
assert row["status"] in {"ok", "nxdomain", "error", "literal"}, row
|
||||
if row["status"] in {"ok", "literal"}:
|
||||
assert row["addresses"], f"{row['name']} is {row['status']} with no address"
|
||||
else:
|
||||
assert row["addresses"] == [], f"{row['name']} publishes an address it did not resolve"
|
||||
assert row["addresses"] == host_agent.sort_resolver_addresses(row["addresses"]), (
|
||||
f"{row['name']} is not in the order the payload will publish it")
|
||||
|
||||
|
||||
def test_the_lookup_the_agent_performs_is_one_real_libc_accepts(monkeypatch, tmp_path) -> None:
|
||||
"""The one pin in this file with **no stub in sight**, because a stub cannot refuse a keyword.
|
||||
|
||||
`socket.getaddrinfo(name, port, proto=socket.SOCK_STREAM)` — the spelling that reads as "give
|
||||
me the TCP addresses" — is not that. `proto` is a **protocol number** (`IPPROTO_TCP` is 6) and
|
||||
`SOCK_STREAM` is 1, so libc is asked for a stream socket over protocol 1 and answers
|
||||
`EAI_SERVICE` for every name. A fake `getaddrinfo` accepts any keyword, so every resolver test
|
||||
in this file would have stayed green while the agent reported `error` on a healthy gateway and
|
||||
left every route on `/gateway` uncounted.
|
||||
|
||||
So: the module-wide `no_real_dns` fixture is removed for this test, and the lookup runs against
|
||||
real libc on a name libc answers from `/etc/hosts` — no resolver, no network, no skip. It
|
||||
cannot be replaced with a numeric literal either: an address literal is the `literal` arm and
|
||||
never reaches libc at all, which is the other half of the same pass.
|
||||
"""
|
||||
monkeypatch.setattr(host_agent.socket, "getaddrinfo", socket.getaddrinfo)
|
||||
collector = host_agent.Collector(str(gateway_tree(tmp_path / "root")),
|
||||
smartctl="/nonexistent/smartctl")
|
||||
|
||||
entry = collector._resolve_target("localhost", 9120)
|
||||
|
||||
assert entry["status"] == "ok", (
|
||||
f"real libc refused the call the agent makes ({entry}); if it says `error` with "
|
||||
"`EAI_SERVICE` in it, the keyword on that call has moved back to `proto=`")
|
||||
assert entry["addresses"], "a name libc resolves must publish at least one address"
|
||||
for value in entry["addresses"]:
|
||||
assert ipaddress.ip_address(value) # type: ignore[call-overload]
|
||||
|
||||
|
||||
def test_the_recorded_resolver_document_publishes_no_globally_routable_address() -> None:
|
||||
"""Invariant 6 over the JSON, for the same reason and with one hazard of its own.
|
||||
|
||||
A resolver answer is an identifier the same way a socket row is: `getent` on this router answers
|
||||
the WAN address of anything published through it, and a global IPv6 answer is EUI-64 — this
|
||||
box's NIC, in hex. `scripts/capture-gateway-config.sh` runs the same substitution over the
|
||||
resolver's answers as over the socket tables, so nothing routable is tracked.
|
||||
|
||||
The second assertion is specific to *this* fixture and is a warning, not a rule: on this fleet
|
||||
every declared target answers a private LAN address, so nothing needs scrubbing. If a capture
|
||||
ever moves an address into documentation space, that replacement is numbered per document in
|
||||
first-seen order — it is therefore **not** guaranteed to be the same documentation address the
|
||||
socket fixtures gave the same endpoint, and `tests/test_gateway_source.py`'s join of the two
|
||||
would silently stop joining. Re-check that join before trusting the numbers.
|
||||
"""
|
||||
document = json.loads((FIXTURES / "gateway_resolver.json").read_text())
|
||||
routable: list[str] = []
|
||||
documented: list[str] = []
|
||||
for row in document["entries"]:
|
||||
for value in row["addresses"]:
|
||||
address = ipaddress.ip_address(value)
|
||||
subject = getattr(address, "ipv4_mapped", None) or address
|
||||
if subject.is_global:
|
||||
routable.append(f"{row['name']} -> {value}")
|
||||
if not subject.is_private:
|
||||
documented.append(f"{row['name']} -> {value}")
|
||||
assert not routable, ("globally routable resolver answers are in a tracked fixture: "
|
||||
+ ", ".join(routable)
|
||||
+ " — scripts/capture-gateway-config.sh is the thing that stops them")
|
||||
assert not documented, ("the recorded resolver document had to be scrubbed: "
|
||||
+ ", ".join(documented)
|
||||
+ " — reconcile the replacement with the socket fixtures before "
|
||||
"trusting the join in tests/test_gateway_source.py")
|
||||
|
||||
|
||||
def test_the_recorded_socket_tables_publish_no_globally_routable_endpoint() -> None:
|
||||
"""AGENTS.md invariant 6, on the artifacts: identifiers never go in the repo.
|
||||
|
||||
@@ -3035,3 +3233,502 @@ def test_the_recorded_gateway_answers_all_three_sections_at_once(tmp_path: Path)
|
||||
"temperatures", "processes", "errors"):
|
||||
assert section in snap, f"the pre-1.2.0 payload lost {section}"
|
||||
|
||||
|
||||
|
||||
# ---- the resolver: the names the configs declare, resolved on this box (phase 32 task 01) -- #
|
||||
|
||||
def resolver_ledger(monkeypatch: pytest.MonkeyPatch, *,
|
||||
nx: tuple[str, ...] = (), delay: float = 0.0,
|
||||
fail: tuple[str, ...] = ()) -> list[tuple[str, int | None]]:
|
||||
"""Stub `getaddrinfo` with a ledger of every `(name, port)` it was asked about.
|
||||
|
||||
The three knobs are the three things a resolver does that a page has to survive: it answers
|
||||
(`203.0.113.x` + `2001:db8::…`, documentation space so a test payload can never carry a real
|
||||
endpoint), it refuses a name (`nxdomain`), and it is slow or broken (`delay`, `fail`).
|
||||
"""
|
||||
calls: list[tuple[str, int | None]] = []
|
||||
|
||||
def stub(name: object, port: object = None, *args: Any, **kwargs: Any) -> Any:
|
||||
text = str(name)
|
||||
calls.append((text, port if isinstance(port, int) else None))
|
||||
if text in fail:
|
||||
raise socket.gaierror(socket.EAI_NONAME, "and the resolver refused to say")
|
||||
if delay:
|
||||
time.sleep(delay)
|
||||
if text in nx:
|
||||
raise socket.gaierror(socket.EAI_NONAME, "Name or service not known")
|
||||
host_port = port if isinstance(port, int) else 0
|
||||
return [(socket.AF_INET, socket.SOCK_STREAM, 0, "", (f"203.0.113.{len(calls)}", host_port))]
|
||||
|
||||
monkeypatch.setattr(host_agent.socket, "getaddrinfo", stub)
|
||||
return calls
|
||||
|
||||
|
||||
def resolver_snapshot(root: Path, **kwargs: Any) -> dict[str, Any]:
|
||||
"""One snapshot of a captured-gateway tree, the resolver's `getaddrinfo` stubbed by the caller."""
|
||||
return host_agent.Collector(str(root), smartctl="/nonexistent/smartctl",
|
||||
**kwargs).snapshot()
|
||||
|
||||
|
||||
def test_only_the_targets_the_configs_declare_are_resolved(tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""The allow-list is the config, in config order, once each — decision 3, pinned as a call set.
|
||||
|
||||
Everything the resolver dialed is a target the two parsed sections publish, in the order the
|
||||
files write them, with no duplicate — and nothing that came from `/proc/net/tcp` is in there.
|
||||
That last half is the point: a socket table is full of addresses this gateway talks to, and
|
||||
resolving one of those would be a fact about whoever connected, not about the config.
|
||||
"""
|
||||
calls = resolver_ledger(monkeypatch)
|
||||
snap = resolver_snapshot(gateway_tree(tmp_path / "root"))
|
||||
declared = host_agent.target_names(snap["haproxy"], snap["nginx"])
|
||||
looked_up = [pair for pair in declared if host_agent.address_family(pair[0]) is None]
|
||||
|
||||
assert calls == looked_up, "the lookups are exactly the declared targets, in config order"
|
||||
assert len(calls) == len(set(calls)), "one name on one port is one lookup, not two"
|
||||
assert len(looked_up) < len(declared), "the tree writes literals too, and they are kept"
|
||||
socket_addresses = {row["remote"]["addr"] for row in snap["sockets"]["established"]}
|
||||
socket_addresses |= {row["local"]["addr"] for row in snap["sockets"]["established"]}
|
||||
socket_addresses |= {row["addr"] for row in snap["sockets"]["listens"]}
|
||||
assert not socket_addresses & {name for name, _ in calls}, (
|
||||
"a name from the socket table was resolved: the config is the only allow-list")
|
||||
# haproxy first (its six `server` lines), and this fleet's two Minecraft ports are two targets
|
||||
assert calls[:6] == [("reeseapps-gitea.reeselink.com", 2222),
|
||||
("stackexpected-gitea.reeselink.com", 2222),
|
||||
("web.reeselink.com", 5201),
|
||||
("unifi-external.reeselink.com", 8080),
|
||||
("minecraft.reeselink.com", 25565),
|
||||
("minecraft.reeselink.com", 25566)]
|
||||
|
||||
|
||||
def test_an_address_literal_in_a_config_is_reported_and_never_dialled(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""`proxy_pass http://127.0.0.1:8081` is already an address: the entry says so, no lookup runs.
|
||||
|
||||
Looking a literal up would be a round trip to be told what the config wrote, and on a box with
|
||||
a slow resolver it is time spent for nothing. The address still travels, because the gateway
|
||||
page's join keys on it.
|
||||
"""
|
||||
calls = resolver_ledger(monkeypatch)
|
||||
snap = resolver_snapshot(gateway_tree(tmp_path / "root"))
|
||||
entries = {host_agent.resolver_key(e["name"], e["port"]): e for e in snap["resolver"]["entries"]}
|
||||
|
||||
literal = entries[host_agent.resolver_key("127.0.0.1", 8081)]
|
||||
assert literal["status"] == "literal"
|
||||
assert literal["addresses"] == ["127.0.0.1"]
|
||||
assert ("127.0.0.1", 8081) not in calls, "a literal costs no lookup"
|
||||
assert not [call for call in calls if host_agent.address_family(call[0])], \
|
||||
"no lookup was ever asked about an address"
|
||||
|
||||
|
||||
def test_the_section_names_how_and_where_and_when(tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""`via`, `config_directive` and the timestamps are the provenance of every address here.
|
||||
|
||||
`libc` is what `getaddrinfo` is — the resolution haproxy performs on this box — and the nginx
|
||||
config's own `resolver` line travels beside it verbatim (read out of `stream.d/stream.conf`,
|
||||
which is where this fleet writes it, so an include is as good as the main file). The
|
||||
`nginx` section itself grows no key: that is what keeps the phase's additivity control a
|
||||
top-level comparison.
|
||||
"""
|
||||
resolver_ledger(monkeypatch)
|
||||
snap = resolver_snapshot(gateway_tree(tmp_path / "root"))
|
||||
section = snap["resolver"]
|
||||
assert section["via"] == host_agent.RESOLVE_VIA == "libc"
|
||||
assert section["config_directive"] == "10.1.0.1"
|
||||
assert "resolver" not in snap["nginx"], "the nginx document stays byte-stable"
|
||||
assert isinstance(section["ts"], float)
|
||||
for entry in section["entries"]:
|
||||
assert entry["ts"] <= section["ts"] + 1.0, "an entry is answered at or before the pass ends"
|
||||
assert set(entry) == {"name", "port", "status", "addresses", "ts"}
|
||||
|
||||
|
||||
def test_the_resolver_cache_means_one_lookup_per_target(tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Two snapshots, one set of lookups; an expiry refreshes in the background and never on the request.
|
||||
|
||||
The dashboard polls every 10 s. Without the cache that is 30 DNS lookups per host per minute
|
||||
for names that move when the operator moves them; with it, the poll that finds an expired
|
||||
cache is answered from the cache and carries the **original** per-entry `ts`, so it cannot
|
||||
present a five-minute-old address as a fresh one.
|
||||
"""
|
||||
calls = resolver_ledger(monkeypatch)
|
||||
root = gateway_tree(tmp_path / "root")
|
||||
collector = host_agent.Collector(str(root), smartctl="/nonexistent/smartctl")
|
||||
|
||||
first = collector.snapshot()["resolver"]
|
||||
baseline = len(calls)
|
||||
assert baseline and len(calls) == len(set(calls))
|
||||
|
||||
second = collector.snapshot()["resolver"]
|
||||
assert len(calls) == baseline, "a fresh cache costs no lookup"
|
||||
assert second["entries"] == first["entries"]
|
||||
|
||||
collector.resolve_cache_seconds = 0.0
|
||||
third = collector.snapshot()["resolver"]
|
||||
assert third["entries"] == first["entries"], "the expired cache is served as it was"
|
||||
assert [e["ts"] for e in third["entries"]] == [e["ts"] for e in first["entries"]], \
|
||||
"a cache-served entry keeps the time it was answered, not the time it was served"
|
||||
wait_for_resolver_refresh(collector)
|
||||
assert len(calls) > baseline, "the expiry did re-resolve, off the request path"
|
||||
|
||||
|
||||
def test_a_slow_resolver_cannot_hold_the_snapshot_open(tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""The pass stops at its budget and says so once — the SMART pass-budget lesson, applied to DNS.
|
||||
|
||||
`getaddrinfo` takes no timeout argument, so the only defence against a resolver that has
|
||||
started black-holing is to stop asking. What the section must still be: `available: true`, the
|
||||
targets it reached present, one line naming how many of them that was, and `/snapshot`
|
||||
answering in a small fraction of the time the full set would have taken — because the *system*
|
||||
page's timeout is what a slow pass would otherwise cost.
|
||||
"""
|
||||
calls = resolver_ledger(monkeypatch, delay=0.02)
|
||||
root = gateway_tree(tmp_path / "root")
|
||||
collector = host_agent.Collector(str(root), smartctl="/nonexistent/smartctl")
|
||||
collector.resolve_pass_budget_seconds = 0.1
|
||||
|
||||
started = time.monotonic()
|
||||
snap = collector.snapshot()
|
||||
elapsed = time.monotonic() - started
|
||||
section = snap["resolver"]
|
||||
budget_lines = [line for line in section["errors"] if "pass budget" in line]
|
||||
|
||||
assert section["available"] is True, "a truncated pass is still an answer"
|
||||
assert len(budget_lines) == 1, snap["errors"]
|
||||
reached = [e for e in section["entries"] if e["status"] != "literal"]
|
||||
assert budget_lines[0] == (f"resolver: pass budget 0.1s exhausted, "
|
||||
f"{len(reached)} of 30 targets looked up"), budget_lines[0]
|
||||
assert len(reached) < 29, "the pass stopped rather than finished"
|
||||
assert len(calls) == len(reached), "the ledger and the section agree on what was reached"
|
||||
assert [e for e in section["entries"] if e["status"] == "literal"], \
|
||||
"an address the config spells survives a pass that stopped early"
|
||||
assert len(section["entries"]) == len(reached) + 2, "two literals, and nothing invented"
|
||||
assert elapsed < 1.0, f"a bounded pass cost {elapsed:.2f}s"
|
||||
assert snap["cpu"]["modes"]["total"] > 0 and snap["host"]["hostname"]
|
||||
assert core_errors(snap) == [], snap["errors"]
|
||||
|
||||
|
||||
def test_the_name_bound_reports_the_excess_in_one_line(tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""More declared targets than the bound: the excess is one line, not 40, and `true` stands."""
|
||||
calls = resolver_ledger(monkeypatch)
|
||||
root = gateway_tree(tmp_path / "root")
|
||||
collector = host_agent.Collector(str(root), smartctl="/nonexistent/smartctl")
|
||||
collector.resolve_max_names = 3
|
||||
|
||||
section = collector.snapshot()["resolver"]
|
||||
bound_lines = [line for line in section["errors"] if "name bound" in line]
|
||||
assert len(bound_lines) == 1, section["errors"]
|
||||
assert bound_lines[0].startswith("resolver: 27 of 30 targets are beyond the 3-name bound"), \
|
||||
bound_lines[0]
|
||||
assert section["available"] is True
|
||||
assert len(calls) == 3, "the bound is the cost of the section, not a display choice"
|
||||
literals = [e for e in section["entries"] if e["status"] == "literal"]
|
||||
assert len(section["entries"]) == 3 + len(literals)
|
||||
|
||||
|
||||
def wait_for_resolver_refresh(collector: Any, attempts: int = 500) -> None:
|
||||
"""Join the single-flight refresher, as far as a test can: it is a daemon thread on purpose.
|
||||
|
||||
The wait is on the flag rather than a `Thread.join()` because the agent keeps no handle to it
|
||||
(a snapshot must never be able to wait on DNS), and 5 s of 10 ms polls is more than a stubbed
|
||||
pass takes on any machine this suite runs on.
|
||||
"""
|
||||
for _ in range(attempts):
|
||||
if not collector._resolver_refreshing:
|
||||
return
|
||||
time.sleep(0.01)
|
||||
raise AssertionError("the background resolver refresh never finished")
|
||||
|
||||
|
||||
def test_a_failed_background_refresh_keeps_the_last_addresses(tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""A refresh that dies is one line, the old entries, and no hammering.
|
||||
|
||||
The thread is the only thing that ever re-reads DNS after the first pass, so a pass that
|
||||
raises must leave the snapshot answering with the last truth it had (SMART's rule) and must
|
||||
not turn every subsequent poll into a retry. One line, replaced rather than stacked: a
|
||||
resolver that has been broken for a week is one fact the operator acts on once, not 2 000.
|
||||
"""
|
||||
resolver_ledger(monkeypatch)
|
||||
collector = host_agent.Collector(str(gateway_tree(tmp_path / "root")),
|
||||
smartctl="/nonexistent/smartctl")
|
||||
first = collector.snapshot()["resolver"]
|
||||
assert first["available"] is True and first["entries"]
|
||||
|
||||
def explode(*args: object) -> None:
|
||||
raise RuntimeError("the resolver daemon is gone")
|
||||
|
||||
monkeypatch.setattr(host_agent.Collector, "resolver_pass", explode)
|
||||
collector.resolve_cache_seconds = 0.0
|
||||
for attempt in range(2):
|
||||
served = collector.snapshot()["resolver"]
|
||||
assert served["entries"] == first["entries"], "the last good answers stay published"
|
||||
wait_for_resolver_refresh(collector)
|
||||
# Read the collector's state, not this response: the snapshot was served *while* the
|
||||
# refresh was in flight, so the line it could not have is not a defect in it.
|
||||
failures = [line for line in collector._resolver_errors
|
||||
if "background refresh failed" in line]
|
||||
assert len(failures) == 1, (attempt, collector._resolver_errors)
|
||||
assert failures[0].startswith("resolver: background refresh failed: RuntimeError")
|
||||
assert collector._resolver_cache_ts is not None, "a failed pass still re-stamps the cache"
|
||||
# …and the line reaches the page eventually, beside the entries it explains.
|
||||
later = collector.snapshot()["resolver"]
|
||||
assert any("background refresh failed" in line for line in later["errors"]), later["errors"]
|
||||
assert later["entries"] == first["entries"]
|
||||
|
||||
|
||||
def test_nxdomain_is_a_status_and_never_a_failure(tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""One name that does not answer is one entry with no addresses — the snapshot is unbothered.
|
||||
|
||||
The route stays on the page and carries no count (task 03 does the joining); what must not
|
||||
happen is a `0`, a dropped entry, or a poll that goes red because a domain left the zone file.
|
||||
"""
|
||||
resolver_ledger(monkeypatch, nx=("reeseapps-brain-of-reese.reeselink.com",))
|
||||
snap = resolver_snapshot(gateway_tree(tmp_path / "root"))
|
||||
section = snap["resolver"]
|
||||
entries = {e["name"]: e for e in section["entries"]}
|
||||
|
||||
failed = entries["reeseapps-brain-of-reese.reeselink.com"]
|
||||
assert failed["status"] == "nxdomain"
|
||||
assert failed["addresses"] == [], "no address, never a 0 and never last month's"
|
||||
answered = [e for e in section["entries"] if e["status"] == "ok"]
|
||||
assert answered, "the others answered"
|
||||
assert section["available"] is True
|
||||
assert section["errors"] == [], "a name that does not resolve is not this section's failure"
|
||||
assert snap["cpu"]["modes"]["total"] > 0
|
||||
assert core_errors(snap) == [], snap["errors"]
|
||||
|
||||
|
||||
def test_a_resolver_that_errors_is_an_error_and_not_an_nxdomain(tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
""""The name has no record" and "the lookup could not be performed" are two fixes.
|
||||
|
||||
A temporary failure (`EAI_AGAIN`) is the one that most looks like `nxdomain` and must not be
|
||||
reported as it: an operator reading `nxdomain` edits a zone file, an operator reading `error`
|
||||
checks whether the resolver is up.
|
||||
"""
|
||||
def stub(name: object, port: object = None, *args: Any, **kwargs: Any) -> Any:
|
||||
text = str(name)
|
||||
if text == "gitea.reeselink.com":
|
||||
raise socket.gaierror(socket.EAI_AGAIN, "Temporary failure in name resolution")
|
||||
if text == "jellyfin.reeselink.com":
|
||||
raise OSError("resolver socket refused to open")
|
||||
if text == "bricktracker.reeselink.com":
|
||||
raise ValueError("a platform-shaped answer nobody predicted")
|
||||
host_port = port if isinstance(port, int) else 0
|
||||
return [(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("203.0.113.7", host_port))]
|
||||
|
||||
monkeypatch.setattr(host_agent.socket, "getaddrinfo", stub)
|
||||
snap = resolver_snapshot(gateway_tree(tmp_path / "root"))
|
||||
entries = {e["name"]: e for e in snap["resolver"]["entries"]}
|
||||
|
||||
assert entries["gitea.reeselink.com"]["status"] == "error", "EAI_AGAIN is not nxdomain"
|
||||
assert entries["jellyfin.reeselink.com"]["status"] == "error"
|
||||
assert entries["bricktracker.reeselink.com"]["status"] == "error"
|
||||
assert entries["bricktracker.reeselink.com"]["detail"].startswith("ValueError")
|
||||
assert entries["gitea.reeselink.com"]["addresses"] == []
|
||||
assert snap["resolver"]["available"] is True
|
||||
assert core_errors(snap) == [], snap["errors"]
|
||||
|
||||
|
||||
def test_the_addresses_are_unique_and_sorted_v4_then_v6(tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Two polls of one healthy gateway are byte-identical, whatever order libc answered in.
|
||||
|
||||
`getaddrinfo` returns whatever the resolver and `/etc/gai.conf` feel like today, and the entry
|
||||
list is something a page renders: an unsorted set makes a healthy box look like its addresses
|
||||
are shuffling.
|
||||
"""
|
||||
def stub(name: object, port: object = None, *args: Any, **kwargs: Any) -> Any:
|
||||
host_port = port if isinstance(port, int) else 0
|
||||
return [(socket.AF_INET6, socket.SOCK_STREAM, 0, "", ("2001:db8::2", host_port, 0, 0)),
|
||||
(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("203.0.113.9", host_port)),
|
||||
(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("203.0.113.9", host_port)),
|
||||
(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("203.0.113.4", host_port))]
|
||||
|
||||
monkeypatch.setattr(host_agent.socket, "getaddrinfo", stub)
|
||||
snap = resolver_snapshot(gateway_tree(tmp_path / "root"))
|
||||
entry = snap["resolver"]["entries"][0]
|
||||
assert entry["status"] == "ok"
|
||||
assert entry["addresses"] == ["203.0.113.4", "203.0.113.9", "2001:db8::2"]
|
||||
|
||||
|
||||
def test_a_value_that_is_not_an_address_sorts_last_instead_of_raising() -> None:
|
||||
"""The sort is defensive for the one input the section should never see.
|
||||
|
||||
`getaddrinfo` answers addresses, so this arm is a stub or a platform oddity — and the answer
|
||||
to that is a stable order with the odd value at the end, not a `ValueError` on a snapshot path
|
||||
and not the whole section lost because one entry could not be compared.
|
||||
"""
|
||||
assert host_agent.sort_resolver_addresses(["nope", "10.0.0.2", "::1", "also-bad", "10.0.0.2"]) \
|
||||
== ["10.0.0.2", "::1", "also-bad", "nope"]
|
||||
|
||||
|
||||
def test_no_resolve_removes_the_key_in_silence(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""`--no-resolve` is `--no-smart`'s precedent: no key, no error line — "not collected".
|
||||
|
||||
An empty section would read as "collected, and nothing resolves", which is a different fact
|
||||
and the wrong one to print for a host the operator told not to ask.
|
||||
"""
|
||||
calls = resolver_ledger(monkeypatch)
|
||||
root = gateway_tree(tmp_path / "root")
|
||||
|
||||
snap = host_agent.Collector(str(root), smartctl="/nonexistent/smartctl",
|
||||
resolve=False).snapshot()
|
||||
assert "resolver" not in snap
|
||||
assert calls == [], "the flag is honoured before anything is dialled"
|
||||
assert snap["nginx"]["available"] is True and snap["sockets"]["available"] is True, \
|
||||
"the rest of the family is untouched"
|
||||
assert [line for line in snap["errors"] if line.startswith("resolver:")] == []
|
||||
|
||||
|
||||
def test_no_proxy_removes_the_resolver_with_the_rest_of_the_family(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""A box that collects no proxy has no declared names, so `--no-proxy` removes this too."""
|
||||
calls = resolver_ledger(monkeypatch)
|
||||
snap = host_agent.Collector(str(gateway_tree(tmp_path / "root")),
|
||||
smartctl="/nonexistent/smartctl", proxy=False).snapshot()
|
||||
for section in ("haproxy", "nginx", "sockets", "resolver"):
|
||||
assert section not in snap
|
||||
assert calls == []
|
||||
|
||||
|
||||
def test_a_host_with_no_proxy_sections_says_so_once(tmp_path: Path) -> None:
|
||||
"""`available: false` and exactly one `resolver:` line naming **not found** — invariant 3.
|
||||
|
||||
Two of this fleet's three hosts run no proxy at all. Their agents must report "nothing to
|
||||
resolve" without putting a red banner, an overview alert, or fifty lines in `errors` anywhere.
|
||||
"""
|
||||
snap = host_agent.Collector(str(tmp_path / "root")).snapshot()
|
||||
resolver_lines = [line for line in snap["errors"] if line.startswith("resolver:")]
|
||||
|
||||
assert snap["resolver"]["available"] is False
|
||||
assert snap["resolver"]["entries"] == []
|
||||
assert len(resolver_lines) == 1, snap["errors"]
|
||||
assert "not found" in resolver_lines[0]
|
||||
assert "--no-resolve" in resolver_lines[0]
|
||||
assert snap["resolver"]["errors"] == resolver_lines
|
||||
assert core_errors(snap) == [], "an unconfigured add-on is not a system degradation"
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.geteuid() == 0, reason="root reads a 0000 file")
|
||||
def test_a_config_that_is_present_but_unreadable_says_that_instead(tmp_path: Path) -> None:
|
||||
"""The `false` arm names the right reason: an unreadable file is not a missing one.
|
||||
|
||||
`not found` and `unreadable` are two operator actions — install the proxy, or fix the mode on
|
||||
its config — and one line that says which is the difference between a `chmod` and a
|
||||
`dnf install`. Still one line, still `available: false`, still no system degradation.
|
||||
"""
|
||||
root = tmp_path / "root"
|
||||
nginx_tree(root, http=None, stream=None)
|
||||
(root / "etc/nginx/nginx.conf").chmod(0o000)
|
||||
|
||||
snap = host_agent.Collector(str(root), smartctl="/nonexistent/smartctl").snapshot()
|
||||
line = next(line for line in snap["errors"] if line.startswith("resolver:"))
|
||||
assert snap["resolver"]["available"] is False
|
||||
assert "haproxy not found" in line
|
||||
assert "nginx unreadable" in line
|
||||
assert len([x for x in snap["errors"] if x.startswith("resolver:")]) == 1
|
||||
|
||||
|
||||
def test_a_config_that_reads_cleanly_and_names_no_target_is_true_and_empty(
|
||||
tmp_path: Path) -> None:
|
||||
"""Configs read, no targets declared: `available: true` with `entries: []` and no line.
|
||||
|
||||
Absence of a target is not a fault, and the third arm (`null`) is reserved for a pass that
|
||||
could not run at all — otherwise "no routes" and "no resolver" arrive as one value.
|
||||
"""
|
||||
root = tmp_path / "root"
|
||||
nginx_tree(root, main="http { server { listen 8080; server_name quiet.test; } }\n",
|
||||
http=None, stream=None)
|
||||
snap = host_agent.Collector(str(root), smartctl="/nonexistent/smartctl").snapshot()
|
||||
|
||||
assert snap["nginx"]["available"] is True
|
||||
assert snap["resolver"]["available"] is True, snap["errors"]
|
||||
assert snap["resolver"]["entries"] == []
|
||||
assert snap["resolver"]["errors"] == []
|
||||
assert core_errors(snap) == [], snap["errors"]
|
||||
|
||||
|
||||
def test_the_resolver_lines_never_fail_the_snapshot(tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Every arm of the new section leaves `/snapshot` answering, with its key and its own line.
|
||||
|
||||
The family rule phase 28 pinned for `haproxy`, `nginx` and `sockets` extended to the fourth
|
||||
section: whatever the resolver does — raises inside, cannot find a config, cannot reach a
|
||||
name — the document keeps the key (so a reader can tell "the pass failed" from "the agent
|
||||
predates it"), the other sections are intact, and the line is the add-on's own rather than a
|
||||
system `degraded` (invariant 3).
|
||||
"""
|
||||
def explode(*args: object) -> dict[str, Any]:
|
||||
raise RuntimeError("resolver exploded")
|
||||
|
||||
monkeypatch.setattr(host_agent.Collector, "resolver", explode)
|
||||
snap = host_agent.Collector(str(gateway_tree(tmp_path / "root")),
|
||||
smartctl="/nonexistent/smartctl").snapshot()
|
||||
assert snap["resolver"] == {"available": None, "via": "libc", "config_directive": None,
|
||||
"ts": None, "entries": [], "errors": []}
|
||||
assert any(line.startswith("resolver:") for line in snap["errors"])
|
||||
assert core_errors(snap) == [], snap["errors"]
|
||||
assert snap["haproxy"]["available"] is True and snap["nginx"]["available"] is True
|
||||
assert snap["cpu"]["modes"]["total"] > 0
|
||||
|
||||
|
||||
def test_the_resolver_section_is_documented_in_the_schema() -> None:
|
||||
"""`GET /schema` answers the docstring, so the section an operator can `curl` is described."""
|
||||
assert schema_documents("resolver")
|
||||
|
||||
|
||||
def test_the_resolver_reaches_the_command_line(tmp_path: Path, capsys: pytest.CaptureFixture[str],
|
||||
monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""`--once` prints the resolver block, and prints none with `--no-resolve` added.
|
||||
|
||||
This is the completion criterion an operator can run on a host with no dashboard: one command,
|
||||
and the answer says which names this box turned into which addresses.
|
||||
"""
|
||||
resolver_ledger(monkeypatch)
|
||||
root = gateway_tree(tmp_path / "root")
|
||||
# The config paths are the ones the **config** writes: an absolute path resolves under
|
||||
# `--root`, which is how a deployment reads /etc and a test reads a fake /etc.
|
||||
nginx, haproxy = "/etc/nginx/nginx.conf", "/etc/haproxy/haproxy.cfg"
|
||||
|
||||
assert host_agent.main(["--once", "--no-smart", "--root", str(root),
|
||||
"--haproxy-config", haproxy, "--nginx-config", nginx]) == 0
|
||||
printed = json.loads(capsys.readouterr().out)
|
||||
assert printed["resolver"]["available"] is True
|
||||
assert printed["resolver"]["via"] == "libc"
|
||||
assert printed["resolver"]["config_directive"] == "10.1.0.1"
|
||||
assert printed["resolver"]["entries"]
|
||||
|
||||
assert host_agent.main(["--once", "--no-smart", "--no-resolve", "--root", str(root),
|
||||
"--haproxy-config", haproxy, "--nginx-config", nginx]) == 0
|
||||
without = json.loads(capsys.readouterr().out)
|
||||
assert "resolver" not in without
|
||||
assert without["nginx"]["available"] is True, "the rest of the family still prints"
|
||||
|
||||
|
||||
def test_the_target_list_is_the_two_sections_and_survives_a_section_that_is_not_one() -> None:
|
||||
"""`target_names` reads haproxy then nginx (upstreams, proxy_pass, maps) and nothing stranger.
|
||||
|
||||
A `proxy_pass $var` contributes nothing — the map decides, and the map's entries are already
|
||||
in the list — and a `None`, a bare string, or a section of the wrong shape contributes an
|
||||
empty list rather than a `TypeError` on a snapshot path.
|
||||
"""
|
||||
haproxy = {"backends": [{"servers": [{"name": "a", "target": "a.test", "port": 1},
|
||||
{"name": "b", "target": "b.test", "port": None},
|
||||
{"name": "c", "target": None, "port": 3},
|
||||
{"name": "d", "target": "a.test", "port": 1}]}]}
|
||||
nginx = {"upstreams": [{"servers": [{"host": "pool.test", "port": 80}]}],
|
||||
"servers": [{"proxy_passes": [{"target": {"host": "vhost.test", "port": 443}},
|
||||
{"target": None, "variable": "backend"}]}],
|
||||
"maps": [{"entries": [{"key": "x", "target": {"host": "map.test", "port": 443}},
|
||||
{"key": "default", "target": None}]}]}
|
||||
|
||||
assert host_agent.target_names(haproxy, nginx) == [
|
||||
("a.test", 1), ("b.test", None), ("pool.test", 80),
|
||||
("vhost.test", 443), ("map.test", 443)]
|
||||
assert host_agent.target_names(None, None) == []
|
||||
assert host_agent.target_names({"backends": "nonsense"}, {"maps": [7]}) == []
|
||||
|
||||
Reference in New Issue
Block a user