Files
ducoterra 86c8bf4992 phase: 33_gateway_route_switching
Phase 33 completion pass is done. Final report:

## Phase 33 — completion report

**All 8 task files complete** (`.agents/phases/complete/33_gateway_route_switching/`), full gate green, docs verified.

### Work in this pass

1. **`tests/test_routectl.py` added** (17 tests, all green) — exercises the *real* helper binary unprivileged against a fake tree (`--root`), the real agent parser, the captured gateway fixtures, and stubbed `nginx`/`haproxy`/`systemctl` on `PATH`. Pins the full protocol (locate → state → dry-run → temp-file/fsync/`os.replace` write → checker → restore on checker failure → reload-never-restart → re-read proof → sidecar record), all three route shapes (haproxy `server`, nginx `proxy_pass`, nginx stream `map` via its sink-value id), `drift` / unknown-id / `map-default` refusals, `list`/`verify`, and the `--serve` peer gate (`SO_PEERCRED` uid check, one JSON line per request, exact body shape).

2. **Real helper defect fixed** — `deploy/dashboards-routectl.py`: `main()`'s early-return paths returned `({"ok": False, …}, EXIT_*)` tuples, so `sys.exit()` printed a Python repr instead of a JSON line (usage errors, agent-import refusal). Now prints `json.dumps(…)` and returns only the code, keeping the one-JSON-line contract.

