Files
dashboards/tests/conftest.py
T
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

445 lines
21 KiB
Python

"""Shared fixtures: real captures for shape, fakes for behaviour.
Anything that must match an upstream (an agent snapshot, an engine's /metrics text, the NUT
wire protocol) is asserted against a verbatim capture in tests/fixtures/. Everything that
needs a *failure* — a dead host, a refused command, a counter reset — is a fake here.
The `live` marker is the bridge to real hardware: those tests dial the fleet, the engines,
or upsd, so they SKIP unless you pass `--live` and have set the env vars they read (see
tests/test_live_fleet.py). That keeps `uv run pytest` hermetic and offline while still
letting `uv run pytest -m live --live` catch an upstream that renamed something.
"""
from __future__ import annotations
import copy
import json
import sys
from pathlib import Path
from typing import Any
import pytest
def pytest_addoption(parser: pytest.Parser) -> None:
parser.addoption(
"--live",
action="store_true",
default=False,
help="run tests marked `live` against the real fleet/engines/upsd (they skip otherwise)",
)
parser.addoption(
"--store-perf",
action="store_true",
default=False,
help="run tests marked `store_perf`: the phase-14 timing measurements against the "
"seeded production-shaped store (they skip otherwise — they cost minutes, not ms)",
)
parser.addoption(
"--allow-live-writes",
action="store_true",
default=False,
help="allow the one opt-in live write test (phase 33's route round-trip on "
"haproxy:5201->web.reeselink.com:5201) to switch its named route; without it "
"the test skips and the default live suite stays read-only",
)
def pytest_collection_modifyitems(config: pytest.Config, items: list[pytest.Item]) -> None:
# Two opt-ins, one shape: a marker whose tests are real but expensive, skipped by default
# so `uv run pytest` stays fast and hermetic. `--store-perf` is the phase-14 half of the
# rule `--live` established; `scripts/store_perf_probe.py` is the CLI for the same numbers.
skips = {
"live": pytest.mark.skip(
reason="needs --live and a reachable target (see tests/test_live_fleet.py)"),
"store_perf": pytest.mark.skip(
reason="needs --store-perf (a seeded ~6 M-row store; see tests/store_perf.py)"),
}
enabled = {
"live": bool(config.getoption("--live")),
"store_perf": bool(config.getoption("--store-perf")),
}
for item in items:
for marker, skip in skips.items():
if not enabled[marker] and marker in item.keywords:
item.add_marker(skip)
ROOT = Path(__file__).resolve().parents[1]
SRC = ROOT / "src"
if str(SRC) not in sys.path:
sys.path.insert(0, str(SRC))
from dashboards.rates import RateEstimator # noqa: E402
from dashboards.sources.host import HostCollector, parse_host_snapshot # noqa: E402
from dashboards.store import SampleStore # noqa: E402
FIXTURES = Path(__file__).resolve().parent / "fixtures"
@pytest.fixture
def db(tmp_path: Path) -> Any:
store = SampleStore(tmp_path / "t.sqlite3")
try:
yield store
finally:
store.close()
@pytest.fixture
def store(db: Any) -> Any:
"""Alias: some tests read better with `store`, others with `db`. Same object."""
return db
@pytest.fixture
def agent_snapshot() -> dict[str, Any]:
"""A real driveripper snapshot (Threadripper + R9700, 48 logical CPUs)."""
return json.loads((FIXTURES / "agent_driveripper.json").read_text())
@pytest.fixture
def agent_snapshots() -> dict[str, dict[str, Any]]:
"""Every captured host, keyed by name — fleet code paths need >1 host to mean anything."""
out: dict[str, dict[str, Any]] = {}
for path in sorted(FIXTURES.glob("agent_*.json")):
out[path.stem.removeprefix("agent_")] = json.loads(path.read_text())
return out
@pytest.fixture
def host_scrape() -> Any:
"""`host_scrape(snapshot, cpu_busy=10.0, mutate=None)` -> one parsed host payload.
Two scrapes are needed before any rate exists (the agent reports counters; the
dashboard owns the window), and the payload the API serves is the second one. `mutate`
edits the snapshot before both scrapes so a test can put a value on a chosen side of
`severity.THRESHOLDS`; `cpu_busy` sets the CPU percentage over the window directly.
Driven from the real capture so a schema drift breaks here rather than in a browser.
"""
def scrape(snapshot: dict[str, Any], *, name: str = "driveripper", cpu_busy: float = 10.0,
mutate: Any = None) -> dict[str, Any]:
snap = copy.deepcopy(snapshot)
if mutate is not None:
mutate(snap)
collector = HostCollector(RateEstimator(span=60.0))
parse_host_snapshot(name, snap, collector, now=1_000.0)
second = copy.deepcopy(snap)
second["ts"] = 1_010.0
modes = second["cpu"]["modes"]
# busy = (Δtotal - Δidle)/Δtotal, and iowait is left untouched so it rates at 0
modes["total"] += 1_000.0
modes["idle"] += 1_000.0 - (cpu_busy * 10.0)
return parse_host_snapshot(name, second, collector, now=1_010.0).payload
return scrape
# ---- phase 09: physical disks (the /disks source and page) ----------------- #
def _load_host_agent() -> Any:
"""The stdlib agent module, loaded by path: it is not an installed package (and must
not become one — it ships as a single file to every host). Same loader as
tests/test_agent.py, kept private here because only the disk fixtures need it."""
import importlib.util # noqa: PLC0415 - only the disk fixtures load the agent
path = FIXTURES.parent.parent / "agent" / "host_agent.py"
spec = importlib.util.spec_from_file_location("host_agent", path)
assert spec and spec.loader
module = importlib.util.module_from_spec(spec)
sys.modules.setdefault("host_agent", module)
spec.loader.exec_module(module)
return module
#: The two `smartctl` shapes the fleet has: NVMe (health-log fields) and ATA (the vendor
#: attribute table). `tests/fixtures/smartctl_reference_*.txt` — labels verbatim from the
#: deployed smartmontools 7.5, values synthetic (see the header of each file).
SMART_SHAPES = ("nvme", "ata_hdd")
#: The agent's own words for the way SMART goes missing that the page must name: the
#: binary is not installed. Mirrors `host_agent.Collector._smart_pass()`; the agent test
#: `test_missing_smartctl_binary_is_one_error_for_the_whole_pass` pins that the line it
#: really writes still contains the word `sources.disks.MISSING_BINARY_WORD` looks for, so
#: a rewording fails a test instead of turning "install smartmontools" into "unknown".
SMART_MISSING_BINARY = ("smart: /usr/sbin/smartctl not found — install smartmontools and "
"give this user device read access (disk group) for SMART data")
@pytest.fixture(scope="session")
def host_agent_module() -> Any:
"""The stdlib agent as a module, for the tests that run it outside `tests/test_agent.py`.
Loaded by path because it is one file that ships to every host and must not become a package
(see `_load_host_agent`). The phase-15 collector tests need one module object for both the
bundle they load and the handler they serve, so the TLS dial runs the artifact the fleet
really runs rather than a look-alike.
"""
return _load_host_agent()
@pytest.fixture
def smart_shape() -> Any:
"""`smart_shape('nvme')` / `smart_shape('ata_hdd')` -> the capture, parsed by the agent.
Fixture-driven on purpose (AGENTS.md rule 7): the `smart` blocks a `/disks` test hands
to the source are the recorded `smartctl` text run through the agent's own parser, so a
field rename on either side of the wire fails a test instead of printing a dash.
"""
agent = _load_host_agent()
def shape(kind: str = "nvme") -> dict[str, Any]:
text = (FIXTURES / f"smartctl_reference_{kind}.txt").read_text()
return agent.parse_smart_output(text)
return shape
@pytest.fixture
def disk_snapshot(agent_snapshots: dict[str, dict[str, Any]], smart_shape: Any) -> Any:
"""`build(host="driveripper", mode="healthy")` — a real capture with SMART attached.
The fleet captures predate the agent's SMART pass, so there is no single capture with
both halves. This assembles the two that exist: the physical disk rows come from the
host capture verbatim, the `smart` blocks from the recorded smartmontools shapes, and
the failure modes are the ones `host_agent.smart()` actually produces.
Modes:
* `healthy` — every disk reads, alternating the NVMe and ATA shapes
* `no_binary` — every `smart` is null plus the agent's single "not found" line
* `denied` — the last disk is null with its own permission line (SMART is installed)
* `silent` — every `smart` is null with no error line (`--no-smart`, or an agent that
predates the pass: the dashboard cannot tell why, and must not guess)
* `failed` — one drive answering `FAILED`, the rest healthy
"""
def build(host: str = "driveripper", *, mode: str = "healthy",
mutate: Any = None) -> dict[str, Any]:
import copy # noqa: PLC0415 - every build starts from the untouched capture
snapshot = copy.deepcopy(agent_snapshots[host])
devices = (snapshot.get("disk_io") or {}).get("devices") or []
errors: list[str] = list(snapshot.get("errors") or [])
for index, device in enumerate(devices):
if mode == "no_binary":
device["smart"] = None
continue
if mode == "silent":
device["smart"] = None
continue
if mode == "denied" and index == len(devices) - 1:
device["smart"] = None
continue
shape = smart_shape(SMART_SHAPES[index % len(SMART_SHAPES)])
block = dict(shape)
# the identity in the capture is the disk the row is about; the shape files
# describe one drive each, so the row keeps its own model and the counters
# come from the shape
block["model"] = block.get("model") or device.get("model")
if mode == "failed" and index == 0:
block["overall_health"] = "FAILED"
device["smart"] = block
if mode == "no_binary":
errors.append(SMART_MISSING_BINARY)
if mode == "denied" and devices:
name = str(devices[-1].get("name"))
errors.append(f"smart: {name}: Smartctl open device: /dev/{name} failed: "
"Permission denied")
snapshot["errors"] = errors
if mutate is not None:
mutate(snapshot)
return snapshot
return build
# ---- phase 29: the gateway page (the /gateway source, API and page) -------- #
#: The captured gateway tree, as the paths the agent's own `--root` resolution expects.
#: These are the verbatim phase-28 captures (`scripts/capture-gateway-config.sh`), so the
#: config half of a `/gateway` test is the file the real ingress runs, not a memory of it.
GATEWAY_TREE = {
"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",
}
GATEWAY_MODES = ("full", "resolved", "predates", "no_haproxy", "no_nginx", "sockets_silent",
"marked", "hand_edited")
@pytest.fixture
def gateway_snapshot(host_agent_module: Any, tmp_path: Path) -> Any:
"""`build(mode="full")` -> a 1.2.0-style agent snapshot carrying the three proxy sections.
Not a hand-written dict: the captured configs and `/proc` tables are laid into a fake
`--root` tree and read by the **real agent `Collector`**, so the sections handed to
`sources/gateway.py` are the bytes agent 1.2.0 serves. A field rename on either side of the
wire therefore fails a source test instead of drawing an empty pipe on the page (rule 7).
One more axis is a **version**, not a degradation:
* `resolved` — the same tree as `full` plus agent 1.3.0's fourth section, the recorded
`gateway_resolver.json` attached verbatim (the document `capture-gateway-config.sh` reads
back with `getent ahostsv4/6` on the box that dials). This is what the deployed ingress
serves after phase 32 task 05, and it is the arm in which a name-written route becomes
countable.
Modes are otherwise the degradation arms the page has to render:
* `full` — all three sections `available: true`, live sockets from the capture, and **no**
`resolver` key at all: a 1.2.0 agent, or a 1.3.0 one run with `--no-resolve`. Phase 30's
payload, verbatim, which is what the phase-32 additivity pins compare against
* `predates` — none of the three keys at all (an agent older than 1.2.0, or `--no-proxy`)
* `no_haproxy` / `no_nginx` — that one config file is absent: `available: false` plus the
agent's own **not found** line, the other half of the page untouched
* `sockets_silent` — neither `/proc` table readable: `available: null`, so every live count
must come back `None` and never `0`
* `marked` / `hand_edited` — the same captured tree with the route switch's three edits
applied (phase 33 task 04): `marked` is the helper's work — one haproxy `server` line,
one vhost `proxy_pass` line and one stream `map` entry (value rewritten to the sink
address) each carrying the agent's `#off <name>` trailing comment (the whole comment, one
name, nothing else — `split_marker`'s grammar), plus the helper's sink `server` block in
a `stream.d` file; `hand_edited` is the same three shapes with the markers removed — the
`server` line deleted, the `proxy_pass` line plain, the map entry's value deleted and
no sink file. Both are the **captured** config plus an edit, never a toy file: the
marker-awareness tests need the fleet's real backends and map, and the suppression has to
be marker-caused, not shape-caused.
"""
def build(mode: str = "full") -> dict[str, Any]:
if mode not in GATEWAY_MODES:
raise AssertionError(f"unknown gateway snapshot mode {mode!r}")
root = tmp_path / f"root-{mode}"
for relative, fixture in GATEWAY_TREE.items():
if not fixture:
continue
target = root / relative
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text((FIXTURES / fixture).read_text())
if mode == "no_haproxy":
(root / "etc/haproxy/haproxy.cfg").unlink()
if mode == "no_nginx":
for path in (root / "etc/nginx/nginx.conf", root / "etc/nginx/conf.d/http.conf",
root / "etc/nginx/stream.d/stream.conf"):
path.unlink()
if mode == "sockets_silent":
(root / "proc/net/tcp").unlink()
(root / "proc/net/tcp6").unlink()
if mode in ("marked", "hand_edited"):
# The one haproxy line the switch turns off: the helper marks the `server` line
# itself (the agent's `split_marker` reads the trailing comment; the name is one
# word and the whole comment) and the line stays in the document. `hand_edited`
# is the same shape an operator left by deleting the line.
haproxy_cfg = root / "etc/haproxy/haproxy.cfg"
text = haproxy_cfg.read_text()
line = " server gitea reeseapps-gitea.reeselink.com:2222 check inter 5s"
assert line in text, "the capture's gitea server line moved; update the injection"
if mode == "marked":
haproxy_cfg.write_text(text.replace(line, f"{line} #off gitea"))
else:
haproxy_cfg.write_text(text.replace(line + "\n", ""))
# The one vhost line: the marker goes after the `;` (the tokenizer attaches the
# end-of-line comment to the statement that closed it). `hand_edited` keeps the
# line — removing a marker leaves the route declared and on.
vhost = root / "etc/nginx/conf.d/http.conf"
text = vhost.read_text()
line = " proxy_pass http://nextcloud.reeselink.com:11000;"
assert line in text, "the capture's nextcloud vhost line moved; update the injection"
if mode == "marked":
vhost.write_text(text.replace(line, f"{line} #off nextcloud-11000"))
# The one map entry: the helper rewrites the value to the sink address and marks
# the line; `hand_edited` is the value deleted and nothing else left behind.
stream = root / "etc/nginx/stream.d/stream.conf"
text = stream.read_text()
line = (" brain.reeseapps.com "
"reeseapps-brain-of-reese.reeselink.com:443;")
assert line in text, "the capture's brain map entry moved; update the injection"
if mode == "marked":
stream.write_text(text.replace(line, " brain.reeseapps.com "
"127.0.0.1:44450; #off brain.reeseapps.com"))
else:
stream.write_text(text.replace(line, " brain.reeseapps.com ;"))
if mode == "marked":
# The helper's sink file, created on first need by the write path: a stream
# `server` that listens on loopback, names no `server_name` and proxies nowhere
# (its whole job is to answer a switched-off route). It is the block, not the
# port, that `sink_endpoint()` recognises, and the glob include picks it up.
(root / "etc/nginx/stream.d/00-dashboards-sink.conf").write_text(
"# managed by dashboards-routectl — do not edit\n"
"server {\n listen 127.0.0.1:44450;\n}\n")
errors: list[str] = []
collector = host_agent_module.Collector(root)
snapshot: dict[str, Any] = {
"ok": True, "schema": 1, "ts": 1_760_000_000.0, "host": "gateway",
"agent_version": host_agent_module.AGENT_VERSION, "errors": errors,
}
if mode != "predates":
snapshot["haproxy"] = collector.haproxy(errors)
snapshot["nginx"] = collector.nginx(errors)
snapshot["sockets"] = collector.sockets(errors)
if mode == "resolved":
# The recorded capture, not a re-derivation: the names are the ones `target_names()`
# derives from the config fixtures above and the answers are libc's on the router, so
# the join is tested against the document the agent will actually serve (rule 7). The
# dashboard never asks a resolver, and nothing here does either.
snapshot["resolver"] = json.loads((FIXTURES / "gateway_resolver.json").read_text())
return snapshot
return build
@pytest.fixture
def metrics_text() -> Any:
"""metrics_text('gufo') / metrics_text('vllm') -> the captured exposition verbatim."""
def load(engine: str) -> str:
path = FIXTURES / f"metrics_{engine}.txt"
if not path.exists():
pytest.skip(f"missing fixture {path.name} — run scripts/capture-fixtures.sh --with-llm")
return path.read_text()
return load
@pytest.fixture
def rates() -> RateEstimator:
return RateEstimator(span=10.0)
# ---- phase 14: the production-shaped store the hot-path budgets measure ---- #
#
# Here rather than in tests/test_store.py because two files price the same budgets — the store
# paths in `test_store.py`, the HTTP paths in `test_api.py` — and a second seeded 6.4 M-row file
# (or, worse, a second *differently shaped* one) would make the two sets of numbers describe two
# databases. Session-scoped because seeding costs ~25 s, and cached on disk (`data/perf/`, see
# `store_perf.cache_dir`) because the before/after has to be the same physical file both times.
@pytest.fixture(scope="session")
def perf_store_path() -> Path:
"""The cached seeded store, built on first use and re-verified on every reuse.
Opt-in tests only: nothing in the default run asks for this fixture, so `uv run pytest` never
pays for the seed. `DASH_PERF_STORE_DIR` relocates the file (see `store_perf.cache_dir`).
"""
import store_perf # noqa: PLC0415 - only the phase-14 tests seed a store
try:
return store_perf.ensure_seeded(log=lambda line: print(line, flush=True))
except OSError as exc: # pragma: no cover - only a read-only checkout hits this
pytest.skip(f"cannot build the seeded store ({exc}) — set DASH_PERF_STORE_DIR "
"to a writable directory to run the phase-14 measurements")
@pytest.fixture
def perf_store(perf_store_path: Path) -> Any:
"""The seeded store, opened per test and closed after (one handle per test, one file)."""
import store_perf # noqa: PLC0415 - see perf_store_path
store = store_perf.open_store(perf_store_path)
try:
yield store
finally:
store.close()