3. **Documentation consistency fixes** (task 08 criterion 6):
   - `README.md`: garbled verdict-colour sentence on the switch ("green and to the position") → "in the **on** position and tinted `--gate-on`" (the existence-pair contract).
   - `README.md`: audit sentence now names all three event kinds — `route-disabled` / `route-enabled` / **`route-denied`** (the code stores one per refused call; dry runs store none).
   - `page-gateway.js`: replaced the stale pre-33 module docstring ("the switch is not wired") with the two-arm `GATEWAY_ALLOW_WRITES` contract.
   - `AGENTS.md`: every phase-33 claim re-verified against code (the ten gates, the 35 s/30 s budget pair, `gateway.writes_enabled`, `/api/summary`'s `disabled`, the 1.4.0 agent bullet, the gap list) — no changes needed.

### Verification
- `uv run pytest --cov`: **1970 passed**, 18 skipped — coverage **98.74%** (floor 94%)
- `ruff` clean · `pyright` 0 errors · `bash .agents/validate.sh` **exit 0** (incl. `node --check` on every page script)
- Dev server running; all 10 sources `ok`; bundles exported

### Live infrastructure
**No live changes made.** The live drill remains unexecuted per `data/logs/NOTES-33-switching.md` (no operator approval; checker binaries unavailable in the podman proxy units). All routes stay enabled, config hashes unchanged, and the sandbox rehearsal + backup tree are in place for the operator's own `DANGER` call. All changes are in the working tree only.
2026-11-01 11:50:51 -05:00

4287 lines
217 KiB
Python

"""The host agent: stdlib-only, read-only by construction, and wrong on no box.
Four things are asserted here:
* parsing of /proc and /sys files exactly as Linux writes them (fake roots, so the
weird cases are testable on any machine);
* the collector produces a complete, non-crashing snapshot against the *real* /proc of
whatever runs the tests, because that is the code that ships;
* the HTTP surface answers GETs, needs a token when it is exposed, and has no write path;
* the TLS mode (phase 15) answers a real handshake only to a client certificate the bundle's
root signed and the bundle's denylist does not name, with certificates minted by the
dashboard's own `certs.py` — there is no second generator to disagree with.
"""
from __future__ import annotations
import hashlib
import http.client
import importlib.util
import ipaddress
import json
import os
import re
import socket
import ssl
import subprocess
import sys
import threading
import time
import urllib.error
import urllib.request
from contextlib import suppress
from datetime import UTC, datetime, timedelta
from http.server import ThreadingHTTPServer
from pathlib import Path
from types import SimpleNamespace
from typing import Any, cast
import pytest
from cryptography import x509
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives.asymmetric.ec import EllipticCurvePrivateKey
from cryptography.hazmat.primitives.hashes import SHA256
from cryptography.hazmat.primitives.serialization import (
Encoding,
NoEncryption,
PrivateFormat,
load_pem_private_key,
)
from cryptography.x509.oid import NameOID
from dashboards import certs, collectors
ROOT = Path(__file__).resolve().parents[1]
AGENT_PATH = ROOT / "agent" / "host_agent.py"
spec = importlib.util.spec_from_file_location("host_agent", AGENT_PATH)
assert spec and spec.loader
host_agent = importlib.util.module_from_spec(spec)
sys.modules["host_agent"] = host_agent
spec.loader.exec_module(host_agent)
linux_only = pytest.mark.skipif(not sys.platform.startswith("linux"),
reason="reads /proc and /sys")
# ---- source-level helpers ------------------------------------------------- #
@pytest.mark.parametrize("lspci,expected", [
("Advanced Micro Devices, Inc. [AMD/ATI] Navi 48 [Radeon AI PRO R9700]",
"Radeon AI PRO R9700"),
("Advanced Micro Devices, Inc. [AMD/ATI] Strix Halo [Radeon Graphics / Radeon 8050S "
"Graphics / Radeon 8060S Graphics]", "Radeon 8060S Graphics"),
("Advanced Micro Devices, Inc. [AMD/ATI] Phoenix1", "Phoenix1"),
("00:02.0 VGA compatible controller: Intel Corporation UHD Graphics 620",
"UHD Graphics 620"),
("NVIDIA GB200", "NVIDIA GB200"),
("Vendor 1af1 Device 0001", "Vendor 1af1 Device 0001"),
(None, None),
("", None),
])
def test_gpu_model_is_shortened_for_legends_without_losing_identity(lspci, expected) -> None:
"""`ATI` on a legend is worse than no name: it is true of every AMD GPU on the box."""
assert host_agent.short_gpu_model(lspci) == expected
def test_gpu_model_is_capped_so_a_legend_cannot_blow_up() -> None:
long_name = "X" * 200
assert len(host_agent.short_gpu_model(long_name)) <= 41 # 40 + ellipsis
# ---- /proc and /sys parsing (fake roots) ---------------------------------- #
def write_file(root: Path, relative: str, text: str) -> None:
path = root / relative
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text)
def test_read_number_takes_the_first_field(tmp_path: Path) -> None:
"""/proc/uptime is `210036.18 9613750.86`; float() of the whole line is None."""
write_file(tmp_path, "proc/uptime", "210036.18 9613750.86\n")
collector = host_agent.Collector(str(tmp_path))
assert collector.read_number("proc/uptime") == 210036.18
def test_read_number_handles_missing_and_junk_files(tmp_path: Path) -> None:
collector = host_agent.Collector(str(tmp_path))
assert collector.read_number("proc/nope") is None
write_file(tmp_path, "proc/junk", "\n")
assert collector.read_number("proc/junk") is None
def test_core_counts_dedupe_socket_and_core(tmp_path: Path) -> None:
"""One /proc/cpuinfo block per *thread*: counting 'core id' lines said '1 core'."""
blocks = []
for thread in range(8): # 2 sockets x 2 cores x 2 threads
socket, core = thread % 2, (thread // 2) % 2
blocks.append(f"processor\t: {thread}\nphysical id\t: {socket}\ncore id\t\t: {core}\n")
write_file(tmp_path, "proc/cpuinfo", "\n".join(blocks))
collector = host_agent.Collector(str(tmp_path))
assert collector.core_counts() == (8, 4)
def test_core_counts_falls_back_to_smt_width_on_sysfs(tmp_path: Path) -> None:
"""Some kernels/vms give no physical id/core id; cpu0's sibling list still says SMT=2."""
write_file(tmp_path, "proc/cpuinfo", "processor\t: 0\nprocessor\t: 1\n")
write_file(tmp_path, "sys/devices/system/cpu/cpu0/topology/thread_siblings_list", "0,1\n")
collector = host_agent.Collector(str(tmp_path))
logical, physical = collector.core_counts()
assert (logical, physical) == (2, 1)
def test_core_counts_survive_a_kernel_with_no_hints(tmp_path: Path) -> None:
collector = host_agent.Collector(str(tmp_path))
assert collector.core_counts()[1] is None, "unknown must be None, never 1"
def test_cpu_modes_total_excludes_guest_ticks(tmp_path: Path) -> None:
"""/proc/stat's idle/iowait are already inside 'total'; guest is inside user on Linux."""
write_file(tmp_path, "proc/stat",
"cpu 100 10 20 500 5 3 2 1 0 0\nintr 1\nctxt 2\nprocesses 3\n")
collector = host_agent.Collector(str(tmp_path))
sample = collector.cpu()["modes"]
assert sample["user"] == 100 and sample["idle"] == 500
assert sample["total"] == pytest.approx(100 + 10 + 20 + 500 + 5 + 3 + 2 + 1)
# ---- SMART (smartctl) ------------------------------------------------------ #
FIXTURES = Path(__file__).resolve().parent / "fixtures"
NVME_CAPTURE = (FIXTURES / "smartctl_reference_nvme.txt").read_text()
ATA_HDD_CAPTURE = (FIXTURES / "smartctl_reference_ata_hdd.txt").read_text()
def read_fixture(text: str) -> str:
"""Strip the `#` provenance header the capture script writes above a fixture."""
return "\n".join(line for line in text.splitlines() if not line.startswith("#"))
@pytest.mark.parametrize("path", sorted(FIXTURES.glob("smartctl_*.txt")))
def test_every_smartctl_capture_parses_without_inventing_numbers(path: Path) -> None:
"""A capture the parser cannot read must fail here, not on a `/disks` card. A serial
in a fixture is a serial in the browser, so every captured serial is scrubbed."""
parsed = host_agent.parse_smart_output(read_fixture(path.read_text()))
assert parsed["model"], f"{path.name}: no model identity parsed"
# a recorded FAILED drive is a real drive failing, not a test to loosen: replace the
# drive, or capture its truth into a purpose-built fixture and say why here.
assert parsed["overall_health"] == "PASSED", f"{path.name}: health is {parsed['overall_health']!r}"
assert parsed["power_on_hours"] is not None, f"{path.name}: no power-on hours parsed"
assert parsed["serial"] is None or parsed["serial"].startswith("SCRUBBEDSERIAL"), path.name
for key in host_agent.SMART_FIELDS:
assert key in parsed, f"{path.name}: {key} missing from the parsed shape"
def test_smart_output_parser_reads_the_nvme_health_log() -> None:
parsed = host_agent.parse_smart_output(read_fixture(NVME_CAPTURE))
assert parsed["model"] == "EXAMPLE NVMe SSD 2TB"
assert parsed["serial"] == "SCRUBBEDSERIAL1"
assert parsed["temp_c"] == 38.0
assert parsed["power_on_hours"] == 8760.0, "the capture writes 8,760 — commas are notation"
assert parsed["percentage_used"] == 1.0
# NVMe's media-error counter IS this drive family's offline-uncorrectable fact
assert parsed["offline_uncorrectable"] == 0.0
# and what an NVMe does not publish stays None — reallocated/pending are ATA attributes
assert parsed["reallocated_sector_ct"] is None
assert parsed["current_pending_sector_ct"] is None
def test_smart_output_parser_reads_the_ata_attribute_table() -> None:
parsed = host_agent.parse_smart_output(read_fixture(ATA_HDD_CAPTURE))
assert parsed["model"] == "EXAMPLE-ST8000VN004"
assert parsed["overall_health"] == "PASSED"
assert parsed["temp_c"] == 31.0, "raw value (last column), not the normalised VALUE column"
assert parsed["power_on_hours"] == 17520.0
assert parsed["reallocated_sector_ct"] == 8.0
assert parsed["current_pending_sector_ct"] == 3.0
assert parsed["offline_uncorrectable"] == 2.0
assert parsed["percentage_used"] is None, "this HDD publishes no wear percentage"
def test_smart_output_parser_keeps_the_drives_own_verdict_words() -> None:
assert host_agent.parse_smart_output(
"SMART overall-health self-assessment test result: FAILED!\n")["overall_health"] == "FAILED"
assert host_agent.parse_smart_output(
"SMART overall-health self-assessment test result: UNKNOWN!\n")["overall_health"] == "UNKNOWN"
def test_smart_failure_note_quotes_the_error_not_the_version_banner() -> None:
"""Caught on a real box: smartctl prints its banner first and the failure later, so
'first line of output' reported the copyright instead of Permission denied."""
banner = ("smartctl 7.5 2025-04-30 r5714 [x86_64-linux] (local build)\n"
"Copyright (C) 2002-25, Bruce Allen, Christian Franke\n\n")
denied = "Smartctl open device: /dev/nvme0 failed: Permission denied\n"
assert "Permission denied" in host_agent.smart_failure_line(2, banner + denied, "")
assert host_agent.smart_failure_line(7, "", "") == "exit status 7"
assert host_agent.smart_failure_line(4, "some odd note\n", "") == "some odd note"
# stderr counts too — smartctl puts some failures there and nowhere else
assert "scsi hangup" in host_agent.smart_failure_line(2, banner, "scsi hangup")
def test_smart_output_parser_returns_all_none_for_output_it_cannot_read() -> None:
junk = ("smartctl 7.5 2025-04-30 r5714 (local build)\n"
"Smartctl open device: /dev/sdb failed: Permission denied\n")
parsed = host_agent.parse_smart_output(junk)
assert set(parsed) == set(host_agent.SMART_FIELDS)
assert all(value is None for value in parsed.values()), "a denied read is n/a, never 0"
for empty in (None, ""):
assert all(value is None for value in host_agent.parse_smart_output(empty).values())
def test_smart_output_parser_refuses_to_decode_vendor_hex_packed_raws() -> None:
"""Some vendors pack temperature into a hex raw whose byte layout is drive-specific.
Decoding it from memory would be an invented number, so it stays None."""
parsed = host_agent.parse_smart_output(
"194 Temperature_Celsius 0x0022 073 059 000 Old_age ALWAYS - 0x001f003c3430\n")
assert parsed["temp_c"] is None
def test_smart_attribute_rows_are_read_from_the_raw_column_not_the_line_end() -> None:
"""Every shape the deployed binary writes into RAW_VALUE, captured off real drives.
Reading the last token of the row — the obvious way to read a column table — loses the
temperature of every spinning disk on driveripper, because a Seagate ends its row with
`35 (Min/Max 34/44)` and the token there is `44)`. A Western Digital packs its hours as
`43894h+43m+32.519s`, which is hours first, minutes and seconds after: the drive's own
number is the leading group, and the value the page prints is that number."""
parsed = host_agent.parse_smart_output(
"190 Airflow_Temperature_Cel 0x0022 065 049 040 Old_age Always"
" In_the_past 35 (Min/Max 34/44)\n"
"194 Temperature_Celsius 0x0022 035 046 000 Old_age Always"
" - 35 (0 13 0 0 0)\n"
" 9 Power_On_Hours 0x0032 055 055 000 Old_age Always"
" - 43894h+43m+32.519s\n"
"197 Current_Pending_Sector 0x0012 100 100 000 Old_age Always"
" - 2 (of 4 failed when written)\n")
assert parsed["temp_c"] == 35.0
assert parsed["power_on_hours"] == 43894.0
assert parsed["current_pending_sector_ct"] == 2.0
def test_the_real_drives_answer_every_number_the_disks_page_prints() -> None:
"""`smartctl_driveripper_*.txt` is hardware truth, 16 drives, both shapes.
The values move on every re-capture, so this pins that each number `/disks` prints is
there and plausible rather than a particular figure — the check that a fleet which looks
healthy in a synthetic fixture is also healthy when read from a real device. A drive that
stops answering health fails here, and the honest response is to replace the drive or
re-capture, never to loosen the assertion."""
real = sorted(FIXTURES.glob("smartctl_driveripper_*.txt"))
assert real, "no real captures on disk: SMART_HOSTS=<alias> ./scripts/capture-fixtures.sh"
for path in real:
parsed = host_agent.parse_smart_output(read_fixture(path.read_text()))
assert parsed["model"], f"{path.name}: no identity"
hours = parsed["power_on_hours"]
assert hours is not None and hours > 0 and hours == int(hours), (
f"{path.name}: power-on hours read as {hours!r}")
temp = parsed["temp_c"]
assert temp is not None and 10 <= temp <= 85, (
f"{path.name}: temperature read as {temp!r}")
# The two shapes disagree the way the page renders them: no ATA drive on this fleet
# publishes a wear percentage, every NVMe does, and neither answer is a 0.
if "_sd" in path.name:
assert parsed["percentage_used"] is None, f"{path.name}: an HDD has no wear percent"
assert parsed["reallocated_sector_ct"] is not None, f"{path.name}: no ATA counters"
else:
assert parsed["percentage_used"] is not None, f"{path.name}: an NVMe must have wear"
assert parsed["offline_uncorrectable"] is not None, (
f"{path.name}: the media-error log is an NVMe's uncorrectable fact")
def make_fake_smartctl(tmp_path: Path, outputs: dict[str, str],
exit_codes: dict[str, int] | None = None,
slow: tuple[str, ...] = ()) -> str:
"""A stdlib fake `smartctl`: per-device stdout/exit code, plus an audit log of which
devices were asked — so 'virtual disks are skipped' is an assertion, not a hope."""
data = tmp_path / "smart-out"
data.mkdir(exist_ok=True)
for device, text in outputs.items():
(data / device).write_text(text)
for device, code in (exit_codes or {}).items():
(data / f"{device}.rc").write_text(str(code))
script = tmp_path / "bin" / "smartctl"
script.parent.mkdir(exist_ok=True)
script.write_text(f"""#!{sys.executable}
import os, sys, time
device = sys.argv[-1].rsplit("/", 1)[-1]
data = os.environ["FAKE_SMARTCTL_DIR"]
with open(os.path.join(data, "_called"), "a") as handle:
handle.write(device + "\\n")
if device in {list(slow)!r}:
time.sleep(30)
rc_path = os.path.join(data, device + ".rc")
code = int(open(rc_path).read()) if os.path.exists(rc_path) else 0
out_path = os.path.join(data, device)
text = open(out_path).read() if os.path.exists(out_path) else ""
if code and not text:
# real smartctl shape: banner first, failure later — the error note must quote
# the failure, never the banner
text = (f"smartctl 7.5 2025-04-30 r5714 [x86_64-linux] (local build)\\n"
f"Copyright (C) 2002-25, Bruce Allen, Christian Franke\\n\\n"
f"Smartctl open device: /dev/{{device}} failed: Permission denied\\n")
sys.stdout.write(text)
sys.exit(code)
""")
script.chmod(0o755)
return str(script)
def write_fake_disk(root: Path, name: str, model: str | None = None) -> None:
write_file(root, f"sys/block/{name}/stat", "1 2 3 4 5 6 7 8 9 10 11")
write_file(root, f"sys/block/{name}/queue/rotational", "0\n")
write_file(root, f"sys/block/{name}/size", "2048\n")
if model:
write_file(root, f"sys/block/{name}/device/model", f"{model}\n")
#: 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", "resolver")
def test_the_addon_error_prefixes_are_the_same_list_the_dashboard_filters() -> None:
"""One list, two readers. The agent writes `smart:` / `haproxy:` / `nginx:` lines, this suite
filters them to decide a snapshot is otherwise clean, and `collectors.py` filters the same
prefixes to decide whether the **system** payload is `degraded` — which is what an overview
alert is built from. A new add-on section that forgets the prefix on the dashboard side puts
a WARN on every host that never installed it (invariant 3), and this is the test that makes
that a red build instead of a fleet-wide false alarm.
"""
assert collectors.ADDON_ERROR_PREFIXES == ADDON_ERROR_PREFIXES
def core_errors(snapshot: dict[str, Any]) -> list[str]:
"""`errors` minus the add-ons' own "not here" lines: the failures that matter to a test."""
return [line for line in snapshot["errors"]
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 []
def test_smart_pass_attaches_parsed_health_per_disk(tmp_path, monkeypatch) -> None:
root = tmp_path / "root"
write_fake_disk(root, "nvme0n1", model="SYSFS nvme0n1")
write_fake_disk(root, "sdb", model="SYSFS sdb")
write_fake_disk(root, "loop0") # virtual: must never be asked
write_fake_disk(root, "dm-0")
monkeypatch.setenv("FAKE_SMARTCTL_DIR", str(tmp_path / "smart-out"))
collector = host_agent.Collector(str(root),
smartctl=make_fake_smartctl(tmp_path, {
"nvme0n1": read_fixture(NVME_CAPTURE),
"sdb": read_fixture(ATA_HDD_CAPTURE),
}))
snap = collector.snapshot()
assert core_errors(snap) == [], snap["errors"]
by_name = {device["name"]: device for device in snap["disk_io"]["devices"]}
assert by_name["nvme0n1"]["smart"]["model"] == "EXAMPLE NVMe SSD 2TB"
assert by_name["nvme0n1"]["smart"]["power_on_hours"] == 8760.0
assert by_name["sdb"]["smart"]["reallocated_sector_ct"] == 8.0
called = fake_calls(tmp_path)
assert "loop0" not in called and "dm-0" not in called, "virtual entries are not physical disks"
assert sorted(called) == ["nvme0n1", "sdb"]
def test_smart_identity_falls_back_to_the_agents_own_disk_model(tmp_path, monkeypatch) -> None:
"""Some bridges refuse the identity block while still answering the health log."""
root = tmp_path / "root"
write_fake_disk(root, "nvme0n1", model="SYSFS DISK MODEL")
monkeypatch.setenv("FAKE_SMARTCTL_DIR", str(tmp_path / "smart-out"))
collector = host_agent.Collector(
str(root),
smartctl=make_fake_smartctl(tmp_path, {"nvme0n1": "Power On Hours: 42\n"}))
device = collector.snapshot()["disk_io"]["devices"][0]
assert device["smart"]["model"] == "SYSFS DISK MODEL"
assert device["smart"]["power_on_hours"] == 42.0
assert device["smart"]["overall_health"] is None
def test_missing_smartctl_binary_is_one_error_for_the_whole_pass(tmp_path, monkeypatch) -> None:
root = tmp_path / "root"
for device in ("nvme0n1", "sdb"):
write_fake_disk(root, device)
monkeypatch.setenv("FAKE_SMARTCTL_DIR", str(tmp_path / "smart-out"))
collector = host_agent.Collector(str(root), smartctl="/nonexistent/smartctl")
snap = collector.snapshot()
smart_errors = [e for e in snap["errors"] if e.startswith("smart:")]
assert len(smart_errors) == 1, "one missing binary is one fact, not one per disk"
assert "smartmontools" in smart_errors[0]
# cross-module contract: `sources/disks.py` reads this word to answer
# "is smartctl installed?" (three-way: yes / no / the agent does not say). Reword the
# line here and that test fails, instead of the /disks page losing its explanation.
assert "not found" in smart_errors[0]
assert all(device["smart"] is None for device in snap["disk_io"]["devices"])
# the rest of the snapshot is intact — SMART is an add-on, not a dependency
assert snap["disk_io"]["device_names"] == ["nvme0n1", "sdb"]
def test_one_denied_device_nulls_only_that_disk(tmp_path, monkeypatch) -> None:
root = tmp_path / "root"
write_fake_disk(root, "nvme0n1")
write_fake_disk(root, "sdb")
monkeypatch.setenv("FAKE_SMARTCTL_DIR", str(tmp_path / "smart-out"))
collector = host_agent.Collector(
str(root),
smartctl=make_fake_smartctl(tmp_path, {"nvme0n1": read_fixture(NVME_CAPTURE)},
exit_codes={"sdb": 2}))
snap = collector.snapshot()
by_name = {device["name"]: device for device in snap["disk_io"]["devices"]}
assert by_name["nvme0n1"]["smart"] is not None
assert by_name["sdb"]["smart"] is None
smart_errors = [e for e in snap["errors"] if e.startswith("smart:")]
assert len(smart_errors) == 1 and "sdb" in smart_errors[0] and "Permission denied" in smart_errors[0]
def test_a_wedged_device_costs_its_timeout_not_the_snapshot(tmp_path, monkeypatch) -> None:
root = tmp_path / "root"
write_fake_disk(root, "nvme0n1")
write_fake_disk(root, "sdb")
monkeypatch.setenv("FAKE_SMARTCTL_DIR", str(tmp_path / "smart-out"))
collector = host_agent.Collector(
str(root),
smartctl=make_fake_smartctl(tmp_path, {"nvme0n1": read_fixture(NVME_CAPTURE)},
slow=("sdb",)))
collector.smart_timeout_seconds = 0.5
snap = collector.snapshot()
by_name = {device["name"]: device for device in snap["disk_io"]["devices"]}
assert by_name["nvme0n1"]["smart"] is not None
assert by_name["sdb"]["smart"] is None
assert any("timed out" in e for e in snap["errors"])
def test_the_pass_budget_skips_the_rest_with_one_error(tmp_path, monkeypatch) -> None:
root = tmp_path / "root"
for device in ("nvme0n1", "sdb", "sdc"):
write_fake_disk(root, device)
monkeypatch.setenv("FAKE_SMARTCTL_DIR", str(tmp_path / "smart-out"))
collector = host_agent.Collector(
str(root), smartctl=make_fake_smartctl(tmp_path, {}))
collector.smart_budget_seconds = 0.0
snap = collector.snapshot()
assert all(device["smart"] is None for device in snap["disk_io"]["devices"])
assert any("budget" in e and "3 disk(s) skipped" in e for e in snap["errors"])
class _TimeProxy:
"""The agent's `time` module with one instrumented function: `monotonic`.
Everything else (`time()`, `sleep()`) stays the stdlib one, so patching the clock cannot
leak into the rest of the snapshot or into pytest.
"""
def __init__(self, clock: FakeSmartPassClock) -> None:
self._clock = clock
def monotonic(self) -> float:
return self._clock.now
def __getattr__(self, name: str) -> Any:
"""Everything the agent's `time` module offers besides `monotonic`, unchanged."""
return getattr(time, name)
class FakeSmartPassClock:
"""A clock plus a `smartctl` spawn the test drives, so a **deadline** is assertable.
The budget arithmetic in `_smart_pass` is invisible to a fake binary: `subprocess.run`'s
`timeout` never reaches the child, and burning 80 real seconds to watch a 16-disk pass
truncate is not a measurement anybody keeps. So this drives the two things the pass
actually reads — `time.monotonic()` and `subprocess.run()` — and records the timeout each
device was given, which is `min(deadline - now, 5 s)`: the pass's own view of how much
budget was left when it asked. Both patches go on `host_agent`'s own module globals
through proxies, never onto the stdlib modules themselves.
"""
def __init__(self, *, seconds_per_device: float, start: float = 10_000.0) -> None:
self.seconds_per_device = seconds_per_device
self.now = start
self.calls: list[tuple[str, float]] = []
self.output = read_fixture(NVME_CAPTURE)
def run(self, argv: list[str], **kwargs: Any) -> subprocess.CompletedProcess[str]:
device = str(argv[-1]).rsplit("/", 1)[-1]
timeout = float(kwargs["timeout"])
self.calls.append((device, timeout))
self.now += self.seconds_per_device
return subprocess.CompletedProcess(argv, 0, self.output, "")
def install(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(host_agent, "time", _TimeProxy(self))
monkeypatch.setattr(host_agent, "subprocess", SimpleNamespace(
run=self.run,
TimeoutExpired=subprocess.TimeoutExpired,
CompletedProcess=subprocess.CompletedProcess))
# driveripper's real inventory: 8 NVMe + 8 spinning, which is the shape the budget scales for
SHELF_DISKS = [f"nvme{i}n1" for i in range(8)] + [f"sd{c}" for c in "abcdefgh"]
def write_disk_shelf(root: Path, names: list[str]) -> None:
for name in names:
write_fake_disk(root, name, model=f"SYSFS {name}")
def test_the_pass_budget_scales_with_the_disk_count(tmp_path, monkeypatch) -> None:
"""A 16-disk pass gets 5 s x 16, not the 20 s floor — because a truncated pass nulls the
tail drives on every single poll (the bug that put this on the operator's screen on
2026-10-19). The clock here burns 1.5 s per device, so the pass costs 24 s of budget:
over the floor, far under the scale. With the flat budget this test truncates.
"""
root = tmp_path / "root"
write_disk_shelf(root, SHELF_DISKS)
clock = FakeSmartPassClock(seconds_per_device=1.5)
clock.install(monkeypatch)
collector = host_agent.Collector(str(root))
snap = collector.snapshot()
asked = [name for name, _ in clock.calls]
assert asked == snap["disk_io"]["device_names"], "every disk got its smartctl run"
assert len(asked) == 16
assert clock.seconds_per_device * len(asked) > host_agent.SMARTCTL_BUDGET_SECONDS, (
"this pass costs more than the 20 s floor — that is what makes it a scaling test")
assert [e for e in snap["errors"] if "budget" in e] == [], snap["errors"]
assert all(device["smart"] is not None for device in snap["disk_io"]["devices"])
# the *late* devices still hold the whole per-device bound, which is the point of scaling
assert clock.calls[-1][1] == host_agent.SMARTCTL_TIMEOUT_SECONDS
assert clock.calls[0][1] == host_agent.SMARTCTL_TIMEOUT_SECONDS
def test_the_pass_budget_floor_is_twenty_seconds(tmp_path, monkeypatch) -> None:
"""Scaling is `max(20 s, 5 s x disks)`, so one or two disks still get the 20 s floor and
not a 5 s or 10 s window. The second device's timeout is the visible half: with a 20 s
floor it is `min(20 - 18, 5)` = 2 s, where a budget of 5 s x 2 would have skipped it and a
30 s floor would still have handed out the full 5 s.
"""
root = tmp_path / "root"
write_disk_shelf(root, ["nvme0n1", "sda"])
clock = FakeSmartPassClock(seconds_per_device=18.0)
clock.install(monkeypatch)
collector = host_agent.Collector(str(root))
snap = collector.snapshot()
assert [name for name, _ in clock.calls] == ["nvme0n1", "sda"]
assert clock.calls[0][1] == host_agent.SMARTCTL_TIMEOUT_SECONDS
assert clock.calls[1][1] == pytest.approx(
host_agent.SMARTCTL_BUDGET_SECONDS - clock.seconds_per_device)
assert [e for e in snap["errors"] if "budget" in e] == []
assert all(device["smart"] is not None for device in snap["disk_io"]["devices"])
def test_the_pass_budget_error_line_names_the_scaled_figure(tmp_path, monkeypatch) -> None:
"""When a scaled pass does run out, the operator's one line says *which* budget ran out:
80 s on a 16-disk shelf, never the 20 s floor. A test that asserted only "a budget error
appears" would pass on the pre-scaling code; this one cannot.
"""
root = tmp_path / "root"
write_disk_shelf(root, SHELF_DISKS)
clock = FakeSmartPassClock(seconds_per_device=6.0)
clock.install(monkeypatch)
collector = host_agent.Collector(str(root))
snap = collector.snapshot()
scaled = max(host_agent.SMARTCTL_BUDGET_SECONDS,
host_agent.SMARTCTL_TIMEOUT_SECONDS * len(SHELF_DISKS))
assert scaled == 80.0
# 6 s per device: devices 1-14 fit inside 80 s (78 s spent), the 15th finds the deadline
# gone, and its sibling is skipped with it.
assert len(clock.calls) == 14, [name for name, _ in clock.calls]
assert clock.calls[-1][1] == pytest.approx(scaled - 6.0 * 13)
budget_lines = [e for e in snap["errors"] if "budget" in e]
assert len(budget_lines) == 1, snap["errors"]
assert budget_lines[0] == (f"smart: pass budget of {scaled:.0f}s exhausted, "
f"2 disk(s) skipped")
assert f"{host_agent.SMARTCTL_BUDGET_SECONDS:.0f}s" not in budget_lines[0]
by_name = {device["name"]: device for device in snap["disk_io"]["devices"]}
assert [by_name[name]["smart"] is None for name in SHELF_DISKS[:14]] == [False] * 14
assert [by_name[name]["smart"] is None for name in SHELF_DISKS[14:]] == [True, True]
def test_unexecutable_smartctl_degrades_like_a_missing_one(tmp_path, monkeypatch) -> None:
root = tmp_path / "root"
write_fake_disk(root, "nvme0n1")
monkeypatch.setenv("FAKE_SMARTCTL_DIR", str(tmp_path / "smart-out"))
blocked = tmp_path / "not-executable"
blocked.write_text("not a program\n")
collector = host_agent.Collector(str(root), smartctl=str(blocked))
snap = collector.snapshot()
assert all(device["smart"] is None for device in snap["disk_io"]["devices"])
assert any(e.startswith("smart:") for e in snap["errors"])
def wait_for_calls(tmp_path, expected: int, *, timeout: float = 5.0) -> list[str]:
"""Wait for the background refresher to have run `expected` passes."""
deadline = time.time() + timeout
while time.time() < deadline:
calls = fake_calls(tmp_path)
if len(calls) >= expected:
return calls
time.sleep(0.02)
return fake_calls(tmp_path)
def test_smart_results_are_cached_and_an_expired_cache_refreshes_off_the_request_path(
tmp_path, monkeypatch) -> None:
"""The system page polls every few seconds; smartctl must not re-run every poll.
And when the cache *does* expire, the refresh runs on a background thread while the
snapshot answers from the previous result: `SMARTCTL_BUDGET_SECONDS` (20) is five
times the dashboard's `HOST_TIMEOUT_SECONDS` (4), so an inline refresh would turn the
whole `system` source red every four minutes because of an add-on it does not use.
"""
root = tmp_path / "root"
write_fake_disk(root, "nvme0n1")
monkeypatch.setenv("FAKE_SMARTCTL_DIR", str(tmp_path / "smart-out"))
collector = host_agent.Collector(
str(root), smartctl=make_fake_smartctl(tmp_path, {"nvme0n1": read_fixture(NVME_CAPTURE)}))
first = collector.snapshot()
second = collector.snapshot()
assert first["disk_io"]["devices"][0]["smart"]["temp_c"] == 38.0
assert second["disk_io"]["devices"][0]["smart"] == first["disk_io"]["devices"][0]["smart"]
assert fake_calls(tmp_path) == ["nvme0n1"], "one pass, then the cache"
collector.smart_cache_seconds = 0.0 # an expired cache re-runs the pass…
served = collector.snapshot()
assert served["disk_io"]["devices"][0]["smart"] == first["disk_io"]["devices"][0]["smart"]
assert wait_for_calls(tmp_path, 2) == ["nvme0n1", "nvme0n1"], "…in the background"
def test_an_expired_cache_never_stalls_the_snapshot_on_a_wedged_disk(tmp_path,
monkeypatch) -> None:
"""The regression that matters: a disk that takes 30 s must cost a poll nothing.
Asserted on wall-clock because the whole point is the request's latency — the agent
answers with the last SMART it had while the refresher sits in `smartctl`'s timeout.
"""
root = tmp_path / "root"
write_fake_disk(root, "nvme0n1")
write_fake_disk(root, "sdb")
monkeypatch.setenv("FAKE_SMARTCTL_DIR", str(tmp_path / "smart-out"))
collector = host_agent.Collector(
str(root),
smartctl=make_fake_smartctl(tmp_path, {"nvme0n1": read_fixture(NVME_CAPTURE),
"sdb": "Power On Hours: 1\n"},
slow=("sdb",)))
collector.smart_timeout_seconds = 1.0 # the slow device costs ~1 s per pass
first = collector.snapshot()
assert any("timed out" in line for line in first["errors"])
temp = first["disk_io"]["devices"][0]["smart"]["temp_c"]
collector.smart_cache_seconds = 0.0
started = time.perf_counter()
served = collector.snapshot()
elapsed = time.perf_counter() - started
assert served["disk_io"]["devices"][0]["smart"]["temp_c"] == temp
assert elapsed < 0.5, f"a wedged disk cost the snapshot {elapsed:.2f}s"
# the refresher is single-flight: a second poll while it runs gets the cache too
started = time.perf_counter()
collector.snapshot()
assert time.perf_counter() - started < 0.5
wait_for_calls(tmp_path, 4) # let the background pass finish before teardown
def test_no_smart_flag_stays_silent_and_null(tmp_path, monkeypatch) -> None:
root = tmp_path / "root"
write_fake_disk(root, "nvme0n1")
monkeypatch.setenv("FAKE_SMARTCTL_DIR", str(tmp_path / "smart-out"))
collector = host_agent.Collector(str(root),
smartctl=make_fake_smartctl(tmp_path, {}), smart=False)
snap = collector.snapshot()
assert core_errors(snap) == []
assert snap["disk_io"]["devices"][0]["smart"] is None
assert fake_calls(tmp_path) == []
def test_an_unexpected_smart_crash_degrades_the_addon_not_the_snapshot(tmp_path, monkeypatch) -> None:
collector = host_agent.Collector("/", smart=False)
monkeypatch.setattr(collector, "smart_enabled", True)
monkeypatch.setattr(collector, "smart",
lambda names: (_ for _ in ()).throw(RuntimeError("boom")))
snap = collector.snapshot()
assert any(e.startswith("smart:") and "boom" in e for e in snap["errors"])
assert snap["disk_io"]["devices"], "the disk rows themselves come from /sys, not smartctl"
def test_smartctl_captures_carry_no_unscrubbed_serials() -> None:
"""The capture script scrubs; this fails the day it stops doing its job. The NVMe
namespace EUI-64 is on the list because it embeds the serial."""
for path in sorted(FIXTURES.glob("smartctl_*.txt")):
for line in path.read_text().splitlines():
lowered = line.lower()
if "serial number" in lowered or "wwn" in lowered or "eui" in lowered:
assert "SCRUBBED" in line, f"{path.name}: unscrubbed identifier: {line!r}"
# ---- haproxy config: the additive `haproxy` section (phase 28) --------------- #
#
# The text below is this fleet's gateway config as read (read-only) from
# `gateway:/etc/haproxy/haproxy.cfg` on 2026-10-21: `global` and `defaults`, then six
# `frontend` blocks of exactly `bind :::<port> v4v6` + `default_backend <name>`, then six
# `backend` blocks of exactly `server <name> <domain>:<port> check inter 5s`. The structure, the
# indentation and the option spelling are verbatim; the service names and domains are synthetic,
# which is the `smartctl_reference_*` precedent — the shape is what the parser has to match, and
# a tracked file holding this fleet's whole ingress map is a disclosure the parser does not need.
# `tests/fixtures/gateway_haproxy.cfg` is the **verbatim** capture of that file (read-only over
# the `gateway-ops` key, 2026-10-21, no credential in it because haproxy configs hold none), and
# `test_the_recorded_gateway_haproxy_config_parses_too` pins the real service names against it.
# `scripts/capture-gateway-config.sh` (task 04) re-records the same bytes.
GATEWAY_HAPROXY_SHAPE = """global
log /dev/log local0
maxconn 4096
defaults
mode tcp
timeout connect 5s
timeout client 30s
timeout server 30s
frontend haproxy-22
bind :::22 v4v6
default_backend svc-2222
frontend haproxy-2000
bind :::2000 v4v6
default_backend svc-2000
frontend haproxy-5201
bind :::5201 v4v6
default_backend svc-5201
frontend haproxy-8080
bind :::8080 v4v6
default_backend svc-8080
frontend haproxy-25565
bind :::25565 v4v6
default_backend svc-25565
frontend haproxy-25566
bind :::25566 v4v6
default_backend svc-25566
backend svc-2222
server svc2222 svc2222.example.com:2222 check inter 5s
backend svc-2000
server svc2000 svc2000.example.com:2000 check inter 5s
backend svc-5201
server svc5201 svc5201.example.com:5201 check inter 5s
backend svc-8080
server svc8080 svc8080.example.com:8080 check inter 5s
backend svc-25565
server svc25565 svc25565.example.com:25565 check inter 5s
backend svc-25566
server svc25566 svc25566.example.com:25566 check inter 5s
"""
def haproxy_section(tmp_path: Path, text: str | None = None, **kwargs: Any) -> dict[str, Any]:
"""One collector's `haproxy` section over a tree that holds (or does not) a config."""
if text is not None:
write_file(tmp_path / "root", "etc/haproxy/haproxy.cfg", text)
errors: list[str] = []
section = host_agent.Collector(str(tmp_path / "root"), **kwargs).haproxy(errors)
section["_errors"] = errors # the pass writes into the caller's list; tests read both
return section
def test_the_captured_haproxy_shape_parses_into_frontends_and_backends() -> None:
"""The whole point of the section: six listening pipes and six targets, as the config says."""
parsed = host_agent.parse_haproxy_config(GATEWAY_HAPROXY_SHAPE)
assert parsed["recognised"] is True
assert [frontend["name"] for frontend in parsed["frontends"]] == [
"haproxy-22", "haproxy-2000", "haproxy-5201", "haproxy-8080",
"haproxy-25565", "haproxy-25566"]
# the split the dashboard's whole page depends on: `:::22` is the address `::` on port 22,
# not the address `:::` and not port `0` (an IPv6 bind is the common case on this host)
assert [frontend["binds"] for frontend in parsed["frontends"]] == [
[{"addr": "::", "port": port}] for port in (22, 2000, 5201, 8080, 25565, 25566)]
assert [frontend["default_backend"] for frontend in parsed["frontends"]] == [
"svc-2222", "svc-2000", "svc-5201", "svc-8080", "svc-25565", "svc-25566"]
# no line carries the helper's marker, so none is off (the flag is present, not inferred)
assert [backend["servers"] for backend in parsed["backends"]] == [
[{"name": f"svc{port}", "target": f"svc{port}.example.com", "port": port,
"disabled": False}]
for port in (2222, 2000, 5201, 8080, 25565, 25566)]
# routing is the names themselves, and the config joins: every frontend's default_backend is
# one of the backends listed beside it (phase 29 draws its pipes from exactly this)
names = {backend["name"] for backend in parsed["backends"]}
assert {frontend["default_backend"] for frontend in parsed["frontends"]} <= names
# `global`/`defaults` are a section each, not a frontend and not a backend
assert len(parsed["backends"]) == 6
def test_the_recorded_gateway_haproxy_config_parses_too() -> None:
"""The real file, pinned: the six pipes the gateway actually routes on, and the six targets
they land in. A re-capture that drifts — a frontend removed, a bind rewritten, a `server`
line re-flowed — fails here before it fails phase 29's page."""
parsed = host_agent.parse_haproxy_config((FIXTURES / "gateway_haproxy.cfg").read_text())
assert parsed["recognised"] is True
assert [frontend["name"] for frontend in parsed["frontends"]] == [
"haproxy-22", "haproxy-2000", "haproxy-5201", "haproxy-8080",
"haproxy-25565", "haproxy-25566"]
assert [frontend["binds"] for frontend in parsed["frontends"]] == [
[{"addr": "::", "port": port}] for port in (22, 2000, 5201, 8080, 25565, 25566)]
assert [frontend["default_backend"] for frontend in parsed["frontends"]] == [
"gitea-2222", "sec-gitea-2000", "iperf3-5201", "unifi-external-8080",
"testcraft-25565", "nimcraft-25566"]
assert [(backend["name"], backend["servers"]) for backend in parsed["backends"]][:2] == [
("gitea-2222", [{"name": "gitea", "target": "reeseapps-gitea.reeselink.com",
"port": 2222, "disabled": False}]),
("sec-gitea-2000", [{"name": "sec-gitea",
"target": "stackexpected-gitea.reeselink.com",
"port": 2222, "disabled": False}]),
]
# every frontend's target exists beside it — the join phase 29 draws its pipes from
names = {backend["name"] for backend in parsed["backends"]}
assert {frontend["default_backend"] for frontend in parsed["frontends"]} <= names
for backend in parsed["backends"]:
for server in backend["servers"]:
assert server["name"] and server["target"] and server["port"] is not None
def test_a_bind_splits_at_the_last_colon_and_one_without_a_port_is_skipped() -> None:
parsed = host_agent.parse_haproxy_config(
"frontend a\n"
" bind :::8443 v4v6\n"
" bind *:80 ssl crt /etc/haproxy/certs/example.com.pem\n"
" bind :8080\n"
" bind [::1]:9090\n"
" bind unix@/run/haproxy/admin.sock\n"
" bind 10.1.0.1:8000-8100\n"
" bind 127.0.0.1\n"
)
assert parsed["frontends"][0]["binds"] == [
{"addr": "::", "port": 8443},
{"addr": "*", "port": 80},
{"addr": None, "port": 8080}, # haproxy's "every address", spelled by absence
{"addr": "[::1]", "port": 9090},
]
assert parsed["recognised"] is True
def test_a_server_that_names_no_port_keeps_null_and_unknown_lines_are_ignored() -> None:
"""Rule 2 on the one optional field, and the reason a real config cannot amber the page."""
parsed = host_agent.parse_haproxy_config(
"backend bare\n"
" balance roundrobin\n"
" default-server check inter 5s\n"
" server withport 10.1.0.9:8080 check\n"
" server noport 10.1.0.10\n"
" server servicename host.example.com:http check\n"
" server broken\n"
" server-template tpl 4 pool.example.com:8080\n"
" option httpchk GET /healthz\n"
" http-request deny deny_status 403\n"
)
assert parsed["backends"][0]["servers"] == [
{"name": "withport", "target": "10.1.0.9", "port": 8080, "disabled": False},
{"name": "noport", "target": "10.1.0.10", "port": None, "disabled": False},
{"name": "servicename", "target": "host.example.com:http", "port": None,
"disabled": False},
]
# a `backend` body never grows frontend fields, however many directives it holds
assert "binds" not in parsed["backends"][0]
def test_a_listen_section_is_reported_as_the_pair_haproxy_says_it_is() -> None:
"""haproxy's shorthand *is* a frontend and a backend of the same name; modelling it keeps a
config that uses it from reading as "this gateway routes nothing"."""
parsed = host_agent.parse_haproxy_config(
"listen stats\n"
" bind :::8404 v4v6\n"
" stats uri /stats\n"
" server own 127.0.0.1:8405\n"
)
assert parsed["frontends"] == [{"name": "stats", "binds": [{"addr": "::", "port": 8404}],
"default_backend": "stats", "backends": ["stats"]}]
assert parsed["backends"] == [{"name": "stats",
"servers": [{"name": "own", "target": "127.0.0.1",
"port": 8405, "disabled": False}]}]
def test_a_backend_computed_per_request_contributes_no_name() -> None:
"""`use_backend %[req.hdr(host),map_dom(...)]` has no name in it, and putting the expression
where the dashboard expects a backend would be a wrong answer, not a partial one."""
parsed = host_agent.parse_haproxy_config(
"frontend mapped\n"
" bind :::443 v4v6\n"
" use_backend %[req.hdr(host),lower,map_dom(/etc/haproxy/maps/domains.map)]\n"
" use_backend canary if is_canary\n"
" use_backend canary if is_other_canary\n"
)
assert parsed["frontends"][0]["default_backend"] is None
assert parsed["frontends"][0]["backends"] == ["canary"]
def test_a_haproxy_config_path_is_an_argument(tmp_path: Path) -> None:
"""The path is a flag because a package or a config management tree may keep it elsewhere;
it resolves under `--root`, so the fake-tree tests read the fake tree."""
write_file(tmp_path / "root", "opt/haproxy/etc/haproxy.cfg",
"frontend f\n bind :::1234 v4v6\n")
errors: list[str] = []
section = host_agent.Collector(
str(tmp_path / "root"), haproxy_config="/opt/haproxy/etc/haproxy.cfg").haproxy(errors)
assert section["available"] is True
assert section["config"] == "/opt/haproxy/etc/haproxy.cfg"
assert section["frontends"][0]["binds"] == [{"addr": "::", "port": 1234}]
assert errors == []
def test_a_relative_haproxy_config_path_is_taken_as_written(tmp_path: Path, monkeypatch) -> None:
"""`--smartctl` and haproxy's own `-f` both resolve a relative path against the working
directory, so this one does too — only the absolute default is read under `--root`."""
(tmp_path / "haproxy.cfg").write_text("frontend f\n bind :::7 v4v6\n")
monkeypatch.chdir(tmp_path)
errors: list[str] = []
section = host_agent.Collector("/", haproxy_config="haproxy.cfg").haproxy(errors)
assert section["available"] is True
assert section["frontends"][0]["binds"] == [{"addr": "::", "port": 7}]
assert errors == []
def test_a_missing_haproxy_config_is_available_false_with_one_errors_line(tmp_path: Path) -> None:
"""A host without haproxy is unconfigured, not broken: one line for the pass, and the other
sections still there."""
section = haproxy_section(tmp_path)
assert section["available"] is False
assert section["config"] == host_agent.HAPROXY_CONFIG_PATH
assert section["frontends"] == [] and section["backends"] == []
haproxy_errors = [line for line in section["_errors"] if line.startswith("haproxy:")]
assert len(haproxy_errors) == 1, "one missing file is one fact, not one per section"
assert "not found" in haproxy_errors[0]
# cross-module contract, the `smartctl: not found` pin's shape: the phase-29 gateway source
# reads this word to answer "is haproxy installed?", and `collectors` keeps the line out of
# the system page's `degraded`. Reword it here and that test fails instead of a page
# losing its explanation — and the line must name the path it looked at.
assert host_agent.HAPROXY_CONFIG_PATH in haproxy_errors[0]
assert "--no-proxy" in haproxy_errors[0], "the line has to say how to stop asking"
def test_the_haproxy_pass_says_nothing_when_the_config_reads_cleanly(tmp_path: Path) -> None:
section = haproxy_section(tmp_path, GATEWAY_HAPROXY_SHAPE)
assert section["available"] is True
assert section["_errors"] == [], "a read that worked writes no line"
assert len(section["frontends"]) == 6
@pytest.mark.skipif(hasattr(os, "geteuid") and os.geteuid() == 0,
reason="root reads any mode, so a 000 file is unreadable only below it")
def test_an_unreadable_haproxy_config_is_available_null_with_one_errors_line(
tmp_path: Path) -> None:
"""The file is there and the agent user cannot read it — a different operator action from
"no haproxy here", so a different `available` and a different sentence."""
path = tmp_path / "root" / "etc" / "haproxy" / "haproxy.cfg"
path.parent.mkdir(parents=True)
path.write_text(GATEWAY_HAPROXY_SHAPE)
path.chmod(0o000)
try:
section = haproxy_section(tmp_path)
finally:
path.chmod(0o644)
assert section["available"] is None
assert len(section["_errors"]) == 1 and section["_errors"][0].startswith("haproxy:")
assert "could not be read" in section["_errors"][0]
def test_a_directory_where_the_config_should_be_is_available_null(tmp_path: Path) -> None:
"""Root-proof arm of the same fact: the path exists and is not a readable file."""
(tmp_path / "root" / "etc" / "haproxy" / "haproxy.cfg").mkdir(parents=True)
section = haproxy_section(tmp_path)
assert section["available"] is None
assert len(section["_errors"]) == 1 and "could not be read" in section["_errors"][0]
def test_an_oversized_haproxy_config_is_refused_rather_than_read(
tmp_path: Path, monkeypatch) -> None:
"""The bound exists so a `--haproxy-config` aimed at a log or a device node cannot cost the
snapshot an unbounded read."""
monkeypatch.setattr(host_agent, "HAPROXY_CONFIG_MAX_BYTES", 64)
section = haproxy_section(tmp_path, "frontend f\n" + " bind :::1 v4v6\n" * 40)
assert section["available"] is None
assert len(section["_errors"]) == 1 and "could not be read" in section["_errors"][0]
def test_a_config_that_is_not_haproxy_degrades_only_its_own_section(tmp_path: Path) -> None:
"""Garbage text is `available: null` plus one line — and the snapshot it lives in is intact,
which is the invariant-4 half of this add-on."""
root = tmp_path / "root"
write_file(root, "proc/stat",
"cpu 100 0 50 800 0 0 0 0 0 0\ncpu0 100 0 50 800 0 0 0 0 0 0\n")
write_file(root, "proc/meminfo", "MemTotal: 8000000 kB\nMemFree: 1000000 kB\n"
"MemAvailable: 4000000 kB\n")
write_file(root, "etc/haproxy/haproxy.cfg", "\x00\x01garbage\nnot a haproxy config at all\n")
snap = host_agent.Collector(str(root)).snapshot()
assert snap["haproxy"]["available"] is None
assert snap["haproxy"]["frontends"] == []
haproxy_errors = [line for line in snap["errors"] if line.startswith("haproxy:")]
assert len(haproxy_errors) == 1, snap["errors"]
assert "no haproxy section" in haproxy_errors[0]
# the sections that are not this add-on still answered
assert snap["cpu"]["modes"]["total"] > 0
assert snap["memory"]["total_bytes"] == 8000000 * 1024.0
assert core_errors(snap) == [], snap["errors"]
def test_no_proxy_removes_the_haproxy_section_and_says_nothing(tmp_path: Path) -> None:
"""The `--no-smart` precedent: opting out is silent, and "not collected" stays
distinguishable from "collected and empty" because the key is absent."""
root = tmp_path / "root"
write_file(root, "etc/haproxy/haproxy.cfg", GATEWAY_HAPROXY_SHAPE)
snap = host_agent.Collector(str(root), proxy=False).snapshot()
assert "haproxy" not in snap, "--no-proxy removes the key, not just its contents"
assert core_errors(snap) == []
def test_the_no_proxy_flag_reaches_the_collector_from_the_command_line(capsys) -> None:
"""A flag nobody wired is a flag that does nothing: `--once` proves the CLI path, and one
flag covers the whole add-on family (`haproxy`, `nginx` and `sockets` leave together)."""
assert host_agent.main(["--once", "--no-proxy"]) == 0
snap = json.loads(capsys.readouterr().out)
assert snap["ok"] is True
assert "haproxy" not in snap and "nginx" not in snap and "sockets" not in snap
assert host_agent.main(["--once"]) == 0
out = json.loads(capsys.readouterr().out)
assert "haproxy" in out and "nginx" in out and "sockets" in out
def test_the_haproxy_section_is_documented_in_the_schema() -> None:
"""`GET /schema` is how an install documents itself, so the section an operator is looking at
has to be named there with every key it carries — the `tls`/`bundle_rev` precedent."""
assert schema_documents("haproxy")
doc = host_agent.__doc__ or ""
block = doc.split(" haproxy ", 1)[-1].split(" errors ", 1)[0]
assert block, "the haproxy section has no SCHEMA block to document it"
for key in ("available", "config", "frontends", "binds", "addr", "port", "default_backend",
"backends", "servers", "target"):
assert key in block, f"haproxy.{key} missing from the SCHEMA block"
assert "--no-proxy" in block and "--haproxy-config" in block, \
"the two flags that decide whether this section exists belong in its documentation"
# ---- nginx config: the additive `nginx` section (phase 28) ------------------ #
#
# The three texts below are this fleet's gateway nginx tree as read (read-only) from
# `gateway:/etc/nginx/nginx.conf`, `/etc/nginx/conf.d/http.conf` and
# `/etc/nginx/stream.d/stream.conf` on 2026-10-21: a main config whose top level is `events` +
# `stream { include …/stream.d/*.conf; }` + `http { … include …/conf.d/*.conf; }` with one
# catch-all `server` on port 80; one `conf.d` file of `listen 4443 ssl proxy_protocol` /
# `listen 8443 ssl` vhosts each with a `server_name <domain>` and a `location / { proxy_pass
# http://<domain>:<port>; }`; one `stream.d` file with a
# `map $ssl_preread_server_name $targetBackend { … }` block and a `server` that ends in
# `proxy_pass $targetBackend; ssl_preread on;`. The structure, the indentation and the flag
# spelling are verbatim; the domains and targets are the `smartctl_reference_*` precedent — the
# shape is what the parser must match, and this fleet's ingress map is not a tracked file's
# business. `tests/fixtures/gateway_nginx_*.conf` (task 04) is the verbatim capture, and the
# re-record script is `scripts/capture-gateway-config.sh`.
GATEWAY_NGINX_MAIN = """user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log notice;
pid /run/nginx.pid;
events {
worker_connections 1024;
}
stream {
include /etc/nginx/stream.d/*.conf;
}
http {
real_ip_header proxy_protocol;
include /etc/nginx/mime.types;
default_type application/octet-stream;
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 80;
listen [::]:80;
server_name _;
return 301 https://$host$request_uri;
}
include /etc/nginx/conf.d/*.conf;
}
"""
GATEWAY_NGINX_HTTP = """server {
listen 4443 ssl proxy_protocol;
listen [::]:4443 ssl proxy_protocol;
server_name nextcloud.example.com;
client_max_body_size 0;
ssl_certificate /etc/letsencrypt/live/nextcloud.example.com/fullchain.pem;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
location / {
proxy_pass http://nextcloud.internal.test:11000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
server {
listen 8443 ssl;
listen [::]:8443 ssl;
server_name alicia.example.com;
location / {
proxy_pass http://127.0.0.1:8081;
proxy_set_header Host $host;
}
}
"""
GATEWAY_NGINX_STREAM = """map $ssl_preread_server_name $targetBackend {
default 127.0.0.1:4443;
brain.example.com brain-of-reese.internal.test:443;
}
server {
listen 443;
listen [::]:443;
proxy_connect_timeout 1800s;
proxy_timeout 1800s;
proxy_protocol on;
proxy_pass $targetBackend;
ssl_preread on;
resolver 10.1.0.1;
}
"""
def nginx_tree(root: Path, *, main: str | None = GATEWAY_NGINX_MAIN,
http: str | None = GATEWAY_NGINX_HTTP, stream: str | None = GATEWAY_NGINX_STREAM,
extra: dict[str, str] | None = None) -> Path:
"""Write the captured gateway tree (or any part of it) under a fake `--root`."""
for relative, text in (("etc/nginx/nginx.conf", main),
("etc/nginx/conf.d/http.conf", http),
("etc/nginx/stream.d/stream.conf", stream)):
if text is not None:
write_file(root, relative, text)
for relative, text in (extra or {}).items():
write_file(root, relative, text)
return root
def nginx_section(tmp_path: Path, *, tree: bool = True, **kwargs: Any) -> dict[str, Any]:
"""One collector's `nginx` section over a tree that holds (or does not) the configs."""
root = tmp_path / "root"
if tree:
nginx_tree(root)
errors: list[str] = []
section = host_agent.Collector(str(root), **kwargs).nginx(errors)
section["_errors"] = errors # the pass writes into the caller's list; tests read both
return section
def by_variable(maps: list[dict[str, Any]]) -> dict[str, dict[str, Any]]:
"""The `maps` list keyed by variable name, because that is the key the dashboard joins on."""
return {entry["variable"]: entry for entry in maps}
def test_the_captured_nginx_shape_parses_into_servers_maps_and_targets() -> None:
"""The whole point of the section: every pipe nginx listens on and where each one lands."""
parsed = host_agent.parse_nginx_config(GATEWAY_NGINX_MAIN, {
"/etc/nginx/conf.d/http.conf": GATEWAY_NGINX_HTTP,
"/etc/nginx/stream.d/stream.conf": GATEWAY_NGINX_STREAM,
})
assert parsed["recognised"] is True
assert parsed["error"] is None and parsed["unparsed"] == []
# document order: the main config's own server, then stream.d (included first), then conf.d
assert [server["server_names"] for server in parsed["servers"]] == [
["_"], [], ["nextcloud.example.com"], ["alicia.example.com"]]
# `listen` keeps the address the config wrote: a bare `80` has no address (nginx's own "every
# address"), `[::]:4443` is the address `::` and not the string `[::]`, and the `ssl
# proxy_protocol` flags are not this section's business
assert [server["listen"] for server in parsed["servers"]] == [
[{"addr": None, "port": 80}, {"addr": "::", "port": 80}],
[{"addr": None, "port": 443}, {"addr": "::", "port": 443}],
[{"addr": None, "port": 4443}, {"addr": "::", "port": 4443}],
[{"addr": None, "port": 8443}, {"addr": "::", "port": 8443}],
]
assert [server["proxy_passes"] for server in parsed["servers"]] == [
[],
# the stream form: no `location` exists, and the target is a variable, not an address
[{"location": None, "scheme": None, "target": None, "variable": "targetBackend",
"disabled": False}],
[{"location": "/", "scheme": "http", "variable": None,
"target": {"host": "nextcloud.internal.test", "port": 11000}, "disabled": False}],
[{"location": "/", "scheme": "http", "variable": None,
"target": {"host": "127.0.0.1", "port": 8081}, "disabled": False}],
]
maps = by_variable(parsed["maps"])
assert set(maps) == {"connection_upgrade", "targetBackend"}
# the variable name is spelled one way on both sides of the join, and the `$` is nginx's
# syntax rather than part of the name — so it is dropped here once, not in the page
assert maps["targetBackend"]["entries"] == [
{"key": "default", "target": {"host": "127.0.0.1", "port": 4443},
"disabled": False},
{"key": "brain.example.com", "target": {"host": "brain-of-reese.internal.test",
"port": 443}, "disabled": False},
]
# …and the `proxy_pass $targetBackend` above still carries `target: null`: the map decides
# per connection, so merging it here would be a number the wire never published
assert parsed["servers"][1]["proxy_passes"][0]["target"] is None
# `$connection_upgrade`'s values are header words, not hosts: keys reported, targets null
assert maps["connection_upgrade"]["entries"] == [
{"key": "default", "target": None, "disabled": False},
{"key": "", "target": None, "disabled": False}]
# the include lines, as written, in document order — including the one file this tree has no
# opinion about because it is not a config (`mime.types`)
assert parsed["includes"] == ["/etc/nginx/stream.d/*.conf", "/etc/nginx/mime.types",
"/etc/nginx/conf.d/*.conf"]
assert parsed["files"] == ["/etc/nginx/stream.d/stream.conf", "/etc/nginx/conf.d/http.conf"]
def test_the_section_names_the_files_the_numbers_came_from(tmp_path: Path) -> None:
"""`files` is the answer to "which files is this page drawing?", main config first, and it
speaks the config's paths rather than the ones under `--root`."""
section = nginx_section(tmp_path)
assert section["available"] is True
assert section["files"] == ["/etc/nginx/nginx.conf",
"/etc/nginx/stream.d/stream.conf",
"/etc/nginx/conf.d/http.conf"]
assert section["_errors"] == [], "a read that worked writes no line"
assert len(section["servers"]) == 4
assert len(section["maps"]) == 2
def test_a_listen_token_splits_the_three_forms_nginx_writes() -> None:
parsed = host_agent.parse_nginx_config(
"http { server {\n"
" listen 80;\n"
" listen [::]:4443 ssl;\n"
" listen 10.1.0.5:9000;\n"
" listen 8080 default_server;\n"
" listen unix:/run/nginx.sock;\n"
" listen [::1;\n" # a bracket that never closes is not an address
" listen [::1];\n" # an address with no port is not one either
" listen;\n"
" } }"
)
assert parsed["servers"][0]["listen"] == [
{"addr": None, "port": 80},
{"addr": "::", "port": 4443},
{"addr": "10.1.0.5", "port": 9000},
{"addr": None, "port": 8080},
]
def test_a_proxy_pass_reports_the_address_the_variable_or_neither() -> None:
"""Three spellings of the same directive, and the agent never resolves one of them."""
parsed = host_agent.parse_nginx_config(
"http { server {\n"
" location /a/ { proxy_pass http://h1.test:8080/api; }\n"
" location /b/ { proxy_pass http://pool_name; }\n"
" location /c/ { proxy_pass $backend; }\n"
" location /d/ { proxy_pass unix:/run/app.sock; }\n"
" location /e/ { proxy_pass http://host:http; }\n"
" } }"
)
assert parsed["servers"][0]["proxy_passes"] == [
# the trailing path is dropped the way haproxy's `check inter 5s` is: it is not a place
{"location": "/a/", "scheme": "http", "target": {"host": "h1.test", "port": 8080},
"variable": None, "disabled": False},
# an `upstream` name is a host with no port — the name is the fact, the port is not
{"location": "/b/", "scheme": "http", "target": {"host": "pool_name", "port": None},
"variable": None, "disabled": False},
{"location": "/c/", "scheme": None, "target": None, "variable": "backend",
"disabled": False},
# a unix socket is neither a host nor a variable: the line is reported with nulls
{"location": "/d/", "scheme": None, "target": None, "variable": None,
"disabled": False},
# a colon that is not a port either: the scheme is a fact, the host would be a guess
{"location": "/e/", "scheme": "http", "target": None, "variable": None,
"disabled": False},
]
@pytest.mark.parametrize(("text", "why"), [
("http { server { listen 80;", "an opening brace is never closed"),
("http { server { listen 80", "the file ends inside the directive"),
("} http { }", "no opening one before it"),
("http { server { listen 80 } }", "is still open"),
("http { { listen 80; } }", "with no block name"),
('http { add_header X "unclosed; }', "quoted string is never closed"),
])
def test_a_config_nginx_would_refuse_to_start_on_fails_the_scanner(text: str,
why: str) -> None:
"""Six malformed files, one verdict each, and no exception on a snapshot path.
The sentences matter because they are what the section says: this is the arm that answers
"the file is there and I could not read it as nginx", and a scanner that raised instead of
reporting would fail the whole `/snapshot` over one half-written vhost.
"""
parsed = host_agent.parse_nginx_config(text)
assert parsed["error"] is not None and why in parsed["error"], parsed
assert parsed["recognised"] is False and parsed["servers"] == []
assert host_agent.nginx_include_paths(text) == [], "no includes out of a file that is not a tree"
def test_an_upstream_block_keeps_its_server_lines_as_targets() -> None:
"""`server` means two different things in nginx, and the scanner tells them apart."""
parsed = host_agent.parse_nginx_config(
"upstream pool_name {\n"
" server 10.1.0.9:8080 max_fails=3 fail_timeout=30s;\n"
" server backend.test;\n"
" server [::1]:8080;\n"
" keepalive 32;\n"
"}\n"
"http { server { listen 80; } }"
)
assert parsed["upstreams"] == [{"name": "pool_name", "servers": [
{"host": "10.1.0.9", "port": 8080, "disabled": False},
{"host": "backend.test", "port": None, "disabled": False},
{"host": "::1", "port": 8080, "disabled": False},
]}]
# an upstream's `server` lines are not server blocks, however many of them there are
assert len(parsed["servers"]) == 1
def test_a_quoted_value_holding_a_semicolon_is_one_word() -> None:
"""The real config writes `add_header … "max-age=31536000; includeSubDomains" always;`. A
`;` inside quotes is data, and cutting on it would halve the file's block structure — same
for the `{`, and an escaped quote inside the value stays inside it. A bare `;` with nothing
before it is whitespace, not an empty directive."""
parsed = host_agent.parse_nginx_config(
'http { server { listen 80; ; add_header X "a;b { \\"c\\"" always; return 404; } }'
)
assert parsed["servers"][0]["listen"] == [{"addr": None, "port": 80}]
assert parsed["error"] is None
# the stray `;` did not open an empty directive and the escaped quote did not end the value
assert parsed["recognised"] is True and len(parsed["servers"]) == 1
def test_a_commented_include_line_is_not_an_include() -> None:
"""An operator commented the old vhost file out; following it would report a pipe that is
not there, and reading it would cost a file the config took out of service."""
parsed = host_agent.parse_nginx_config(
"http {\n # include /etc/nginx/conf.d/*.conf;\n server { listen 80; }\n}",
{"/etc/nginx/conf.d/http.conf": 'server { listen 9999; server_name ghost.test; }'},
)
assert parsed["includes"] == [] and parsed["files"] == []
assert [server["listen"] for server in parsed["servers"]] == [[{"addr": None, "port": 80}]]
def test_includes_are_followed_one_level_and_no_deeper(tmp_path: Path) -> None:
"""The captured tree is one level deep. Going deeper would mean reading files the pass was
never bounded for, so an include inside an included file contributes nothing — and no
error, because the config is not wrong, the pass is deliberately shallow."""
root = tmp_path / "root"
nginx_tree(root, stream=None, http=None, extra={
# deliberately outside conf.d, so the main config's own glob cannot be what read it
"etc/nginx/hidden/deep.conf": 'server { listen 7777; server_name deep.test; }\n',
})
write_file(root, "etc/nginx/conf.d/http.conf",
GATEWAY_NGINX_HTTP + "\ninclude /etc/nginx/hidden/deep.conf;\n")
errors: list[str] = []
section = host_agent.Collector(str(root)).nginx(errors)
assert section["available"] is True
assert section["files"] == ["/etc/nginx/nginx.conf", "/etc/nginx/conf.d/http.conf"]
assert 7777 not in [entry["port"] for server in section["servers"] for entry
in server["listen"]], "the second level is not this pass's job"
assert errors == []
def test_a_literal_include_is_read_when_this_host_has_it(tmp_path: Path) -> None:
"""The non-glob arm of the include line. `mime.types` is the captured case: it exists on a
real host, holds a `types { … }` block this section does not model, and costs one file."""
root = tmp_path / "root"
nginx_tree(root, http=None, stream=None, extra={
"etc/nginx/mime.types": "types {\n text/html html htm;\n}\n",
})
errors: list[str] = []
section = host_agent.Collector(str(root)).nginx(errors)
assert section["available"] is True
assert section["files"] == ["/etc/nginx/nginx.conf", "/etc/nginx/mime.types"]
assert section["servers"][0]["listen"] == [{"addr": None, "port": 80},
{"addr": "::", "port": 80}]
assert errors == []
def test_an_include_naming_a_file_this_host_does_not_have_says_nothing(tmp_path: Path) -> None:
"""`include /etc/nginx/mime.types;` is in every main config and in no fake tree. Absence is
not a fault, and a line per absent file would bury the arms that are."""
section = nginx_section(tmp_path)
assert "/etc/nginx/mime.types" not in section["files"]
assert section["_errors"] == [], "absence is the `available: false` arm one level down"
def test_the_include_file_bound_drops_the_excess_with_one_line_naming_the_count(
tmp_path: Path) -> None:
"""The SMART pass-budget shape: cover what you can, say what you could not, keep the section
`available: true` because the config was read."""
root = tmp_path / "root"
nginx_tree(root, http=None, stream=None)
for index in range(10):
write_file(root, f"etc/nginx/conf.d/vhost{index}.conf",
f"server {{ listen {8100 + index}; server_name h{index}.test; }}\n")
errors: list[str] = []
section = host_agent.Collector(str(root)).nginx(errors)
assert section["available"] is True
limit = host_agent.NGINX_INCLUDE_MAX_FILES
assert section["files"] == ["/etc/nginx/nginx.conf"] + [
f"/etc/nginx/conf.d/vhost{index}.conf" for index in range(limit)]
assert len(section["servers"]) == limit + 1 # main's own server plus the 8 that were read
nginx_errors = [line for line in errors if line.startswith("nginx:")]
assert len(nginx_errors) == 1, "one bound is one fact, not one per dropped file"
assert f"{10 - limit} included file(s)" in nginx_errors[0], nginx_errors
assert f"{limit}-file" in nginx_errors[0], "the line names the bound it hit"
def test_the_include_byte_bound_drops_the_file_that_would_cross_it(tmp_path: Path,
monkeypatch) -> None:
"""The other half of the bound: one huge include must not turn a snapshot into a file copy."""
monkeypatch.setattr(host_agent, "NGINX_INCLUDE_MAX_BYTES", 120)
root = tmp_path / "root"
nginx_tree(root, stream=None)
# a padding comment, so the file is still valid nginx: it has to be dropped for its size and
# not because it is the half-written vhost the next test is about
write_file(root, "etc/nginx/conf.d/http.conf", f"# {'x' * 100}\n")
write_file(root, "etc/nginx/conf.d/second.conf", "server { listen 8123; }\n")
errors: list[str] = []
section = host_agent.Collector(str(root)).nginx(errors)
assert section["available"] is True
assert section["files"] == ["/etc/nginx/nginx.conf", "/etc/nginx/conf.d/http.conf"]
nginx_errors = [line for line in errors if line.startswith("nginx:")]
assert len(nginx_errors) == 1 and "1 included file(s)" in nginx_errors[0]
assert "120-byte" in nginx_errors[0], "the line names the bound it hit, in the unit it is set in"
def test_an_oversized_main_config_is_refused_rather_than_read(tmp_path: Path,
monkeypatch) -> None:
"""The bound exists so a `--nginx-config` aimed at a log or a device node cannot cost the
snapshot an unbounded read — the haproxy rule, on the other half of the add-on."""
monkeypatch.setattr(host_agent, "NGINX_CONFIG_MAX_BYTES", 64)
section = nginx_section(tmp_path)
assert section["available"] is None
assert len(section["_errors"]) == 1 and "could not be read" in section["_errors"][0]
@pytest.mark.skipif(hasattr(os, "geteuid") and os.geteuid() == 0,
reason="root reads any mode, so a 000 file is unreadable only below it")
def test_an_unreadable_main_config_is_available_null_with_one_errors_line(
tmp_path: Path) -> None:
"""The file is there and the agent user cannot read it — a different operator action from
"no nginx here", so a different `available` and a different sentence."""
root = tmp_path / "root"
nginx_tree(root)
path = root / "etc" / "nginx" / "nginx.conf"
path.chmod(0o000)
try:
section = nginx_section(tmp_path, tree=False) # do not rewrite the tree over the mode
finally:
path.chmod(0o644)
assert section["available"] is None
assert len(section["_errors"]) == 1 and section["_errors"][0].startswith("nginx:")
assert "could not be read" in section["_errors"][0]
@pytest.mark.skipif(hasattr(os, "geteuid") and os.geteuid() == 0,
reason="root reads any mode, so a 000 file is unreadable only below it")
def test_an_unreadable_included_file_is_reported_once_and_the_rest_still_lands(
tmp_path: Path) -> None:
"""One denied vhost file must not cost the whole section — and must not pass in silence
either, because the page would draw four pipes where nginx has five."""
root = tmp_path / "root"
nginx_tree(root, main=None)
(root / "etc" / "nginx").mkdir(parents=True, exist_ok=True)
write_file(root, "etc/nginx/nginx.conf", "http { include /etc/nginx/conf.d/*.conf; }\n")
write_file(root, "etc/nginx/conf.d/second.conf", "server { listen 8124; }\n")
denied = root / "etc" / "nginx" / "conf.d" / "http.conf"
denied.chmod(0o000)
try:
errors: list[str] = []
section = host_agent.Collector(str(root)).nginx(errors)
finally:
denied.chmod(0o644)
assert section["available"] is True
assert [entry["port"] for server in section["servers"] for entry in server["listen"]] == [8124]
nginx_errors = [line for line in errors if line.startswith("nginx:")]
assert len(nginx_errors) == 1, "one denied file is one line, not one per include line"
assert "could not be read" in nginx_errors[0]
assert "/etc/nginx/conf.d/http.conf" in nginx_errors[0], "it names what it could not read"
def test_an_included_file_that_will_not_parse_is_dropped_and_named(tmp_path: Path) -> None:
"""A half-written vhost file is that file's problem: the section keeps every other server
and says which document it could not read, because the main config still answered."""
root = tmp_path / "root"
nginx_tree(root, stream=None, http="server { listen 4443;\n")
errors: list[str] = []
section = host_agent.Collector(str(root)).nginx(errors)
assert section["available"] is True
assert section["files"] == ["/etc/nginx/nginx.conf"]
nginx_errors = [line for line in errors if line.startswith("nginx:")]
assert len(nginx_errors) == 1
assert "could not be parsed" in nginx_errors[0]
assert "/etc/nginx/conf.d/http.conf" in nginx_errors[0]
# the main config's own server is still there: one bad include degrades, it does not blank
assert [entry["port"] for entry in section["servers"][0]["listen"]] == [80, 80]
def test_a_missing_nginx_config_is_available_false_with_one_errors_line(tmp_path: Path) -> None:
"""A host without nginx is unconfigured, not broken: one line for the pass, and the other
sections still there."""
section = nginx_section(tmp_path, tree=False)
assert section["available"] is False
assert section["config"] == host_agent.NGINX_CONFIG_PATH
assert section["files"] == [] and section["servers"] == []
nginx_errors = [line for line in section["_errors"] if line.startswith("nginx:")]
assert len(nginx_errors) == 1, "one missing file is one fact, not one per section"
assert "not found" in nginx_errors[0]
# cross-module contract, the `smartctl: not found` and `haproxy: … not found` pins' shape:
# the phase-29 gateway source reads this word to answer "is nginx installed?", and
# `collectors` keeps the line out of the system page's `degraded`.
assert host_agent.NGINX_CONFIG_PATH in nginx_errors[0]
assert "--nginx-config" in nginx_errors[0], "the line names the flag that moves the path"
assert "--no-proxy" in nginx_errors[0], "the line has to say how to stop asking"
def test_text_that_is_not_an_nginx_config_degrades_only_its_own_section(tmp_path: Path) -> None:
"""Valid text that names no nginx context is a different fact from an unreadable file, and
it is still one line and still only this section's problem."""
root = tmp_path / "root"
nginx_tree(root, http=None, stream=None)
write_file(root, "etc/nginx/nginx.conf", "some-other-daemon on;\nopts 3;\n")
errors: list[str] = []
section = host_agent.Collector(str(root)).nginx(errors)
assert section["available"] is None
assert section["servers"] == [] and section["files"] == []
nginx_errors = [line for line in errors if line.startswith("nginx:")]
assert len(nginx_errors) == 1, errors
assert "no nginx context" in nginx_errors[0]
def test_a_corrupt_nginx_config_leaves_the_rest_of_the_snapshot_intact(tmp_path: Path) -> None:
"""The invariant-4 half of this add-on: an unparseable main config is `available: null` plus
one line, and `/snapshot` still answers with its cpu and memory sections."""
root = tmp_path / "root"
write_file(root, "proc/stat",
"cpu 100 0 50 800 0 0 0 0 0 0\ncpu0 100 0 50 800 0 0 0 0 0 0\n")
write_file(root, "proc/meminfo", "MemTotal: 8000000 kB\nMemFree: 1000000 kB\n"
"MemAvailable: 4000000 kB\n")
write_file(root, "etc/nginx/nginx.conf", "http { server { listen 80;\n")
snap = host_agent.Collector(str(root)).snapshot()
assert snap["nginx"]["available"] is None
assert snap["nginx"]["servers"] == []
nginx_errors = [line for line in snap["errors"] if line.startswith("nginx:")]
assert len(nginx_errors) == 1, snap["errors"]
assert "could not be parsed" in nginx_errors[0]
assert snap["cpu"]["modes"]["total"] > 0
assert snap["memory"]["total_bytes"] == 8000000 * 1024.0
assert core_errors(snap) == [], snap["errors"]
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.
`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_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": []}
assert any(line.startswith("nginx:") for line in snap["errors"])
assert core_errors(snap) == [], "the add-on's own failure line is not a system degradation"
def test_an_nginx_config_path_is_an_argument(tmp_path: Path) -> None:
"""Packaged and containerised nginx trees keep the main config in different places; the path
resolves under `--root`, so the fake-tree tests read the fake tree."""
write_file(tmp_path / "root", "opt/nginx/conf/nginx.conf",
'http { server { listen 7777; server_name arg.test; } }\n')
errors: list[str] = []
section = host_agent.Collector(
str(tmp_path / "root"), nginx_config="/opt/nginx/conf/nginx.conf").nginx(errors)
assert section["available"] is True
assert section["config"] == "/opt/nginx/conf/nginx.conf"
assert section["files"] == ["/opt/nginx/conf/nginx.conf"]
assert section["servers"][0]["listen"] == [{"addr": None, "port": 7777}]
assert errors == []
def test_a_relative_include_line_is_resolved_and_labelled_as_written(tmp_path: Path,
monkeypatch) -> None:
"""The relative arm of both halves: an include argument written without a leading `/` is
resolved against the working directory (where nginx's own prefix would resolve it) and stays
spelled that way in `files`, because that is how the config wrote it."""
(tmp_path / "nginx.conf").write_text("http { include conf.d/http.conf; }\n")
write_file(tmp_path, "conf.d/http.conf", "server { listen 8123; server_name rel.test; }\n")
monkeypatch.chdir(tmp_path)
errors: list[str] = []
section = host_agent.Collector("/", nginx_config="nginx.conf").nginx(errors)
assert section["available"] is True
assert section["files"] == ["nginx.conf", "conf.d/http.conf"]
assert section["servers"][0]["listen"] == [{"addr": None, "port": 8123}]
assert errors == []
def test_a_relative_nginx_config_path_is_taken_as_written(tmp_path: Path, monkeypatch) -> None:
"""nginx's own `-c` resolves a relative path against the working directory, so this one does
too — only the absolute default is read under `--root`."""
(tmp_path / "nginx.conf").write_text("http { server { listen 7; } }\n")
monkeypatch.chdir(tmp_path)
errors: list[str] = []
section = host_agent.Collector("/", nginx_config="nginx.conf").nginx(errors)
assert section["available"] is True
assert section["servers"][0]["listen"] == [{"addr": None, "port": 7}]
assert errors == []
def test_two_include_lines_naming_one_file_read_it_once(tmp_path: Path) -> None:
"""`include conf.d/http.conf;` and `include conf.d/*.conf;` name the same file, and reading
it twice would double every server block it holds."""
root = tmp_path / "root"
nginx_tree(root, stream=None, main="http {\n"
" include /etc/nginx/conf.d/http.conf;\n"
" include /etc/nginx/conf.d/*.conf;\n"
"}\n")
errors: list[str] = []
section = host_agent.Collector(str(root)).nginx(errors)
assert section["available"] is True
assert section["files"] == ["/etc/nginx/nginx.conf", "/etc/nginx/conf.d/http.conf"]
# two vhosts in http.conf, and not four
assert [entry["port"] for server in section["servers"] for entry in server["listen"]] == [
4443, 4443, 8443, 8443]
assert errors == []
def test_the_include_paths_of_a_config_that_will_not_parse_are_simply_none() -> None:
"""The collector asks this before it can report anything, so a broken file must answer
empty rather than raise — and the parse failure is then said once, by the parser."""
assert host_agent.nginx_include_paths("http { server { listen 80;\n") == []
assert host_agent.nginx_include_paths("http { include /a/*.conf; }") == ["/a/*.conf"]
def test_no_proxy_removes_the_nginx_section_and_says_nothing(tmp_path: Path) -> None:
"""The `--no-smart` precedent, on the second section: opting out is silent, and "not
collected" stays distinguishable from "collected and empty" because the key is absent."""
root = tmp_path / "root"
nginx_tree(root)
snap = host_agent.Collector(str(root), proxy=False).snapshot()
assert "nginx" not in snap, "--no-proxy removes the key, not just its contents"
assert core_errors(snap) == []
def test_the_nginx_section_reaches_the_command_line(tmp_path: Path, capsys) -> None:
"""A flag nobody wired is a flag that does nothing: `--once` proves the CLI path for both
the path argument and the opt-out."""
config = tmp_path / "nginx.conf"
config.write_text('http { server { listen 7778; server_name cli.test; } }\n')
assert host_agent.main(["--once", "--nginx-config", str(config)]) == 0
snap = json.loads(capsys.readouterr().out)
assert snap["ok"] is True
assert snap["nginx"]["available"] is True
assert snap["nginx"]["servers"][0]["listen"] == [{"addr": None, "port": 7778}]
assert host_agent.main(["--once", "--no-proxy"]) == 0
assert "nginx" not in json.loads(capsys.readouterr().out)
def test_the_nginx_section_is_documented_in_the_schema() -> None:
"""`GET /schema` is how an install documents itself, so the section an operator is looking at
has to be named there with every key it carries — the `haproxy` block's precedent."""
assert schema_documents("nginx")
doc = host_agent.__doc__ or ""
block = doc.split(" nginx ", 1)[-1].split(" errors ", 1)[0]
assert block, "the nginx section has no SCHEMA block to document it"
for key in ("available", "config", "files", "servers", "listen", "addr", "port",
"server_names", "proxy_passes", "location", "scheme", "target", "variable",
"upstreams", "maps", "entries", "key"):
assert key in block, f"nginx.{key} missing from the SCHEMA block"
assert "--no-proxy" in block and "--nginx-config" in block, \
"the two flags that decide whether this section exists belong in its documentation"
# ---- live sockets: the additive `sockets` section (phase 28) ---------------- #
#: A fragment of `/proc/net/tcp` as the deployed kernel writes it: the header verbatim, the
#: column order and spacing verbatim, the addresses documentation space (RFC 5737) and the
#: hexadecimals computed from them the way `/proc` computes them — one 32-bit word, printed
#: little-endian. `tests/fixtures/gateway_proc_net_tcp{,6}.txt` is the verbatim capture; this is
#: the pinned shape with the values a test can assert, the `smartctl_reference_*` precedent.
#: Rows: LISTEN 0.0.0.0:80, ESTABLISHED 10.1.0.5:443 <- 203.0.113.7:50000, that same pair in
#: TIME_WAIT, and LISTEN 127.0.0.1:8082.
REFERENCE_PROC_NET_TCP = """\
sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode
0: 00000000:0050 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 4314 1 0000000000000000 100 0 0 10 0
1: 0500010A:01BB 077100CB:C350 01 00000000:00000000 00:00000000 00000000 1000 0 5122 1 00000000d22dedba 20 4 28 10 -1
2: 0500010A:01BB 077100CB:C350 06 00000000:00000000 00:00000000 00000000 0 0 0 3 0000000000000000 20 0 0 10 0
3: 0100007F:1F92 00000000:0000 0A 00000000:00000000 00:00000000 00000000 1000 0 4389 1 0000000000000000 100 0 0 10 0
"""
#: The v6 half, same shape: LISTEN `::`:443 (what haproxy writes as `bind :::443 v4v6`), an
#: ESTABLISHED pair between two documentation-space v6 addresses (RFC 3849), the same pair in
#: TIME_WAIT, and LISTEN `::ffff:127.0.0.1:8082` — the IPv4-mapped spelling a v6 socket uses,
#: which stays a v6 row instead of being folded into the v4 table.
REFERENCE_PROC_NET_TCP6 = """\
sl local_address remote_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode
0: 00000000000000000000000000000000:01BB 00000000000000000000000000000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 4315 1 0000000000000000 100 0 0 10 0
1: B80D0120000000000000000005000000:20FB B80D0120000001000000000009000000:CE41 01 00000000:00000000 00:00000000 00000000 1000 0 5130 1 00000000d22dedba 20 4 28 10 -1
2: B80D0120000000000000000005000000:20FB B80D0120000001000000000009000000:CE41 06 00000000:00000000 00:00000000 00000000 0 0 0 3 0000000000000000 20 0 0 10 0
3: 0000000000000000FFFF00000100007F:1F92 00000000000000000000000000000000:0000 0A 00000000:00000000 00:00000000 00000000 1000 0 4390 1 0000000000000000 100 0 0 10 0
"""
def sockets_tree(root: Path, tcp: str = REFERENCE_PROC_NET_TCP,
tcp6: str | None = REFERENCE_PROC_NET_TCP6) -> Path:
"""A proc tree whose only interesting content is the two socket tables."""
write_file(root, "proc/stat", "cpu 9000 0 1000 80000\n")
write_file(root, "proc/meminfo", "MemTotal: 8000000 kB\nMemAvailable: 5000000 kB\n")
write_file(root, "proc/net/tcp", tcp)
if tcp6 is not None:
write_file(root, "proc/net/tcp6", tcp6)
return root
def test_a_captured_proc_net_tcp_fragment_parses_into_listens_and_established() -> None:
"""The v4 table's two facts, exactly, with `/proc`'s own hexadecimals."""
listens, established = host_agent.parse_proc_net_tcp(REFERENCE_PROC_NET_TCP, "v4")
assert listens == [
{"family": "v4", "addr": "0.0.0.0", "port": 80},
{"family": "v4", "addr": "127.0.0.1", "port": 8082},
]
assert established == [
{"family": "v4",
"local": {"addr": "10.1.0.5", "port": 443},
"remote": {"addr": "203.0.113.7", "port": 50000}},
]
@pytest.mark.parametrize("state", ["02", "03", "04", "05", "06", "07", "08", "09", "0B", "0C"])
def test_a_socket_in_transit_is_never_reported_as_a_pipe(state: str) -> None:
"""Every kernel state but LISTEN and ESTABLISHED is a socket that is not a pipe.
`06` is TIME_WAIT, which is what a proxy's finished client connections turn into by the
thousand: reporting them would draw two pipes for one connection and bury the live pairs
under rows that stopped being true before the page painted. The kernel's own list
(`net/tcp_states.h`) is what this walks, so a state added upstream is skipped too.
"""
line = (f" 0: 0500010A:01BB 077100CB:C350 {state} 00000000:00000000 00:00000000"
" 00:00000000 00000000 1000 0 5122 1 00000000d22dedba 20 4 28 10 -1")
assert host_agent.parse_proc_net_tcp(line, "v4") == ([], [])
def test_the_captured_fragment_actually_holds_the_row_it_omits() -> None:
"""The TIME_WAIT assertion above is only worth anything if the capture has one to omit."""
states = [line.split()[3] for line in REFERENCE_PROC_NET_TCP.splitlines()[1:]
if len(line.split()) > 3]
assert states == ["0A", "01", "06", "0A"], "the v4 fragment lost its socket in transit"
v6_states = [line.split()[3] for line in REFERENCE_PROC_NET_TCP6.splitlines()[1:]
if len(line.split()) > 3]
assert v6_states == ["0A", "01", "06", "0A"]
listens, established = host_agent.parse_proc_net_tcp(REFERENCE_PROC_NET_TCP, "v4")
assert len(established) == 1, "the TIME_WAIT row leaked into the established list"
assert len(listens) == 2
def test_a_captured_proc_net_tcp6_fragment_unrolls_its_little_endian_words() -> None:
"""v6 is four 32-bit words, each printed little-endian; the parser must unroll each group.
A parser that read the hex as written would print `b80d:120:…` — a plausible-looking address
that is not the one the kernel reported, which is worse than a gap beside a real socket.
"""
listens, established = host_agent.parse_proc_net_tcp(REFERENCE_PROC_NET_TCP6, "v6")
assert listens == [
{"family": "v6", "addr": "::", "port": 443},
{"family": "v6", "addr": "::ffff:127.0.0.1", "port": 8082},
]
assert established == [
{"family": "v6",
"local": {"addr": "2001:db8::5", "port": 8443},
"remote": {"addr": "2001:db8:1::9", "port": 52801}},
]
def test_the_wildcard_listeners_keep_their_literal_wildcard_addresses() -> None:
"""The wildcard **is** the fact the inbound join keys on, so it is never nullled or starred.
haproxy's `bind :::80 v4v6` and nginx's bare `listen 80` both mean "any address on this
port", and the page reaches that fact by comparing the socket's address to the config's; a
`*`, a `null` or a dropped field would each lose every wildcard frontend on the gateway.
"""
v4_listens, _ = host_agent.parse_proc_net_tcp(REFERENCE_PROC_NET_TCP, "v4")
v6_listens, _ = host_agent.parse_proc_net_tcp(REFERENCE_PROC_NET_TCP6, "v6")
assert v4_listens[0]["addr"] == "0.0.0.0"
assert v6_listens[0]["addr"] == "::"
assert "*" not in str(v4_listens) and "*" not in str(v6_listens)
def test_an_endpoint_that_is_not_an_address_is_null_and_never_a_guess() -> None:
"""A short word, a non-hex word and a non-numeric port each cost a field, not a row."""
assert host_agent._proc_net_endpoint("0100007F:1F92", "v4") == ("127.0.0.1", 8082)
assert host_agent._proc_net_endpoint("0100007:1F92", "v4") == (None, 8082)
assert host_agent._proc_net_endpoint("010000ZZ:1F92", "v4") == (None, 8082)
assert host_agent._proc_net_endpoint("0100007F:nope", "v4") == ("127.0.0.1", None)
assert host_agent._proc_net_endpoint("local_address", "v4") == (None, None)
listens, _ = host_agent.parse_proc_net_tcp(
" 0: local_address rem_address st rest\n"
" 1: 00000000:0050 00000000:0000 0A rest\n", "v4")
assert listens == [{"family": "v4", "addr": "0.0.0.0", "port": 80}], \
"/proc's own header line must not become a socket"
def test_an_address_this_platform_cannot_render_is_a_gap_and_not_a_crash(
monkeypatch: pytest.MonkeyPatch) -> None:
"""`socket.inet_ntop` is the one call here the agent does not control.
It exists on every Linux the agent runs on, and this pins what happens if it ever refuses:
the address becomes `null` and the socket row stays, because a snapshot path may not raise
over a rendering question, and the port — the half the join actually needs — is still true.
"""
def refuse(_family: int, _bytes: bytes) -> str:
raise OSError("no inet_ntop here")
monkeypatch.setattr(host_agent.socket, "inet_ntop", refuse)
listens, _ = host_agent.parse_proc_net_tcp(REFERENCE_PROC_NET_TCP, "v4")
assert listens[0] == {"family": "v4", "addr": None, "port": 80}
def test_the_socket_tables_are_read_from_the_proc_tree(tmp_path: Path) -> None:
"""The section over a tree, not just over text: `--root` resolves `/proc/net/tcp`."""
root = tmp_path / "root"
sockets_tree(root)
errors: list[str] = []
section = host_agent.Collector(str(root)).sockets(errors)
assert section["available"] is True
assert section["listens"] == [
{"family": "v4", "addr": "0.0.0.0", "port": 80},
{"family": "v4", "addr": "127.0.0.1", "port": 8082},
{"family": "v6", "addr": "::", "port": 443},
{"family": "v6", "addr": "::ffff:127.0.0.1", "port": 8082},
], "the v4 rows come first and the family label says which table each came from"
assert len(section["established"]) == 2
def test_a_tree_without_the_socket_tables_is_null_with_one_errors_line(tmp_path: Path) -> None:
"""Neither table answering is the section's own `null` plus **one** line, nothing else.
A snapshot is not the place to discover that `/proc` is missing, and one line is the whole
report: the other sections still have to be readable and the system source still has to be
`ok` (the `sockets` prefix is in the dashboard's add-on list for exactly that).
"""
root = tmp_path / "root"
sockets_tree(root, tcp="", tcp6=None)
(root / "proc" / "net" / "tcp").unlink()
errors: list[str] = []
section = host_agent.Collector(str(root)).sockets(errors)
assert section == {"available": None, "listens": [], "established": []}
assert len(errors) == 1, errors
assert errors[0].startswith("sockets:")
assert "/proc/net/tcp" in errors[0] and "/proc/net/tcp6" in errors[0]
assert "--no-proxy" in errors[0]
@pytest.mark.skipif(hasattr(os, "geteuid") and os.geteuid() == 0, reason="running as root")
def test_the_socket_tables_reach_a_snapshot_and_nothing_else_moves(tmp_path: Path) -> None:
"""The section is additive in the strongest sense: adding the tables changes no other field.
A snapshot is a dozen independent reads, and the cheapest proof that a new one is a guest
rather than a renovation is to take the same tree twice — once with the socket tables, once
without — and demand every other key byte-identical. `ts` and `duration_ms` are the two
fields a snapshot owns, so they are the only ones allowed to differ besides `sockets`.
"""
with_tables = tmp_path / "with"
sockets_tree(with_tables)
without = tmp_path / "without"
sockets_tree(without)
(without / "proc" / "net" / "tcp").unlink()
(without / "proc" / "net" / "tcp6").unlink()
first = host_agent.Collector(str(with_tables)).snapshot()
second = host_agent.Collector(str(without)).snapshot()
assert first["sockets"]["available"] is True
assert second["sockets"]["available"] is None
for key in first:
if key in ("ts", "duration_ms", "sockets", "errors"):
continue
assert first[key] == second[key], f"{key} moved when the socket section appeared"
# the gap line is the add-on's own voice: it reaches `errors` and nothing else
assert any(line.startswith("sockets:") for line in second["errors"])
assert not any(line.startswith("sockets:") for line in first["errors"])
def test_an_unreadable_socket_table_costs_one_line_while_the_other_still_reports(
tmp_path: Path) -> None:
"""Partial coverage is `available: true` plus one line: the host said something worth saying."""
root = tmp_path / "root"
sockets_tree(root)
(root / "proc" / "net" / "tcp6").chmod(0)
errors: list[str] = []
section = host_agent.Collector(str(root)).sockets(errors)
assert section["available"] is True
assert [row["family"] for row in section["listens"]] == ["v4", "v4"]
assert len(errors) == 1, errors
assert errors[0].startswith("sockets:") and "/proc/net/tcp6" in errors[0]
assert "not found" not in errors[0], "a denied file is not a missing one"
def test_a_missing_second_table_costs_nothing_when_the_first_answered(tmp_path: Path) -> None:
"""A kernel built without IPv6 has no `/proc/net/tcp6` at all.
That is the host telling us what it has, so the section is `available: true` and the pass is
silent — the nginx rule that an include naming a file this host does not have contributes
nothing and no line, and the reason a fleet-wide agent rollout does not put a line in every
IPv4-only container's snapshot.
"""
root = tmp_path / "root"
sockets_tree(root, tcp6=None)
errors: list[str] = []
section = host_agent.Collector(str(root)).sockets(errors)
assert section["available"] is True
assert [row["family"] for row in section["listens"]] == ["v4", "v4"]
assert errors == []
def test_the_socket_read_is_bounded_and_says_so(tmp_path: Path) -> None:
"""A busy gateway writes one line per socket; the bound is what keeps that out of the poll.
Truncation is reported, not hidden: `available` stays `true`, the first rows are the rows the
section carries, and one line names the bound so the difference between "4096 sockets" and
"more sockets than this read takes" is on the wire (the SMART pass-budget shape).
"""
root = tmp_path / "root"
rows = [" sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt"
" uid timeout inode"]
rows += [f" {n}: 00000000:{n:04X} 00000000:0000 0A 00000000:00000000 00:00000000"
" 00000000 0 0 1 1 0000000000000000 100 0 0 10 0"
for n in range(1, 5000)]
sockets_tree(root, tcp="\n".join(rows) + "\n", tcp6=None)
errors: list[str] = []
section = host_agent.Collector(str(root)).sockets(errors)
assert section["available"] is True
assert len(section["listens"]) == host_agent.PROC_NET_MAX_LINES - 1, \
"the bound counts the lines read, /proc's own header among them"
assert [row["port"] for row in section["listens"][:2]] == [1, 2]
assert section["listens"][-1]["port"] == host_agent.PROC_NET_MAX_LINES - 1
assert len(errors) == 1, errors
assert errors[0].startswith("sockets:") and "/proc/net/tcp" in errors[0]
assert str(host_agent.PROC_NET_MAX_LINES) in errors[0]
def test_no_proxy_removes_the_sockets_section_and_says_nothing(tmp_path: Path) -> None:
"""A box with no proxy has nothing to watch from either side, so opting out is silent."""
root = tmp_path / "root"
sockets_tree(root)
snap = host_agent.Collector(str(root), proxy=False).snapshot()
assert "sockets" not in snap, "--no-proxy removes the key, not just its contents"
assert core_errors(snap) == []
def test_a_sockets_failure_inside_the_collector_still_yields_a_section(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""The `_safe` wrapper is the section's last line of defence, like the two configs'"""
root = tmp_path / "root"
sockets_tree(root)
collector = host_agent.Collector(str(root))
def explode(errors: list[str]) -> dict:
raise RuntimeError("kaboom")
monkeypatch.setattr(collector, "sockets", explode)
snap = collector.snapshot()
assert snap["sockets"] == {"available": None, "listens": [], "established": []}
assert any(line.startswith("sockets:") for line in snap["errors"])
def test_the_sockets_section_reaches_the_command_line(tmp_path: Path, capsys) -> None:
"""`--once` proves the CLI path, including the opt-out the operator actually uses."""
root = tmp_path / "root"
sockets_tree(root)
assert host_agent.main(["--once", "--root", str(root)]) == 0
snap = json.loads(capsys.readouterr().out)
assert snap["ok"] is True
assert snap["sockets"]["available"] is True
assert {"family": "v6", "addr": "::", "port": 443} in snap["sockets"]["listens"]
assert host_agent.main(["--once", "--root", str(root), "--no-proxy"]) == 0
assert "sockets" not in json.loads(capsys.readouterr().out)
def test_the_sockets_section_is_documented_in_the_schema() -> None:
"""`GET /schema` is how an install documents itself; the two configs set the precedent."""
assert schema_documents("sockets")
doc = host_agent.__doc__ or ""
block = doc.split(" sockets ", 1)[-1].split(" errors ", 1)[0]
assert block, "the sockets section has no SCHEMA block to document it"
for key in ("available", "listens", "established", "family", "addr", "port",
"local", "remote"):
assert key in block, f"sockets.{key} missing from the SCHEMA block"
assert "--no-proxy" in block, "the flag that decides whether this section exists belongs here"
for table in ("/proc/net/tcp", "/proc/net/tcp6"):
assert table in block, f"{table} is what the section reads; name it in the schema"
@linux_only
def test_the_real_proc_net_tcp_parses_on_the_box_running_pytest() -> None:
"""The parser against the kernel that is actually here.
The shape is what this pins: every row carries its family, a port that parsed is a plausible
one, and a wildcard listener is still a wildcard. Whether this machine has any listeners at
all is not a claim a test may make, so it does not.
"""
collector = host_agent.Collector("/")
errors: list[str] = []
section = collector.sockets(errors)
assert section["available"] is True, "a Linux box with /proc mounted must answer for sockets"
assert errors == []
for row in section["listens"]:
assert row["family"] in ("v4", "v6")
assert row["port"] is None or 0 <= row["port"] <= 65535
if row["port"] is not None:
assert row["addr"] is not None, "a decoded port from an undecodable address is drift"
for row in section["established"]:
assert set(row) == {"family", "local", "remote"}
assert set(row["local"]) == set(row["remote"]) == {"addr", "port"}
# ---- the real collector --------------------------------------------------- #
@linux_only
def test_snapshot_against_the_live_system_is_complete() -> None:
"""Real /proc and /sys, but only of the box running pytest — no network, so this is not
`live` (see tests/test_live_fleet.py): it belongs in the default run."""
collector = host_agent.Collector("/")
snap = collector.snapshot()
for key in ("schema", "ts", "host", "cpu", "memory", "filesystems", "disk_io",
"network", "gpus", "temperatures", "processes", "haproxy", "nginx",
"sockets", "errors"):
assert key in snap, f"{key} missing from a real snapshot"
assert snap["host"]["hostname"]
assert snap["cpu"]["modes"]["total"] > 0
assert len(snap["cpu"]["cores"]) >= 1
assert snap["memory"]["total_bytes"] > 0
assert snap["filesystems"], "the box it runs on has at least one mounted filesystem"
# SMART notes are allowed here: the box running pytest may have no smartctl or no
# device read access, and both are documented degradations, never snapshot failures.
# So are the proxy ones — most boxes have no haproxy, and saying so is the add-on's
# `available: false` arm, not a degraded section. So is `sockets:` on a box whose /proc has
# no net tables mounted, which is the same "unconfigured, not broken" sentence.
hard_errors = core_errors(snap)
assert hard_errors == [], f"degraded sections must be empty on a healthy box: {hard_errors}"
# `files` is the config's own paths: the main config first when it was read, empty when not
if snap["nginx"]["available"] is True:
assert snap["nginx"]["files"][0] == snap["nginx"]["config"]
else:
assert snap["nginx"]["files"] == []
# the add-on is present by default, and its own verdict is one of the three arms
assert snap["haproxy"]["available"] in (True, False, None)
assert snap["haproxy"]["config"] == host_agent.HAPROXY_CONFIG_PATH
assert snap["nginx"]["available"] in (True, False, None)
assert snap["nginx"]["config"] == host_agent.NGINX_CONFIG_PATH
# the socket table is present by default too, and answers for itself: /proc/net/tcp is not
# something a host can leave uninstalled, so the arms here are "read" and "did not read"
assert snap["sockets"]["available"] in (True, None)
for row in snap["sockets"]["listens"]:
assert row["family"] in ("v4", "v6")
assert row["port"] is None or 0 <= row["port"] <= 65535
def test_captured_fixture_has_the_shape_the_dashboard_depends_on(agent_snapshot) -> None:
"""Ground truth from driveripper: if the agent's schema drifts, this fails first."""
assert agent_snapshot["schema"] == host_agent.SCHEMA_VERSION
host = agent_snapshot["host"]
assert host["hostname"] == "driveripper"
assert (host["cpu_cores_physical"], host["cpu_cores_logical"]) == (24, 48)
assert host["uptime_seconds"] > 0
gpu = agent_snapshot["gpus"][0]
assert gpu["model"] == "Radeon AI PRO R9700"
assert "model_full" in gpu and "Navi 48" in gpu["model_full"]
assert gpu["vram_total_bytes"] > 0
# the dashboard renders n/a for anything a driver omits — it must never invent a 0
assert gpu["busy_pct"] is None or 0.0 <= gpu["busy_pct"] <= 100.0
@linux_only
def test_uptime_and_boot_ts_are_reported() -> None:
"""Both are needed: uptime for the table, boot_ts to detect a counter-reset reboot."""
info = host_agent.Collector("/").host_info()
assert info["uptime_seconds"] and info["uptime_seconds"] > 0
assert info["boot_ts"] and info["boot_ts"] <= time.time()
assert abs((time.time() - info["boot_ts"]) - info["uptime_seconds"]) < 60
assert info["cpu_cores_logical"] and info["cpu_cores_physical"]
@linux_only
def test_snapshot_is_repeatable_and_does_not_deadlock() -> None:
"""snapshot() reads processes() internally; a non-reentrant lock used to hang it."""
collector = host_agent.Collector("/")
first = collector.snapshot()
second = collector.snapshot(top=0)
assert first["processes"]["total"] > 0
assert second["processes"]["top_cpu"] == [], "top=0 must skip the expensive scan"
@linux_only
def test_disk_io_comes_from_sysfs_not_proc_diskstats() -> None:
"""/proc/diskstats field order has moved between kernels; /sys/block/<dev>/stat has not."""
devices = host_agent.Collector("/").disk_io()["devices"]
assert devices
for device in devices:
assert device["reads_total"] is None or device["reads_total"] >= 0
assert device["size_bytes"] is None or device["size_bytes"] > 0
# ---- HTTP surface --------------------------------------------------------- #
@pytest.fixture
def agent_server():
"""Run the real handler on an ephemeral loopback port; yield (get, port)."""
servers: list[ThreadingHTTPServer] = []
def start(token: str | None = None) -> tuple[str, int]:
host_agent.Handler.collector = host_agent.Collector("/")
host_agent.Handler.token = token
# Cleared on purpose: `Handler` is process-wide state, so a token-mode server started
# after a TLS test in this file must not inherit that test's bundle — or the write
# path's gate, which the bundle check is the first line of.
host_agent.Handler.bundle = None
host_agent.Handler.allow_writes = False
host_agent.Handler.routectl_socket = None
httpd = ThreadingHTTPServer(("127.0.0.1", 0), host_agent.Handler)
servers.append(httpd)
threading.Thread(target=httpd.serve_forever, daemon=True).start()
return f"http://127.0.0.1:{httpd.server_address[1]}", httpd.server_address[1]
yield start
for httpd in servers:
httpd.shutdown()
httpd.server_close()
def _get(url: str, headers: dict[str, str] | None = None) -> tuple[int, dict]:
request = urllib.request.Request(url, headers=headers or {})
try:
with urllib.request.urlopen(request, timeout=5) as response:
return response.status, json.loads(response.read())
except urllib.error.HTTPError as error:
body = error.read()
try:
return error.code, json.loads(body)
except json.JSONDecodeError: # pragma: no cover - handler always sends JSON
return error.code, {"raw": body.decode(errors="replace")}
def test_health_stays_up_while_smart_is_degraded(agent_server, tmp_path) -> None:
"""Phase-09 criterion: SMART failures are add-on notes, never an agent outage."""
root = tmp_path / "root"
write_fake_disk(root, "nvme0n1")
collector = host_agent.Collector(str(root), smartctl="/nonexistent/smartctl")
base, _port = agent_server()
status, health = _get(base + "/health")
assert status == 200 and health["ok"] is True
handler = host_agent.Handler
handler.collector = collector
status, snap = _get(base + "/snapshot")
assert status == 200
assert snap["disk_io"]["devices"][0]["smart"] is None
assert any(e.startswith("smart:") for e in snap["errors"])
def test_health_info_schema_snapshot_all_answer(agent_server) -> None:
base, _port = agent_server()
for path in ("/health", "/info", "/schema", "/snapshot"):
status, body = _get(base + path)
assert status == 200, path
assert body["ok"] is True, path
def test_snapshot_query_param_controls_the_process_scan(agent_server) -> None:
base, _port = agent_server()
_status, body = _get(base + "/snapshot?top=0")
assert body["processes"]["top_cpu"] == []
_status, full = _get(base + "/snapshot?top=3")
assert len(full["processes"]["top_cpu"]) <= 3
def test_unknown_path_is_404_not_a_snapshot(agent_server) -> None:
base, _port = agent_server()
status, body = _get(base + "/etc/shadow")
assert status == 404
assert body["ok"] is False
def test_token_is_required_when_configured_but_health_stays_open(agent_server) -> None:
base, _port = agent_server(token="s3cret")
assert _get(base + "/snapshot")[0] == 401
assert _get(base + "/snapshot", {"Authorization": "Bearer wrong"})[0] == 401
assert _get(base + "/snapshot", {"Authorization": "Bearer s3cret"})[0] == 200
# /health stays open so a monitoring probe needs no secret
assert _get(base + "/health")[0] == 200
def test_refuses_to_bind_off_loopback_without_a_token(monkeypatch) -> None:
"""0.0.0.0 with no token would hand host inventory to the whole LAN."""
monkeypatch.delenv("AGENT_TOKEN", raising=False)
monkeypatch.delenv("AGENT_CERT_BUNDLE", raising=False)
assert host_agent.main(["--host", "0.0.0.0", "--port", "0"]) == 2
def test_once_mode_prints_json_and_exits(capsys) -> None:
assert host_agent.main(["--once"]) == 0
printed = capsys.readouterr().out
assert json.loads(printed)["ok"] is True
def test_agent_is_dependency_free() -> None:
"""The agent must run on a stock distro python: stdlib imports, and few of them.
Two checks, because they fail for different reasons: anything outside the stdlib means a
`pip install` on every monitored host (deployment pain), while a growing footprint means
the agent can break on an older interpreter than the dashboard's own Python.
"""
text = AGENT_PATH.read_text()
imports = re.findall(r"^(?:import|from)\s+([a-zA-Z0-9_.]+)", text, re.M)
# indented (function-local) imports count too — subprocess is loaded lazily on purpose
imports += re.findall(r"^\s+(?:import|from)\s+([a-zA-Z0-9_.]+)", text, re.M)
top = {name.split(".")[0] for name in imports}
third_party = top - set(sys.stdlib_module_names) - {"host_agent"}
assert not third_party, f"agent grew a non-stdlib import: {third_party}"
# `fnmatch` + `glob` (phase 28) join the list on purpose: an nginx `include` line is a glob,
# and both are as old as the 3.10 floor and as dependency-free as `re`.
assert top <= {"__future__", "argparse", "collections", "contextlib", "dataclasses", "fnmatch",
"glob", "http", "json", "os", "pathlib", "re", "shutil", "socket", "ssl",
"subprocess", "tempfile", "threading", "time", "typing"}, (
f"agent import footprint grew: {sorted(top)}")
# ---- how the token is supplied ------------------------------------------- #
def test_token_file_wins_over_the_command_line(tmp_path) -> None:
path = tmp_path / "token"
path.write_text("from-file\n", encoding="utf-8")
token, error = host_agent.resolve_token("from-flag", str(path))
assert error is None
assert token == "from-file", "argv is world-readable in ps; the file has to win"
def test_a_missing_token_file_is_fatal_and_says_which_one(tmp_path) -> None:
missing = tmp_path / "nope"
token, error = host_agent.resolve_token("", str(missing))
assert token is None
assert error and str(missing) in error, "a silent fallback to no-token would expose the host"
def test_an_empty_token_file_is_fatal(tmp_path) -> None:
path = tmp_path / "token"
path.write_text(" \n", encoding="utf-8")
token, error = host_agent.resolve_token("ignored", str(path))
assert token is None and error is not None
def test_no_token_at_all_is_allowed_on_loopback(tmp_path) -> None:
token, error = host_agent.resolve_token("", "")
assert (token, error) == (None, None)
def test_main_refuses_to_bind_off_loopback_without_a_token(tmp_path, monkeypatch) -> None:
monkeypatch.delenv("AGENT_TOKEN", raising=False)
monkeypatch.delenv("AGENT_CERT_BUNDLE", raising=False)
code = host_agent.main(["--host", "0.0.0.0", "--token-file", str(tmp_path / "missing")])
assert code == 2, "the agent must not start exposed and unauthenticated"
# ---- TLS mode: one installed bundle, and its client certificate is the auth --- #
#
# Everything below runs a real `ssl` handshake against the real handler, because the claims
# here are handshake claims: "optional at the handshake, enforced by the handler" is exactly
# the kind of thing that reads fine and then makes `/health` unprobeable. The certificates come
# from `dashboards.certs` over a dict store, so the agent is tested against the artifact the
# dashboard actually signs rather than against a second generator that could drift from it.
#: The agent module is loaded by `importlib` (it is one file with no importable package), so
#: pyright sees `host_agent` as an untyped module and rejects `host_agent.TlsBundle` in a type
#: position. This alias is the honest spelling: the object is that class, and the tests below
#: assert `isinstance` where the claim actually matters.
Bundle = Any
class DictMeta:
"""The three-method `certs.MetaStore` slice, backed by a dict (the `test_certs.py` idiom)."""
def __init__(self) -> None:
self.rows: dict[str, str] = {}
def get_meta(self, key: str) -> str | None:
return self.rows.get(key)
def set_meta(self, key: str, value: str) -> None:
self.rows[key] = value
def delete_meta(self, key: str) -> int:
return 1 if self.rows.pop(key, None) is not None else 0
def sign_client_leaf(state: certs.CertsState, cn: str, where: Path) -> SimpleNamespace:
"""A second client identity under the dashboard's own root, so it verifies and can be revoked.
`certs.py` mints exactly one client certificate on purpose (the dashboard has one identity),
so the revoked-peer and wrong-authority cases need a leaf signed by hand. The serial comes
back because the denylist names a serial, not a name.
"""
root_key = cast(EllipticCurvePrivateKey,
load_pem_private_key(state.root_key.encode("utf-8"), password=None))
root_cert = x509.load_pem_x509_certificate(state.root_cert.encode("utf-8"))
key = ec.generate_private_key(ec.SECP256R1())
moment = datetime.now(UTC)
cert = (
x509.CertificateBuilder()
.subject_name(x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, cn)]))
.issuer_name(root_cert.subject)
.public_key(key.public_key())
.serial_number(x509.random_serial_number())
.not_valid_before(moment - timedelta(days=1))
.not_valid_after(moment + timedelta(days=365))
.add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=True)
.sign(root_key, SHA256())
)
cert_path = where / f"{cn}-cert.pem"
key_path = where / f"{cn}-key.pem"
cert_path.write_bytes(cert.public_bytes(Encoding.PEM))
key_path.write_bytes(key.private_bytes(Encoding.PEM, PrivateFormat.PKCS8, NoEncryption()))
return SimpleNamespace(cert=cert_path, key=key_path, serial=cert.serial_number)
@pytest.fixture(scope="module")
def tls_fleet(tmp_path_factory: pytest.TempPathFactory) -> SimpleNamespace:
"""A dashboard CA, its client pair, one issued agent bundle, and one foreign authority."""
base = tmp_path_factory.mktemp("agent-tls")
store = DictMeta()
state = certs.ensure_certs(store)
state = certs.issue_agent_cert(store, state, "localhost", ["127.0.0.1", "localhost"])
entry = state.agent("localhost")
assert entry is not None
for name in ("dashboard", "foreign"):
(base / name).mkdir()
dashboard = certs.write_material(state, base / "dashboard")
foreign = certs.write_material(certs.ensure_certs(DictMeta()), base / "foreign")
return SimpleNamespace(
root_cert=state.root_cert,
bundle=entry.bundle,
bundle_rev=entry.bundle_rev,
client_cert=str(dashboard.client_cert),
client_key=str(dashboard.client_key),
foreign_cert=str(foreign.client_cert),
foreign_key=str(foreign.client_key),
stolen=sign_client_leaf(state, "stolen", base),
)
def write_bundle(tmp_path: Path, text: str) -> Path:
"""An installed bundle: one PEM file, exactly as `install-agent.sh` will place it."""
path = tmp_path / "dashboards-agent.certs"
path.write_text(text, encoding="ascii")
return path
def load_bundle(tmp_path: Path, text: str) -> Bundle:
path = write_bundle(tmp_path, text)
bundle, error = host_agent.TlsBundle.load(str(path))
assert error is None and bundle is not None
return bundle
def client_context(ca: str, cert: str | None = None, key: str | None = None) -> ssl.SSLContext:
"""A client that trusts `ca`, and presents a client certificate only when given one."""
context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
context.load_verify_locations(cadata=ca)
if cert and key:
context.load_cert_chain(cert, key)
return context
@pytest.fixture
def tls_agent_server():
"""The real handler on a TLS-wrapped loopback port; yields the base URL."""
servers: list[ThreadingHTTPServer] = []
def start(bundle: Bundle, token: str | None = None) -> str:
host_agent.Handler.collector = host_agent.Collector("/", smart=False)
host_agent.Handler.token = token
host_agent.Handler.bundle = bundle
# Cleared on purpose, like `bundle` above: a writes-off server started after a
# `POST /route` test must not inherit that test's write gate or helper socket.
host_agent.Handler.allow_writes = False
host_agent.Handler.routectl_socket = None
httpd = ThreadingHTTPServer(("127.0.0.1", 0), host_agent.Handler)
httpd.socket = bundle.context.wrap_socket(httpd.socket, server_side=True)
servers.append(httpd)
threading.Thread(target=httpd.serve_forever, daemon=True).start()
return f"https://127.0.0.1:{httpd.server_address[1]}"
yield start
for httpd in servers:
httpd.shutdown()
httpd.server_close()
host_agent.Handler.bundle = None
host_agent.Handler.allow_writes = False
host_agent.Handler.routectl_socket = None
def _json_or_raw(body: bytes) -> dict:
"""JSON when the handler answered with JSON; the raw text for the status page."""
try:
return json.loads(body)
except json.JSONDecodeError:
return {"raw": body.decode(errors="replace")}
def _https_get(url: str, context: ssl.SSLContext) -> tuple[int, dict]:
request = urllib.request.Request(url)
try:
with urllib.request.urlopen(request, timeout=15, context=context) as response:
return response.status, _json_or_raw(response.read())
except urllib.error.HTTPError as error:
return error.code, _json_or_raw(error.read())
def test_the_agent_and_the_dashboard_agree_on_the_bundle_lines() -> None:
"""The agent parses a PEM by text with stdlib only, so the two literals are the contract."""
assert host_agent.BUNDLE_REV_LINE == certs.BUNDLE_REV_LINE
assert host_agent.REVOKED_LINE == certs.REVOKED_LINE
def test_the_bundle_lines_are_read_as_written() -> None:
text = "# BUNDLE-REV 12\n# REVOKED 5\n# REVOKED 77\n"
assert host_agent.bundle_revision(text) == 12
assert host_agent.bundle_revoked(text) == frozenset({"5", "77"})
assert host_agent.bundle_revision("nothing at all\n") is None
assert host_agent.bundle_revision("# BUNDLE-REV not-a-number\n") is None
assert host_agent.bundle_revoked("# REVOKED\n") == frozenset()
@pytest.mark.parametrize("revoked, peer_hex, expected", [
# the shape a real `x509.random_serial_number()` serial arrives in, both spellings
("57989923439447977599989997426575336710275559875",
"A285BAC84EB9C5FC282EF7CDB8D962F38BF71C3", True),
("12648430", "C0FFEE", True),
("14", "0E", True),
("0E", "0E", True),
("15", "0E", False),
("0", "0E", False),
])
def test_the_denylist_matches_a_serial_whichever_way_it_is_written(revoked: str,
peer_hex: str,
expected: bool) -> None:
"""`getpeercert()` says hex, `certs.py` writes decimal, and the agent must meet in the middle.
An agent comparing one spelling to the other accepts a revoked client forever: the operator
clicked revoke, `/certs` says revoked, and the fleet keeps trusting the stolen identity.
"""
cert = {"serialNumber": peer_hex} if peer_hex else {}
assert host_agent.serial_is_revoked(cert, frozenset({revoked})) is expected
def test_a_certificate_with_no_serial_is_never_treated_as_revoked() -> None:
assert host_agent.serial_is_revoked({}, frozenset({"1", "0"})) is False
def test_resolve_bundle_reports_the_revision_and_the_denylist_of_a_real_bundle(
tls_fleet: SimpleNamespace, tmp_path: Path) -> None:
text = tls_fleet.bundle + f"{host_agent.REVOKED_LINE}{tls_fleet.stolen.serial}\n"
context, revision, revoked, error = host_agent.resolve_bundle(str(write_bundle(tmp_path, text)))
assert error is None
assert context is not None and isinstance(context, ssl.SSLContext)
assert revision == tls_fleet.bundle_rev
assert revoked == frozenset({str(tls_fleet.stolen.serial)})
@pytest.mark.parametrize("shape", ["missing", "empty", "no-key", "no-revision"])
def test_a_bundle_that_cannot_be_loaded_is_an_error_naming_the_file(
tls_fleet: SimpleNamespace, tmp_path: Path, shape: str) -> None:
"""Every failure is a message the operator can act on, and none of them is "no TLS today"."""
if shape == "missing":
path = tmp_path / "absent.certs"
elif shape == "empty":
path = write_bundle(tmp_path, " \n")
elif shape == "no-key":
body = [block for block in re.findall(
r"-----BEGIN [A-Z0-9 ]+-----.*?-----END [A-Z0-9 ]+-----\n", tls_fleet.bundle, re.S)
if block.startswith("-----BEGIN CERTIFICATE")]
path = write_bundle(tmp_path, "# BUNDLE-REV 1\n" + "".join(body))
else:
path = write_bundle(tmp_path, tls_fleet.bundle.replace("# BUNDLE-REV 1\n", ""))
context, revision, revoked, error = host_agent.resolve_bundle(str(path))
assert context is None and revision == 0 and revoked == frozenset()
assert error is not None and ("absent" in error if shape == "missing" else str(path) in error)
if shape == "no-revision":
assert "BUNDLE-REV" in error
def test_the_unpacked_pems_are_gone_once_the_context_has_them(
tls_fleet: SimpleNamespace, tmp_path: Path, monkeypatch) -> None:
"""The unit's RuntimeDirectory is used when it exists, and holds nothing afterwards.
`ssl` loads a chain from paths, so the key is written; leaving it there means the private key
outlives the process on a disk an operator did not choose, and `kill -9` has no cleanup path.
"""
runtime = tmp_path / "run"
runtime.mkdir()
monkeypatch.setattr(host_agent, "BUNDLE_RUNTIME_DIR", runtime)
context, _revision, _revoked, error = host_agent.resolve_bundle(str(write_bundle(tmp_path,
tls_fleet.bundle)))
assert error is None and context is not None
assert list(runtime.iterdir()) == [], "the private key must not stay on disk once loaded"
def test_a_missing_runtime_directory_falls_back_to_a_temporary_one(
tls_fleet: SimpleNamespace, tmp_path: Path, monkeypatch) -> None:
monkeypatch.setattr(host_agent, "BUNDLE_RUNTIME_DIR", tmp_path / "not-mounted")
context, revision, _revoked, error = host_agent.resolve_bundle(str(write_bundle(tmp_path,
tls_fleet.bundle)))
assert error is None and context is not None
assert revision == tls_fleet.bundle_rev
def test_a_client_certificate_signed_by_the_root_reads_the_snapshot(
tls_fleet: SimpleNamespace, tls_agent_server, tmp_path: Path) -> None:
"""The whole point of the phase, plus the two fields task 03 reads.
`bundle_rev` is how `/certs` tells "this host runs an old bundle" from "this host is current",
so it is the number the bundle carried, not a value the agent made up.
"""
base = tls_agent_server(load_bundle(tmp_path, tls_fleet.bundle))
status, body = _https_get(base + "/snapshot",
client_context(tls_fleet.root_cert, tls_fleet.client_cert,
tls_fleet.client_key))
assert status == 200
assert body["ok"] is True and body["host"]["hostname"]
assert body["tls"] is True
assert body["bundle_rev"] == tls_fleet.bundle_rev
for path in ("/info", "/schema", "/"):
status, _body = _https_get(base + path, client_context(tls_fleet.root_cert,
tls_fleet.client_cert,
tls_fleet.client_key))
assert status == 200, path
def test_health_is_probeable_with_the_root_ca_alone_while_every_other_path_is_not(
tls_fleet: SimpleNamespace, tls_agent_server, tmp_path: Path) -> None:
"""The `CERT_OPTIONAL` assumption: a probe with no client certificate must still work.
`install-agent.sh --status` and the systemd probe hold the CA and were never handed a client
certificate; `CERT_REQUIRED` would make every TLS install look dead. Everything that carries
metrics is 403 — including the status page, which repeats hostname, kernel and CPU model.
"""
base = tls_agent_server(load_bundle(tmp_path, tls_fleet.bundle))
probe = client_context(tls_fleet.root_cert)
status, body = _https_get(base + "/health", probe)
assert status == 200 and body["ok"] is True
for path in ("/snapshot", "/info", "/schema", "/", "/nope"):
refused, detail = _https_get(base + path, probe)
assert refused == 403, path
assert detail["detail"] == "client certificate required", path
def test_a_client_signed_by_another_authority_is_refused(
tls_fleet: SimpleNamespace, tls_agent_server, tmp_path: Path) -> None:
"""A certificate that does not chain to the bundle's root never reaches a handler.
The server verifies at the handshake, so the answer is an aborted connection rather than an
HTTP code — the assertion is only ever `not 200` because which alert arrives is the peer
OpenSSL's choice, not ours.
"""
base = tls_agent_server(load_bundle(tmp_path, tls_fleet.bundle))
stranger = client_context(tls_fleet.root_cert, tls_fleet.foreign_cert, tls_fleet.foreign_key)
with pytest.raises((OSError, http.client.HTTPException)) as caught:
_https_get(base + "/snapshot", stranger)
assert not isinstance(caught.value, urllib.error.HTTPError)
def test_a_plaintext_request_to_a_tls_agent_gets_nothing(
tls_fleet: SimpleNamespace, tls_agent_server, tmp_path: Path) -> None:
"""A port probe cannot read the answer, and does not get a helpful error either."""
base = tls_agent_server(load_bundle(tmp_path, tls_fleet.bundle))
port = int(base.rsplit(":", 1)[-1])
with socket.create_connection(("127.0.0.1", port), timeout=15) as raw:
raw.sendall(b"GET /snapshot HTTP/1.1\r\nHost: x\r\n\r\n")
raw.settimeout(5)
try:
payload = raw.recv(200)
except OSError:
payload = b""
assert b"hostname" not in payload
def test_a_revoked_client_certificate_is_refused_and_the_bundle_is_re_read(
tls_fleet: SimpleNamespace, tls_agent_server, tmp_path: Path) -> None:
"""The two halves: the denylist bites, and re-deploying the bundle takes effect no later
than the next request — no restart, which is the only reason the lines live in the file."""
path = write_bundle(tmp_path, tls_fleet.bundle
+ f"{host_agent.REVOKED_LINE}{tls_fleet.stolen.serial}\n")
bundle, error = host_agent.TlsBundle.load(str(path))
assert error is None and bundle is not None
base = tls_agent_server(bundle)
stolen = client_context(tls_fleet.root_cert, str(tls_fleet.stolen.cert), str(tls_fleet.stolen.key))
status, body = _https_get(base + "/snapshot", stolen)
assert status == 403
assert body["detail"] == "client certificate revoked"
# A withdrawn identity is refused even on the open path: the exemption is for a probe that
# holds the CA and no certificate, not for a certificate the operator has just taken away.
assert _https_get(base + "/health", stolen)[0] == 403
# The dashboard's own identity is untouched — the list names a serial, not a host.
assert _https_get(base + "/snapshot", client_context(tls_fleet.root_cert, tls_fleet.client_cert,
tls_fleet.client_key))[0] == 200
path.write_text(tls_fleet.bundle, encoding="ascii") # a redeploy: the line is gone
status, body = _https_get(base + "/snapshot", stolen)
assert status == 200, "the denylist is re-read when the file changes, without a restart"
def test_a_renamed_denylist_entry_is_re_read_on_the_next_request(
tls_fleet: SimpleNamespace, tmp_path: Path) -> None:
"""An unreadable bundle keeps the list this agent already had rather than forget it."""
path = write_bundle(tmp_path, tls_fleet.bundle
+ f"{host_agent.REVOKED_LINE}{tls_fleet.stolen.serial}\n")
bundle, error = host_agent.TlsBundle.load(str(path))
assert error is None and bundle is not None
assert bundle.revoked_serials() == frozenset({str(tls_fleet.stolen.serial)})
path.unlink()
assert bundle.revoked_serials() == frozenset({str(tls_fleet.stolen.serial)})
def test_the_token_is_not_read_on_the_tls_path(
tls_fleet: SimpleNamespace, tls_agent_server, tmp_path: Path) -> None:
"""Bundle plus token: the handshake is the auth, so a stolen token cannot outlive a revoke.
Checking the bearer on the TLS path too would not be defence in depth — it would be a second
credential that keeps working after the operator revoked the certificate, which is the one
thing the denylist exists to prevent.
"""
base = tls_agent_server(load_bundle(tmp_path, tls_fleet.bundle), token="s3cret")
status, _body = _https_get(base + "/snapshot",
client_context(tls_fleet.root_cert, tls_fleet.client_cert,
tls_fleet.client_key))
assert status == 200
def test_token_mode_reports_no_tls_fields(agent_server) -> None:
"""Token mode is byte-identical: no `tls`, no `bundle_rev`, no new field that an agent
installed before phase 15 does not have."""
assert host_agent.Handler.bundle is None, "the token-mode fixture must not inherit a bundle"
base, _port = agent_server(token="s3cret")
_status, body = _get(base + "/snapshot", {"Authorization": "Bearer s3cret"})
assert body["ok"] is True
assert "tls" not in body and "bundle_rev" not in body
def test_serve_wraps_the_socket_and_says_tls_and_the_revision(
tls_fleet: SimpleNamespace, tmp_path: Path, capsys, monkeypatch) -> None:
"""`serve()` is the function the unit runs, so its start line is what an operator reads.
The server is recorded rather than guessed at a port: the test needs the ephemeral port the
OS chose *and* a way to stop `serve_forever`, which is the same object.
"""
created: list[ThreadingHTTPServer] = []
class Recording(ThreadingHTTPServer):
def __init__(self, *args: object, **kwargs: object) -> None:
super().__init__(*args, **kwargs) # type: ignore[arg-type]
created.append(self)
monkeypatch.setattr(host_agent, "ThreadingHTTPServer", Recording)
bundle = load_bundle(tmp_path, tls_fleet.bundle)
thread = threading.Thread(target=host_agent.serve,
args=("127.0.0.1", 0, host_agent.Collector("/", smart=False),
None, bundle),
daemon=True)
thread.start()
deadline = time.monotonic() + 15
while not created and time.monotonic() < deadline:
time.sleep(0.01)
assert created, "serve() never built its server"
httpd = created[0]
port = httpd.server_address[1]
status, _body = _https_get(f"https://127.0.0.1:{port}/health",
client_context(tls_fleet.root_cert))
assert status == 200
httpd.shutdown()
thread.join(timeout=15)
printed = capsys.readouterr().out
assert "tls (client cert required, rev " in printed
# the scheme is the whole claim here (the port in the line is the requested one, which is 0
# when the OS picks, exactly as in token mode)
assert "[agent] listening on https://127.0.0.1" in printed
assert "token" not in printed, "a TLS agent does not read a token"
def test_a_bundle_allows_the_off_loopback_bind_without_a_token(
tls_fleet: SimpleNamespace, tmp_path: Path, monkeypatch) -> None:
"""The client certificate *is* the protection, so the exit-2 refusal does not apply."""
served: list[tuple] = []
seen_kwargs: list[dict] = []
monkeypatch.setattr(host_agent, "serve",
lambda *args, **kwargs: served.append(args) or seen_kwargs.append(kwargs))
path = write_bundle(tmp_path, tls_fleet.bundle)
assert host_agent.main(["--host", "0.0.0.0", "--port", "0", "--cert-bundle", str(path)]) == 0
assert served, "a bundle-configured agent must start"
host_arg, _port, _collector, token_arg, bundle_arg = served[0]
assert (host_arg, token_arg) == ("0.0.0.0", None)
assert isinstance(bundle_arg, host_agent.TlsBundle)
# 1.4.0's defaults: the write path is off, and the helper's home is the standard socket
# path unless the flag names another one (an empty value is the 503 arm: no helper here).
assert seen_kwargs[0]["allow_writes"] is False
assert seen_kwargs[0]["routectl_socket"] == "/run/dashboards-routectl.sock"
@pytest.mark.parametrize("extra", [[], ["--cert-bundle", ""]])
def test_off_loopback_without_a_token_or_a_bundle_is_refused_and_names_both(
tmp_path: Path, capsys, monkeypatch, extra: list[str]) -> None:
monkeypatch.delenv("AGENT_TOKEN", raising=False)
monkeypatch.delenv("AGENT_CERT_BUNDLE", raising=False)
args = ["--host", "0.0.0.0", "--port", "0", *extra]
assert host_agent.main(args) == 2
printed = capsys.readouterr().out
assert "--token" in printed and "--cert-bundle" in printed
def test_an_unreadable_bundle_is_fatal_at_startup(tmp_path: Path, capsys) -> None:
"""Exit 2 naming the file: a degraded-to-plain-HTTP agent is the answer that is worse."""
missing = tmp_path / "absent.certs"
assert host_agent.main(["--host", "127.0.0.1", "--port", "0",
"--cert-bundle", str(missing)]) == 2
assert str(missing) in capsys.readouterr().out
def schema_documents(field: str) -> bool:
"""Is this field in the SCHEMA block? `GET /schema` answers with that text, self-documenting
an install for whoever has to debug it, so a field the payload carries belongs there."""
doc = host_agent.__doc__ or ""
schema = doc.split("SCHEMA (GET /snapshot)", 1)[-1].split("Deployment:", 1)[0]
return re.search(rf"^\s+{field}\s", schema, re.M) is not None
def test_the_tls_fields_are_documented_in_the_schema() -> None:
"""The phase-15 fields are documented in the docstring, which is what `GET /schema` answers.
The version constant itself 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 schema_documents("tls") and schema_documents("bundle_rev")
assert "--cert-bundle" in (host_agent.__doc__ or "")
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`.
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.
"""
doc = host_agent.__doc__ or ""
for section in ("haproxy", "nginx", "sockets"):
assert schema_documents(section), f"{section} is not documented in the SCHEMA block"
block = doc.split(f" {section} ", 1)[-1]
assert "available" in block, f"{section}'s block does not document its three-way answer"
assert "--no-proxy" in doc and "--haproxy-config" in doc and "--nginx-config" in doc
def test_the_agent_version_is_1_4_0_and_the_schema_documents_the_route_marker() -> None:
"""1.4.0 is the version that reads the route switch's marker off the route line.
The bump is how the fleet tells an agent that publishes `disabled` from one that does not —
`--status`, `/health` and this test all read the same constant. The SCHEMA block is
`GET /schema`'s body, so it documents the flag on both config sections and the marker's
spelling, and `--no-resolve` stays named: the flag that removes the resolver section in
silence.
"""
assert host_agent.AGENT_VERSION == "1.4.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"
assert "disabled" in doc, "the marker read is not documented in the SCHEMA block"
assert "#off" in doc, "the marker's spelling is not documented in the schema"
def test_the_marker_grammar_is_one_constant_shared_by_writer_and_reader() -> None:
"""`split_marker` is the read side of the switch and `marker_for` the write side; both
spell the same grammar, which is the whole of the read side (phase 33 task 04).
A marked line keeps its text and gains a name; anything that is not exactly `#off <name>`
— an extra word, a bare `#off`, or the operator's own comment — is not a switch, and a line
without a comment is a line.
"""
assert host_agent.ROUTE_MARKER == "#off"
line, name = host_agent.split_marker("server s1 10.1.1.9:443 #off s1")
assert line == "server s1 10.1.1.9:443" and name == "s1"
# trailing whitespace after the name is the operator's formatting, not part of the name
assert host_agent.split_marker("server s1 10.1.1.9:443 #off s1 ") == (
"server s1 10.1.1.9:443", "s1")
line, name = host_agent.split_marker("server s1 10.1.1.9:443 #off")
assert name is None, "a bare #off with no name is a comment, not a switch"
line, name = host_agent.split_marker("server s1 10.1.1.9:443 #off s1 extra")
assert name is None, "two words after the marker is a comment, not a switch"
line, name = host_agent.split_marker("server s1 10.1.1.9:443 # off s1")
assert name is None, "a comment that merely mentions the word is not a switch"
line, name = host_agent.split_marker("server s1 10.1.1.9:443")
assert name is None
# the write side spells what the read side parses: the round trip is exact
for name_ in ("s1", "pool:8080", "web.example.com"):
marked, marker = host_agent.split_marker(f"line {host_agent.marker_for(name_)}")
assert marker == name_ and marked == "line"
def test_config_line_index_finds_the_line_the_marker_names() -> None:
"""The helper edits by line number, and the line is found by the marker, never the route's
text (a target string appears in many lines; the marker names exactly one of them)."""
text = "backend be\n server s1 10.1.1.9:443\n server s2 10.1.1.10:443 #off s1\n"
assert host_agent.config_line_index(text, "s1") == 3
assert host_agent.config_line_index(text, "s2") == -1
# a commented-out line is not a route: its marker is part of the comment, not a switch
text = "# server s3 10.1.1.11:443 #off s3\n"
assert host_agent.config_line_index(text, "s3") == -1
def test_the_haproxy_parser_reports_a_marked_server_line_disabled() -> None:
"""The read side of the switch: a `server` line carrying the helper's marker is
`disabled: true` and every other line `disabled: false` — and the marked line is still a
line: its target is parsed and its text kept, because the helper rewrites the value, not
the line, and a parser that dropped it would lose the route the switch is about."""
parsed = host_agent.parse_haproxy_config(
"backend be\n"
" server on 10.1.1.9:443\n"
" server off 10.1.1.10:443 #off off\n"
" server note 10.1.1.11:443 # a comment that is not the switch\n"
)
assert parsed["backends"][0]["servers"] == [
{"name": "on", "target": "10.1.1.9", "port": 443, "disabled": False},
{"name": "off", "target": "10.1.1.10", "port": 443, "disabled": True},
{"name": "note", "target": "10.1.1.11", "port": 443, "disabled": False},
]
def test_the_nginx_parser_reports_a_marked_line_disabled_in_all_three_shapes() -> None:
"""One marker, three route lines: a `proxy_pass`, an `upstream` member, a `map` entry.
The stream form is the fleet's own: a `proxy_pass $var` with no location, a `map` whose
entries are the SNI fan-out, and an `upstream` the http vhosts name. Each line's marker is
its own line's `disabled`, and an unmarked line beside a marked one is still on."""
parsed = host_agent.parse_nginx_config(
"stream {\n"
" server { listen 443; proxy_pass $targetBackend; }\n"
" server { listen 127.0.0.1:44450; return 502; }\n"
" map $ssl_preread_server_name $targetBackend {\n"
" default 10.1.0.1:8008;\n"
" on.example.com 10.1.0.1:8008;\n"
" off.example.com 127.0.0.1:44450; #off off.example.com\n"
" }\n"
"}"
"http {\n"
" upstream pool {\n"
" server 10.1.0.9:8080; #off 10.1.0.9:8080\n"
" server 10.1.0.10:8080;\n"
" }\n"
" server { listen 80; location / { proxy_pass http://pool; } }\n"
"}"
)
assert parsed["servers"][0]["proxy_passes"] == [
{"location": None, "scheme": None, "target": None, "variable": "targetBackend",
"disabled": False}]
assert parsed["servers"][1]["proxy_passes"] == [], "a `return` block proxies nowhere"
entries = parsed["maps"][0]["entries"]
assert [entry["key"] for entry in entries] == ["default", "on.example.com", "off.example.com"]
assert [entry["disabled"] for entry in entries] == [False, False, True]
assert entries[2]["target"] == {"host": "127.0.0.1", "port": 44450}, \
"the marked entry keeps its value: the state is the flag, not a rewritten line"
assert parsed["upstreams"] == [{"name": "pool", "servers": [
{"host": "10.1.0.9", "port": 8080, "disabled": True},
{"host": "10.1.0.10", "port": 8080, "disabled": False},
]}]
def test_the_nginx_section_exposes_the_marker_flag_on_the_wire(tmp_path: Path) -> None:
"""The section the dashboard reads is where the flag must land: a marked line in the file
is a `disabled: true` row in the `haproxy` and `nginx` sections, and an unmarked config
reports `false`, never a missing key (the dashboard's `enabled: false` is the flag's
absence of absence, and a row that could not answer the question would read as on)."""
write_file(tmp_path / "root", "opt/haproxy/etc/haproxy.cfg",
"backend be\n server on 10.1.1.9:443\n"
" server off 10.1.1.10:443 #off off\n")
write_file(tmp_path / "root", "opt/nginx/conf/nginx.conf",
"stream {\n"
" server {\n"
" listen 443;\n"
" proxy_pass $v; #off v\n"
" }\n"
" map $x $v {\n"
" default 10.1.0.1:8008;\n"
" a.test 10.1.0.1:8008;\n"
" }\n"
"}\n"
"http {\n"
" upstream p {\n"
" server 10.1.0.9:8080; #off 10.1.0.9:8080\n"
" }\n"
"}\n")
errors: list[str] = []
collector = host_agent.Collector(
str(tmp_path / "root"), haproxy_config="/opt/haproxy/etc/haproxy.cfg",
nginx_config="/opt/nginx/conf/nginx.conf")
haproxy = collector.haproxy(errors)
assert [row["disabled"] for row in haproxy["backends"][0]["servers"]] == [False, True]
nginx = collector.nginx(errors)
assert nginx["servers"][0]["proxy_passes"][0]["disabled"] is True
assert [row["disabled"] for row in nginx["upstreams"][0]["servers"]] == [True]
assert [row["disabled"] for row in nginx["maps"][0]["entries"]] == [False, False]
assert errors == []
# ---- 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
#: it from and with the fixture it came from. This is the phase's ground truth: the synthetic
#: shapes above are edge cases with pinned labels, and these are the box (invariant 7).
GATEWAY_CAPTURES = {
"etc/haproxy/haproxy.cfg": "gateway_haproxy.cfg",
"etc/nginx/nginx.conf": "gateway_nginx_main.conf",
"etc/nginx/conf.d/http.conf": "gateway_nginx_conf_d_http.conf",
"etc/nginx/stream.d/stream.conf": "gateway_nginx_stream_d_stream.conf",
"proc/net/tcp": "gateway_proc_net_tcp.txt",
"proc/net/tcp6": "gateway_proc_net_tcp6.txt",
}
#: What each captured document was read from, so a test can say which file to re-record.
GATEWAY_SOURCES = {
"gateway_haproxy.cfg": "/etc/haproxy/haproxy.cfg",
"gateway_nginx_main.conf": "/etc/nginx/nginx.conf",
"gateway_nginx_conf_d_http.conf": "/etc/nginx/conf.d/http.conf",
"gateway_nginx_stream_d_stream.conf": "/etc/nginx/stream.d/stream.conf",
"gateway_proc_net_tcp.txt": "/proc/net/tcp",
"gateway_proc_net_tcp6.txt": "/proc/net/tcp6",
}
def gateway_tree(root: Path) -> Path:
"""A fake root carrying the recorded gateway: the six captures at their real paths.
`mime.types` is deliberately absent, because it is absent on the host too — the main config
includes it and the agent's answer for that is one include line that costs nothing.
"""
write_file(root, "proc/stat",
"cpu 100 0 50 800 0 0 0 0 0 0\ncpu0 100 0 50 800 0 0 0 0 0 0\n")
write_file(root, "proc/meminfo", "MemTotal: 8000000 kB\nMemFree: 1000000 kB\n"
"MemAvailable: 4000000 kB\n")
for tree_path, fixture in GATEWAY_CAPTURES.items():
write_file(root, tree_path, (FIXTURES / fixture).read_text())
return root
def gateway_collector(root: Path) -> Any:
return host_agent.Collector(str(root), smartctl="/nonexistent/smartctl")
def test_the_gateway_captures_are_captures_and_say_so() -> None:
"""Each fixture names the remote path it came from and the script that read it.
That header is the difference between a capture and a file somebody typed, and the parsers
skip it — `#` is a comment in both config formats, and a `/proc` line whose 4th field is not
a kernel state is not a socket row — so provenance costs nothing on either side.
"""
for fixture, source in GATEWAY_SOURCES.items():
path = FIXTURES / fixture
assert path.is_file(), f"{fixture} is missing: run ./scripts/capture-gateway-config.sh"
head = path.read_text().splitlines()[:10]
assert head[0].startswith(f"# Verbatim {source} from "), head[:1]
assert any("capture-gateway-config.sh" in line for line in head), head
def test_the_recorded_gateway_nginx_tree_merges_the_documents_it_names(tmp_path: Path) -> None:
"""The real main config plus the real vhosts, through the real include pass.
`files` is the answer an operator can check against `nginx -t`, and the map is the SNI table
the whole point of the stream block: the map's *values* are the outbound targets, and the
server beside it has no target at all because the map decides per connection.
"""
errors: list[str] = []
section = gateway_collector(gateway_tree(tmp_path / "root")).nginx(errors)
assert section["available"] is True, errors
assert errors == [], f"the recorded tree should cost no error line: {errors}"
assert section["files"] == ["/etc/nginx/nginx.conf",
"/etc/nginx/stream.d/stream.conf",
"/etc/nginx/conf.d/http.conf"], \
"the merge set changed: re-run ./scripts/capture-gateway-config.sh"
assert len(section["servers"]) >= 10, "a gateway with a handful of vhosts is not this gateway"
maps = {cast("dict[str, Any]", m)["variable"]: m for m in section["maps"]}
assert "targetBackend" in maps and "connection_upgrade" in maps, list(maps)
entries = {cast("str", cast("dict[str, Any]", e)["key"]): cast("dict[str, Any]", e)
for e in maps["targetBackend"]["entries"]}
brain = entries["brain.reeseapps.com"]["target"]
assert brain == {"host": "reeseapps-brain-of-reese.reeselink.com", "port": 443}, brain
assert entries["default"]["target"] == {"host": "127.0.0.1", "port": 4443}, entries["default"]
# `map $http_upgrade` answers header words, and a word is not a host (rule 2).
upgrade = {cast("str", cast("dict[str, Any]", e)["key"]): cast("dict[str, Any]", e)["target"]
for e in maps["connection_upgrade"]["entries"]}
assert upgrade == {"default": None, "": None}, upgrade
def test_the_recorded_gateway_routes_by_sni_without_inventing_a_target(tmp_path: Path) -> None:
"""One vhost and the stream server, named: what the phase-29 page draws its pipes from."""
errors: list[str] = []
section = gateway_collector(gateway_tree(tmp_path / "root")).nginx(errors)
servers = [cast("dict[str, Any]", s) for s in section["servers"]]
def ports(server: dict[str, Any]) -> set[int | None]:
return {cast("dict[str, Any]", listen)["port"] for listen in server["listen"]}
nextcloud = [s for s in servers
if "nextcloud.reeseapps.com" in cast("list[str]", s["server_names"])
and 4443 in ports(s)]
assert nextcloud, "the recorded vhost for nextcloud on 4443 is gone"
passes = [cast("dict[str, Any]", p) for p in nextcloud[0]["proxy_passes"]]
assert {"host": "nextcloud.reeselink.com", "port": 11000} in [
cast("dict[str, Any]", p)["target"] for p in passes], passes
stream = [s for s in servers
if ports(s) == {443} and not cast("list[str]", s["server_names"])]
assert stream, "the stream server that terminates :443 and routes by SNI is gone"
variable_pass = [cast("dict[str, Any]", p) for p in stream[0]["proxy_passes"]
if p["variable"] == "targetBackend"]
assert variable_pass and variable_pass[0]["target"] is None, \
"`proxy_pass $targetBackend` must travel as the variable, never as a merged target"
def in_documentation_space(address: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool:
"""RFC 5737 (`203.0.113.0/24`) or RFC 3849 (`2001:db8::/32`) — the ranges the capture uses."""
return (address in ipaddress.ip_network("203.0.113.0/24")
or address in ipaddress.ip_network("2001:db8::/32"))
def proc_data_lines(text: str) -> list[list[str]]:
"""The socket rows of a `/proc/net/tcp` capture: no kernel header, no provenance header.
The kernel numbers its rows (`0:`, `1:` …), which is the one test that separates a socket from
a line of comment or a column title — both of which have four fields too.
"""
rows = []
for line in text.splitlines():
fields = line.split()
if len(fields) > 4 and fields[0].endswith(":") and fields[0][:-1].isdigit():
rows.append(fields)
return rows
def test_the_recorded_socket_tables_decode_every_row_they_hold() -> None:
"""Real `/proc` bytes, every address and port of them resolved.
The agent's decoder is being read against a kernel's own output rather than a hand-written
fragment: a row whose address comes back `None` means the spelling changed under us, which is
exactly what a fixture is for. And the rows the parser must *not* report are here in volume —
a proxy's `TIME_WAIT` pile — so their absence from the answer is measured, not assumed.
"""
transit = 0
for fixture, family in (("gateway_proc_net_tcp.txt", "v4"), ("gateway_proc_net_tcp6.txt", "v6")):
text = (FIXTURES / fixture).read_text()
rows = proc_data_lines(text)
assert rows, f"{fixture} holds no socket row at all"
listens, established = host_agent.parse_proc_net_tcp(text, family)
parsed = [*listens, *established]
assert parsed, f"{fixture} produced no socket at all"
broken = [row for row in parsed
if any(endpoint.get("addr") is None or endpoint.get("port") is None
for endpoint in ([row] if "addr" in row
else [row["local"], row["remote"]]))]
assert not broken, f"{fixture} has a row /proc's spelling does not explain: {broken[:3]}"
states = [fields[3] for fields in rows]
kept = states.count(host_agent.PROC_NET_STATE_LISTEN) \
+ states.count(host_agent.PROC_NET_STATE_ESTABLISHED)
assert len(parsed) == kept, (
f"{fixture}: the parser returned {len(parsed)} rows for {kept} LISTEN/ESTABLISHED "
f"sockets (states present: {sorted(set(states))})")
transit += len(states) - kept
def test_the_recorded_configs_and_the_recorded_sockets_agree(tmp_path: Path) -> None:
"""The config claims frontends; the kernel agrees, and one listener belongs to neither.
Two independent reads of one box at one moment: haproxy's `bind`s and nginx's `listen`s are
ports the gateway is answering on, and the agent's own port is a listener no proxy config
names. That last one is the case phase 29 must keep visible rather than drop — a socket with
no claim is a fact, not a join failure.
"""
collector = gateway_collector(gateway_tree(tmp_path / "root"))
errors: list[str] = []
haproxy = collector.haproxy(errors)
nginx = collector.nginx(errors)
sockets = collector.sockets(errors)
assert errors == [], errors
assert haproxy["available"] is True and nginx["available"] is True
assert sockets["available"] is True
claimed: set[int] = set()
for frontend in cast("list[dict[str, Any]]", haproxy["frontends"]):
claimed |= {cast("int", b["port"]) for b in cast("list[dict[str, Any]]", frontend["binds"])}
for server in cast("list[dict[str, Any]]", nginx["servers"]):
claimed |= {cast("int", ls["port"]) for ls in cast("list[dict[str, Any]]", server["listen"])
if ls["port"] is not None}
listening = {cast("int", row["port"]) for row in
cast("list[dict[str, Any]]", sockets["listens"]) if row["port"] is not None}
missing = claimed - listening
assert not missing, (
f"the configs claim ports {sorted(missing)} nothing is listening on: the two captures "
"were taken apart, or a service was down — re-run ./scripts/capture-gateway-config.sh")
assert 9120 in listening - claimed, \
"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.
The socket table is the one capture here that can hold somebody else's address, and on Linux
a global IPv6 is EUI-64 — it encodes the NIC's MAC, which is the identifier class
`capture-fixtures.sh` already refuses to record for a drive. Private, loopback, wildcard and
documentation addresses are all expected here; a globally routable one is a capture that got
away.
"""
offenders: list[str] = []
documented = 0
for fixture in ("gateway_proc_net_tcp.txt", "gateway_proc_net_tcp6.txt"):
for fields in proc_data_lines((FIXTURES / fixture).read_text()):
for column in (fields[1], fields[2]):
word = column.rpartition(":")[0]
packed = bytes.fromhex(word)
raw = b"".join(packed[i:i + 4][::-1] for i in range(0, len(packed), 4))
address = ipaddress.ip_address(raw)
# Only an IPv6Address has `ipv4_mapped` (3.14 added `ipv6_mapped` beside it).
subject = getattr(address, "ipv4_mapped", None) or address
if subject.is_global:
offenders.append(f"{fixture}: {subject}")
if in_documentation_space(subject):
documented += 1
assert not offenders, ("globally routable endpoints are in a tracked fixture: "
+ ", ".join(offenders)
+ " — scripts/capture-gateway-config.sh is the thing that stops them")
assert documented, ("no documentation-space address either, which means this fixture no "
"longer pins the substitution the capture makes")
def test_the_recorded_gateway_answers_all_three_sections_at_once(tmp_path: Path) -> None:
"""The phase's end state in one harness: one 1.2.0-shaped read of the captured gateway.
`haproxy` and `nginx` from the config tree, `sockets` from the kernel tables, all three in
one snapshot, nothing in `errors`, and the old sections exactly as they were — which is what
phase 29 builds its page on.
"""
snap = gateway_collector(gateway_tree(tmp_path / "root")).snapshot()
assert snap["agent_version"] == host_agent.AGENT_VERSION
for section in ("haproxy", "nginx", "sockets"):
assert snap[section]["available"] is True, snap["errors"]
assert len(snap["haproxy"]["frontends"]) == 6, "the recorded gateway has six frontends"
assert len(snap["haproxy"]["backends"]) == 6, "and one backend each"
assert snap["nginx"]["maps"], "the SNI map is the point of the nginx capture"
assert snap["sockets"]["listens"], "the socket capture must answer listeners"
assert core_errors(snap) == [], snap["errors"]
# The old sections are untouched by the add-on, which is the whole compatibility claim.
for section in ("host", "cpu", "memory", "filesystems", "disk_io", "network",
"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]}) == []
# ---- POST /route (the one write, agent 1.4.0) -------------------------------- #
class _Peer:
"""A routectl stub: an AF_UNIX socket that counts dials, reads one request line and
answers one line. `hang` holds the connection open without answering, which is what
makes the agent's 30 s budget (patched down in the test) fire. A dial it never sees
is a refusal that happened before the socket, so the counters are the pins."""
def __init__(self, tmp_path: Path, reply: str | None = None, *, hang: bool = False) -> None:
self.path = str(tmp_path / "peer.sock")
self.reply = reply
self.hang = hang
self.requests: list[dict[str, Any]] = []
self.connections = 0
self._server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
self._server.bind(self.path)
self._server.listen(8)
def serve(self, window: float) -> None:
deadline = time.monotonic() + window
self._server.settimeout(0.05)
while time.monotonic() < deadline:
try:
conn, _addr = self._server.accept()
except TimeoutError:
continue
except OSError:
return # stop() closed the server; the window is over
self.connections += 1
data = b""
try:
while b"\n" not in data:
chunk = conn.recv(65536)
if not chunk:
break
data += chunk
except OSError:
pass
if data:
self.requests.append(json.loads(data.decode()))
if self.hang:
continue # never answer; the agent's own timeout fires
if self.reply is not None:
with suppress(OSError):
conn.sendall(self.reply.encode())
conn.close()
def stop(self) -> None:
self._server.close()
def _start_peer(tmp_path: Path, **kwargs: Any) -> _Peer:
peer = _Peer(tmp_path, **kwargs)
threading.Thread(target=peer.serve, args=(6.0,), daemon=True).start()
return peer
def _https_post(url: str, context: ssl.SSLContext, body: bytes) -> tuple[int, dict]:
request = urllib.request.Request(url, data=body, method="POST")
try:
with urllib.request.urlopen(request, timeout=15, context=context) as response:
return response.status, _json_or_raw(response.read())
except urllib.error.HTTPError as error:
return error.code, _json_or_raw(error.read())
def _post(url: str, body: bytes, headers: dict[str, str] | None = None) -> tuple[int, dict]:
request = urllib.request.Request(url, data=body, headers=headers or {}, method="POST")
try:
with urllib.request.urlopen(request, timeout=5) as response:
return response.status, json.loads(response.read())
except urllib.error.HTTPError as error:
return error.code, json.loads(error.read())
def _route_body() -> bytes:
return json.dumps({"id": "nginx:443->brain.test:443", "action": "disable",
"dry_run": True}).encode()
def test_the_agent_is_1_4_0_and_the_write_path_is_a_documented_flag() -> None:
"""The version moves with the write; the two flags are on the command line, not in
config, and the status page and the docstring say the write exists and is gated."""
assert host_agent.AGENT_VERSION == "1.4.0"
assert host_agent.ROUTE_TIMEOUT_SECONDS == 30
help_text = subprocess.run([sys.executable, str(AGENT_PATH), "--help"],
capture_output=True, text=True, timeout=30).stdout
for flag in ("--allow-writes", "--routectl-socket", "--once"):
assert flag in help_text, flag
assert "POST /route" in (host_agent.__doc__ or "")
def test_a_post_without_a_client_certificate_is_refused(
tls_fleet: SimpleNamespace, tls_agent_server, tmp_path: Path) -> None:
"""The token is not a write credential: a probe that holds only the CA (the same
identity `install-agent.sh --status` uses) gets the certificate refusal, on a POST too."""
base = tls_agent_server(load_bundle(tmp_path, tls_fleet.bundle))
status, body = _https_post(base + "/route", client_context(tls_fleet.root_cert), _route_body())
assert status == 403
assert body["detail"] == "client certificate required"
def test_a_revoked_serial_is_refused_on_the_write_path_too(
tls_fleet: SimpleNamespace, tls_agent_server, tmp_path: Path) -> None:
"""A serial on the denylist is refused on every path, including the write — the
revoke in `/certs` bites a `POST /route` no later than it bites a `GET`."""
text = tls_fleet.bundle + f"{host_agent.REVOKED_LINE}{tls_fleet.stolen.serial}\n"
base = tls_agent_server(load_bundle(tmp_path, text))
status, body = _https_post(base + "/route",
client_context(tls_fleet.root_cert, str(tls_fleet.stolen.cert),
str(tls_fleet.stolen.key)),
_route_body())
assert status == 403
assert body["detail"] == "client certificate revoked"
def test_token_mode_cannot_write_and_token_is_not_a_write_credential(
agent_server, tmp_path: Path) -> None:
"""A token-mode agent (no bundle) answers the write 403 even when the bearer token is
right: `Authorization` is a read credential, and the write's gate is the certificate.
The 405 arm is the same handler: a POST to any other path is refused before anything
else is checked."""
base, _port = agent_server("k8s-ops-token")
bearer = {"Authorization": "Bearer k8s-ops-token"}
status, body = _post(base + "/route", _route_body(), bearer)
assert status == 403
assert body["detail"] == "writes require a certificate"
for path in ("/health", "/snapshot", "/info", "/schema", "/"):
status, body = _post(base + path, b"{}", bearer)
assert status == 405, path
assert body["detail"] == "no such write path", path
@pytest.mark.parametrize("body, detail", [
('{"id": "x", "action": "disable", "dry_run": true, "extra": 1}',
"body carries a key the write path does not take"),
('{"id": "x", "action": "restart", "dry_run": true}',
"action must be disable or enable"),
(json.dumps({"id": "y" * 257, "action": "disable"}),
"id must be a string of at most 256 characters"),
("[1, 2]", "body is not a JSON object"),
("not json at all", "body is not a JSON object"),
])
def test_a_body_outside_the_vocabulary_is_refused_before_the_peer_is_touched(
body: str, detail: str, tls_fleet: SimpleNamespace, tls_agent_server,
tmp_path: Path) -> None:
"""The helper decides what a route id means; the agent decides what a body is. A body
outside `{id, action, dry_run}` is refused before any socket opens, so a bad body can
never reach a proxy config through this door."""
peer = _start_peer(tmp_path, reply='{"ok": true, "applied": true}')
base = tls_agent_server(load_bundle(tmp_path, tls_fleet.bundle))
host_agent.Handler.allow_writes = True
host_agent.Handler.routectl_socket = peer.path
status, answer = _https_post(base + "/route",
client_context(tls_fleet.root_cert, tls_fleet.client_cert,
tls_fleet.client_key),
body.encode())
assert status == 422
assert answer["detail"] == detail
peer.stop()
assert peer.connections == 0
def test_an_oversized_body_is_refused_before_the_peer_is_touched(
tls_fleet: SimpleNamespace, tls_agent_server, tmp_path: Path) -> None:
peer = _start_peer(tmp_path, reply='{"ok": true, "applied": true}')
base = tls_agent_server(load_bundle(tmp_path, tls_fleet.bundle))
host_agent.Handler.allow_writes = True
host_agent.Handler.routectl_socket = peer.path
oversized = json.dumps({"id": "x" * 5000, "action": "disable"})
assert len(oversized) > host_agent._ROUTE_MAX_BODY
status, answer = _https_post(base + "/route",
client_context(tls_fleet.root_cert, tls_fleet.client_cert,
tls_fleet.client_key),
oversized.encode())
assert status == 422
assert answer["detail"] == "body longer than the write path accepts"
peer.stop()
assert peer.connections == 0
def test_writes_off_refuses_before_the_peer_is_touched(
tls_fleet: SimpleNamespace, tls_agent_server, tmp_path: Path) -> None:
"""The certificate verifies and the helper is listening, but the agent was started
without `--allow-writes`: the refusal is the agent's own, and the dial never happens —
a host that is not armed must not be reachable by an armed dashboard."""
peer = _start_peer(tmp_path, reply='{"ok": true, "applied": true}')
base = tls_agent_server(load_bundle(tmp_path, tls_fleet.bundle))
# allow_writes stays False (the fixture's default)
host_agent.Handler.routectl_socket = peer.path
status, answer = _https_post(base + "/route",
client_context(tls_fleet.root_cert, tls_fleet.client_cert,
tls_fleet.client_key),
_route_body())
assert status == 403
assert answer["detail"] == "writes are not enabled on this agent"
peer.stop()
assert peer.connections == 0
def test_a_host_without_the_helper_answers_503_not_a_write(
tls_fleet: SimpleNamespace, tls_agent_server, tmp_path: Path) -> None:
"""`--routectl-socket` empty (the flag's documented arm): armed host, no helper unit.
The answer is 503, not a write and not a 200 — a dashboard can tell "not installed"
from "refused" without guessing."""
base = tls_agent_server(load_bundle(tmp_path, tls_fleet.bundle))
host_agent.Handler.allow_writes = True
# routectl_socket stays None (the fixture's default)
status, answer = _https_post(base + "/route",
client_context(tls_fleet.root_cert, tls_fleet.client_cert,
tls_fleet.client_key),
_route_body())
assert status == 503
assert answer["detail"] == "no route helper on this host"
@pytest.mark.parametrize("action, dry_run", [("disable", True), ("enable", False)])
def test_a_good_request_reaches_the_peer_once_and_the_answer_is_passed_through(
action: str, dry_run: bool, tls_fleet: SimpleNamespace, tls_agent_server,
tmp_path: Path) -> None:
"""The agent is a forward: the body that reaches the helper is exactly `{id, action,
dry_run}` — no header, no token, no invented fields — and the helper's answer (a
refusal here, `applied` stays false) is the response, verbatim. One dial, one request:
a retry would be a second reload."""
peer = _start_peer(tmp_path, reply='{"ok": false, "detail": "ambiguous: whatever"}')
base = tls_agent_server(load_bundle(tmp_path, tls_fleet.bundle))
host_agent.Handler.allow_writes = True
host_agent.Handler.routectl_socket = peer.path
payload = {"id": "nginx:443->brain.test:443", "action": action, "dry_run": dry_run}
status, answer = _https_post(base + "/route",
client_context(tls_fleet.root_cert, tls_fleet.client_cert,
tls_fleet.client_key),
json.dumps(payload).encode())
assert status == 200
assert answer == {"ok": False, "detail": "ambiguous: whatever"}
assert peer.connections == 1
assert peer.requests == [payload]
peer.stop()
def test_the_peer_is_called_once_even_when_it_times_out(
tls_fleet: SimpleNamespace, tls_agent_server, tmp_path: Path,
monkeypatch: pytest.MonkeyPatch) -> None:
"""The helper hangs: the agent's own 30 s budget (patched to 0.3 s here) fires and the
answer is 502 with the reason, never a hang and never a second dial — a retry would be
a second reload of the fleet's only ingress."""
peer = _start_peer(tmp_path, hang=True)
monkeypatch.setattr(host_agent, "ROUTE_TIMEOUT_SECONDS", 0.3)
base = tls_agent_server(load_bundle(tmp_path, tls_fleet.bundle))
host_agent.Handler.allow_writes = True
host_agent.Handler.routectl_socket = peer.path
status, answer = _https_post(base + "/route",
client_context(tls_fleet.root_cert, tls_fleet.client_cert,
tls_fleet.client_key),
_route_body())
assert status == 502
assert answer["detail"] == "route helper timed out"
assert peer.connections == 1
assert len(peer.requests) == 1
peer.stop()
def test_an_unreadable_peer_answer_is_502_and_the_peer_is_not_retried(
tls_fleet: SimpleNamespace, tls_agent_server, tmp_path: Path) -> None:
"""A helper that answers bytes that are not a JSON object is a broken helper: 502 with
the reason, one dial. The agent invents no success and no detail of its own."""
peer = _start_peer(tmp_path, reply="nonsense is not json")
base = tls_agent_server(load_bundle(tmp_path, tls_fleet.bundle))
host_agent.Handler.allow_writes = True
host_agent.Handler.routectl_socket = peer.path
status, answer = _https_post(base + "/route",
client_context(tls_fleet.root_cert, tls_fleet.client_cert,
tls_fleet.client_key),
_route_body())
assert status == 502
assert answer["detail"] == "route helper answered something unreadable"
assert peer.connections == 1
peer.stop()
def test_every_other_path_is_405_on_the_write_path_and_a_query_string_is_not_a_second_path(
tls_fleet: SimpleNamespace, tls_agent_server, tmp_path: Path) -> None:
"""The write path is `/route` and nothing else, on a TLS host with writes on: every
other path is 405 before the certificate, the flag or the body are even looked at. A
query string on `/route` is the same path — the helper decides what the id means."""
peer = _start_peer(tmp_path, reply='{"ok": true, "applied": true}')
base = tls_agent_server(load_bundle(tmp_path, tls_fleet.bundle))
host_agent.Handler.allow_writes = True
host_agent.Handler.routectl_socket = peer.path
context = client_context(tls_fleet.root_cert, tls_fleet.client_cert, tls_fleet.client_key)
for path in ("/health", "/snapshot", "/info", "/schema", "/", "/nope"):
status, answer = _https_post(base + path, context, b"{}")
assert status == 405, path
assert answer["detail"] == "no such write path", path
status, answer = _https_post(base + "/route?from=drill", context, _route_body())
assert status == 200
assert peer.connections == 1
peer.stop()
# ---- the `disabled` field (the read side of the switch) ---------------------- #
def _haproxy_server(snapshot: dict[str, Any], name: str) -> dict[str, Any] | None:
for backend in snapshot["haproxy"]["backends"]:
for server in backend["servers"]:
if server["name"] == name:
return server
return None
def _nginx_proxy_pass(snapshot: dict[str, Any], host: str, port: int) -> dict[str, Any] | None:
for server in snapshot["nginx"]["servers"]:
for entry in server["proxy_passes"]:
target = entry.get("target")
if target is not None and target["host"] == host and target["port"] == port:
return entry
return None
def _nginx_map_entry(snapshot: dict[str, Any], key: str) -> dict[str, Any] | None:
for mapped in snapshot["nginx"]["maps"]:
for entry in mapped["entries"]:
if entry["key"] == key:
return entry
return None
def test_a_marked_haproxy_server_is_disabled_and_stays_in_the_document(gateway_snapshot) -> None:
"""`#off <name>` is a trailing comment, not a deletion: the marked line stays in the
config (the enable half needs it) and the agent says `disabled: true` on the node.
The unmarked neighbours keep `disabled: false` — the field is the marker, nothing else."""
snap = gateway_snapshot("marked")
gitea = _haproxy_server(snap, "gitea")
assert gitea is not None, "the marked line must stay in the document"
assert gitea["disabled"] is True
assert gitea["target"] == "reeseapps-gitea.reeselink.com" and gitea["port"] == 2222
others = [server for backend in snap["haproxy"]["backends"]
for server in backend["servers"] if server["name"] != "gitea"]
assert others, "the fixture's other servers must still be parsed"
assert all(server["disabled"] is False for server in others)
def test_an_unmarked_config_has_disabled_false_everywhere(gateway_snapshot) -> None:
snap = gateway_snapshot("full")
for backend in snap["haproxy"]["backends"]:
for server in backend["servers"]:
assert server["disabled"] is False, server["name"]
for server in snap["nginx"]["servers"]:
for entry in server["proxy_passes"]:
assert entry["disabled"] is False
for pool in snap["nginx"]["upstreams"]:
for member in pool["servers"]:
assert member["disabled"] is False
for mapped in snap["nginx"]["maps"]:
for entry in mapped["entries"]:
assert entry["disabled"] is False
def test_a_marked_vhost_proxy_pass_is_disabled(gateway_snapshot) -> None:
snap = gateway_snapshot("marked")
entry = _nginx_proxy_pass(snap, "nextcloud.reeselink.com", 11000)
assert entry is not None, "the marked proxy_pass must stay in the document"
assert entry["disabled"] is True
plain = gateway_snapshot("full")
plain_entry = _nginx_proxy_pass(plain, "nextcloud.reeselink.com", 11000)
assert plain_entry is not None
assert plain_entry["disabled"] is False
def test_a_marked_map_entry_is_disabled_and_its_value_is_the_sink(gateway_snapshot) -> None:
"""The helper's third arm: the map entry's value is rewritten to the sink address and
the line marked. `disabled` is the marker (true even though the target is a loopback),
and the unmarked capture keeps the real target with `disabled: false`."""
snap = gateway_snapshot("marked")
entry = _nginx_map_entry(snap, "brain.reeseapps.com")
assert entry is not None
assert entry["disabled"] is True
assert entry["target"] == {"host": "127.0.0.1", "port": 44450}
plain = gateway_snapshot("full")
plain_entry = _nginx_map_entry(plain, "brain.reeseapps.com")
assert plain_entry is not None
target = plain_entry["target"]
assert target is not None and target["host"] == "reeseapps-brain-of-reese.reeselink.com"
assert target["port"] == 443
def test_a_hand_edit_is_an_absence_not_a_marker(gateway_snapshot) -> None:
"""`hand_edited` is the operator's other half of the switch: the haproxy line is gone
(the route is off, nothing to mark), the vhost line is plain (on, `disabled: false`),
and the map value is deleted (the entry is there, its target is null, not marked)."""
snap = gateway_snapshot("hand_edited")
assert _haproxy_server(snap, "gitea") is None, "the deleted line is gone"
entry = _nginx_proxy_pass(snap, "nextcloud.reeselink.com", 11000)
assert entry is not None
assert entry["disabled"] is False
mapped = _nginx_map_entry(snap, "brain.reeseapps.com")
assert mapped is not None
assert mapped["target"] is None
assert mapped["disabled"] is False