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.
This commit is contained in:
@@ -0,0 +1,625 @@
|
||||
"""The route-switch root helper, tested unprivileged against a fake tree.
|
||||
|
||||
`deploy/dashboards-routectl.py` is the phase 33 writer: the dashboard reaches a
|
||||
gateway config file only through it (through the agent's `POST /route`, through the
|
||||
systemd-activated socket). Its safety property is the write protocol's order — locate,
|
||||
state, edit, validate, reload, prove, record — and the refusals that end it before a
|
||||
byte moves. So the tests run the **real helper** (`--root` pointed at a fake tree),
|
||||
against the **captured** gateway configs (`tests/fixtures/gateway_*.cfg|.conf`, the
|
||||
fleet's real ingress bytes, never a toy file), with `nginx` / `haproxy` / `systemctl`
|
||||
faked on `PATH` as scripts that record their argv and fail on order. The assertions
|
||||
are on the bytes: one line changed and nothing else, a byte-identical restore, a
|
||||
refusal that wrote nothing, the state sidecar's mode at birth, the sink's header and
|
||||
its removal rule, and the socket's peer gate. The helper's own `--serve` is driven the
|
||||
way the unit does (the connected socket on stdin), and the structural pins (no
|
||||
`shell=True`, no path from the wire, the one `systemctl` verb, the Python 3.10 floor)
|
||||
are greps over the file, the `auth.py`/`certs.py` precedent.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import ast
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import pwd
|
||||
import re
|
||||
import socket
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import types
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
DEPLOY = ROOT / "deploy"
|
||||
FIXTURES = ROOT / "tests" / "fixtures"
|
||||
HELPER = DEPLOY / "dashboards-routectl.py"
|
||||
AGENT = ROOT / "agent" / "host_agent.py"
|
||||
|
||||
#: The captured gateway tree, the same mapping `tests/conftest.py` lays out — the
|
||||
#: helper's `--root` reads the file the real ingress runs.
|
||||
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",
|
||||
}
|
||||
|
||||
#: The ids the dashboard publishes for these lines (phase 30's vocabulary), and the
|
||||
#: two refusals that are not ids at all.
|
||||
HAPROXY_ROUTE = "haproxy:5201->web.reeselink.com:5201" # the iperf3 server line
|
||||
VHOST_ROUTE = "nginx:4443->nextcloud.reeselink.com:11000" # the 4443 vhost's proxy_pass
|
||||
MAP_ROUTE = "nginx:443->reeseapps-brain-of-reese.reeselink.com:443" # brain.reeseapps.com
|
||||
MAP_SINK_ID = "nginx:443->127.0.0.1:44450" # while off, the map entry is addressed by its sink value
|
||||
IMMICH_ROUTE = "nginx:443->reeseapps-immich.reeselink.com:443"
|
||||
MAP_DEFAULT = "nginx:443->127.0.0.1:4443" # the map's default: a fallback, not a service
|
||||
SINK_HEADER = "# managed by dashboards-routectl — do not edit"
|
||||
IMMICH_ORIGINAL = " immich.reeseapps.com reeseapps-immich.reeselink.com:443;"
|
||||
|
||||
|
||||
def _tree(tmp_path: Path) -> Path:
|
||||
"""The captured configs, laid into a fake `--root` tree byte for byte."""
|
||||
root = tmp_path / "root"
|
||||
for relative, fixture in GATEWAY_TREE.items():
|
||||
target = root / relative
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_bytes((FIXTURES / fixture).read_bytes())
|
||||
return root
|
||||
|
||||
|
||||
def _stubs(tmp_path: Path) -> tuple[Path, Path]:
|
||||
"""Fake `nginx` / `haproxy` / `systemctl` on `PATH`: they record their argv in
|
||||
`$FAKE_CALLS_LOG` and fail on order — `FAKE_CHECK_FAIL` makes the checker exit 1
|
||||
with its own first line, `FAKE_RELOAD_FAIL` makes `reload` fail and `status`
|
||||
print the unit's line."""
|
||||
stubs = tmp_path / "stubs"
|
||||
stubs.mkdir()
|
||||
log = tmp_path / "calls.log"
|
||||
for name in ("nginx", "haproxy", "systemctl"):
|
||||
script = stubs / name
|
||||
script.write_text(
|
||||
"#!/bin/sh\n"
|
||||
f'printf "%s %s\\n" "{name}" "$*" >> "$FAKE_CALLS_LOG"\n'
|
||||
"case \"$1\" in\n"
|
||||
" -t|-c)\n"
|
||||
" if [ -n \"$FAKE_CHECK_FAIL\" ]; then\n"
|
||||
f' echo "fake {name}: configuration file test failed"\n'
|
||||
" exit 1\n"
|
||||
" fi\n"
|
||||
" ;;\n"
|
||||
" reload)\n"
|
||||
" if [ -n \"$FAKE_RELOAD_FAIL\" ]; then exit 1; fi\n"
|
||||
" ;;\n"
|
||||
" status)\n"
|
||||
' if [ -n "$FAKE_RELOAD_FAIL" ]; then echo "fake unit: reload failed"; fi\n'
|
||||
" ;;\n"
|
||||
"esac\n"
|
||||
"exit 0\n",
|
||||
encoding="utf-8")
|
||||
script.chmod(0o755)
|
||||
return stubs, log
|
||||
|
||||
|
||||
def _run(tmp_path: Path, stubs: Path, log: Path, args: list[str], *,
|
||||
check_fail: bool = False, reload_fail: bool = False,
|
||||
agent_module: str | None = None
|
||||
) -> tuple[subprocess.CompletedProcess[str], dict, list[str]]:
|
||||
"""One helper invocation the way the operator runs it by hand: the real binary,
|
||||
the fake tree, the fake proxy commands. Returns the process, the one JSON answer
|
||||
line, and the recorded calls so far (the log is appended across invocations)."""
|
||||
argv = [sys.executable, str(HELPER),
|
||||
"--root", str(tmp_path / "root"),
|
||||
"--agent-module", str(AGENT) if agent_module is None else agent_module,
|
||||
"--state", str(tmp_path / "state.json"),
|
||||
"--sink-conf", "/etc/nginx/stream.d/00-dashboards-sink.conf",
|
||||
"--sink-address", "127.0.0.1:44450",
|
||||
"--nginx-unit", "nginx", "--haproxy-unit", "haproxy", *args]
|
||||
env = {
|
||||
"PATH": f"{stubs}{os.pathsep}{os.environ.get('PATH', '')}",
|
||||
"FAKE_CALLS_LOG": str(log),
|
||||
"FAKE_CHECK_FAIL": "1" if check_fail else "",
|
||||
"FAKE_RELOAD_FAIL": "1" if reload_fail else "",
|
||||
"HOME": str(tmp_path),
|
||||
}
|
||||
proc = subprocess.run(argv, capture_output=True, text=True, env=env, timeout=120)
|
||||
lines = [line for line in proc.stdout.splitlines() if line.strip()]
|
||||
answer: dict = json.loads(lines[-1]) if lines else {}
|
||||
calls = log.read_text().splitlines() if log.exists() else []
|
||||
return proc, answer, calls
|
||||
|
||||
|
||||
def _serve(tmp_path: Path, tree: Path, allow_uid: str, request: bytes) -> tuple[int, str]:
|
||||
"""`--serve` the way the unit does: the connected socket on fd 0/fd 1, one
|
||||
request, one answer line. (The `tests/test_deploy_scripts.py` transport pin,
|
||||
re-used for the wire-body gates the overview demands in this file.)"""
|
||||
child, parent = socket.socketpair(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
proc = subprocess.Popen(
|
||||
[sys.executable, str(HELPER), "--serve",
|
||||
"--allow-uid", allow_uid,
|
||||
"--agent-module", str(AGENT),
|
||||
"--root", str(tree),
|
||||
"--state", str(tmp_path / "serve-state.json"),
|
||||
"--sink-conf", "/etc/nginx/stream.d/00-dashboards-sink.conf",
|
||||
"--sink-address", "127.0.0.1:44450",
|
||||
"--nginx-unit", "nginx", "--haproxy-unit", "haproxy"],
|
||||
stdin=child.fileno(), stdout=child.fileno(),
|
||||
stderr=subprocess.PIPE, close_fds=True)
|
||||
parent.sendall(request)
|
||||
answer = parent.recv(65536).decode("utf-8").strip()
|
||||
parent.close()
|
||||
err = proc.stderr.read().decode() if proc.stderr is not None else ""
|
||||
proc.wait(timeout=60)
|
||||
child.close()
|
||||
if proc.returncode not in (0, 1):
|
||||
raise AssertionError(
|
||||
f"the helper exited {proc.returncode} instead of answering: {answer!r} {err}")
|
||||
return proc.returncode, answer
|
||||
|
||||
|
||||
def _load(name: str, path: Path) -> types.ModuleType:
|
||||
"""Import a stdlib module by file path, the way the helper imports the agent."""
|
||||
spec = importlib.util.spec_from_file_location(name, path)
|
||||
assert spec is not None and spec.loader is not None, f"no loader for {path}"
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def _snapshot(tree: Path) -> dict[Path, bytes]:
|
||||
return {path: path.read_bytes() for path in sorted(tree.rglob("*")) if path.is_file()}
|
||||
|
||||
|
||||
def _sink(tree: Path) -> Path:
|
||||
return tree / "etc/nginx/stream.d/00-dashboards-sink.conf"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tree(tmp_path: Path) -> Path:
|
||||
return _tree(tmp_path)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def stubs(tmp_path: Path) -> tuple[Path, Path]:
|
||||
return _stubs(tmp_path)
|
||||
|
||||
|
||||
def test_disabling_one_route_changes_exactly_one_line(tree: Path, stubs: tuple[Path, Path],
|
||||
tmp_path: Path) -> None:
|
||||
"""The whole write, against the fakes: one hunk, one line, every other byte
|
||||
identical — asserted on the bytes, not on a count. The reload is the one verb
|
||||
the file executes, and the state sidecar names the write."""
|
||||
stubs_dir, log = stubs
|
||||
hap = tree / "etc/haproxy/haproxy.cfg"
|
||||
before_files = _snapshot(tree)
|
||||
before = hap.read_bytes()
|
||||
|
||||
proc, answer, calls = _run(tmp_path, stubs_dir, log,
|
||||
["disable", "--id", HAPROXY_ROUTE, "--apply"])
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
assert answer == {"ok": True, "applied": True, "validated": True,
|
||||
"reloaded": True, "status": "", "state": "off"}, answer
|
||||
|
||||
after = hap.read_bytes()
|
||||
b, a = before.decode("utf-8").splitlines(), after.decode("utf-8").splitlines()
|
||||
assert len(b) == len(a), "the edit is in place: one line, never an add or a remove"
|
||||
changed = [i for i in range(len(b)) if b[i] != a[i]]
|
||||
assert len(changed) == 1, f"exactly one line changed byte for byte, nothing else: {changed}"
|
||||
index = changed[0]
|
||||
assert b[index] == " server iperf3 web.reeselink.com:5201 check inter 5s"
|
||||
assert a[index] == b[index] + " #off iperf3", (
|
||||
"the marker is the whole trailing comment; the original stands verbatim before it")
|
||||
for path, blob in before_files.items():
|
||||
if path != hap:
|
||||
assert path.read_bytes() == blob, f"{path} moved when one haproxy line was marked"
|
||||
assert "systemctl reload haproxy" in calls, "the one verb, for the one unit"
|
||||
assert "systemctl reload nginx" not in calls
|
||||
assert not [c for c in calls if "status" in c], (
|
||||
f"a successful reload never asks the unit for its status: {calls}")
|
||||
|
||||
state = json.loads((tmp_path / "state.json").read_text(encoding="utf-8"))
|
||||
assert set(state) == {HAPROXY_ROUTE}
|
||||
row = state[HAPROXY_ROUTE]
|
||||
assert row["action"] == "disable"
|
||||
assert row["file"] == "/etc/haproxy/haproxy.cfg"
|
||||
assert row["written"] == b[index] + " #off iperf3"
|
||||
assert row["original"] == b[index]
|
||||
assert row["sha256"] == hashlib.sha256(after).hexdigest()
|
||||
|
||||
|
||||
def test_the_vhost_arm_comments_the_proxy_pass_line_and_restores(
|
||||
tree: Path, stubs: tuple[Path, Path], tmp_path: Path) -> None:
|
||||
"""The second arm: the vhost's `proxy_pass` is commented the same way, the
|
||||
sibling vhost on another port is untouched, and the re-enable is byte-identical."""
|
||||
stubs_dir, log = stubs
|
||||
http = tree / "etc/nginx/conf.d/http.conf"
|
||||
before = http.read_bytes()
|
||||
|
||||
proc, answer, _calls = _run(tmp_path, stubs_dir, log,
|
||||
["disable", "--id", VHOST_ROUTE, "--apply"])
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
assert answer["state"] == "off"
|
||||
lines = http.read_text().splitlines()
|
||||
assert " proxy_pass http://nextcloud.reeselink.com:11000; #off nextcloud-11000" in lines
|
||||
assert " proxy_pass http://nextcloud.reeselink.com:11001;" in lines, (
|
||||
"the 8443 vhost's own line is a different fact; it does not move")
|
||||
|
||||
proc, answer, calls = _run(tmp_path, stubs_dir, log,
|
||||
["enable", "--id", VHOST_ROUTE, "--apply"])
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
assert answer["state"] == "on"
|
||||
assert http.read_bytes() == before, "the re-enable restores the file byte for byte"
|
||||
assert "systemctl reload nginx" in calls
|
||||
assert "systemctl reload haproxy" not in calls
|
||||
|
||||
|
||||
def test_a_disabled_map_entry_points_at_the_sink_and_keeps_its_original(
|
||||
tree: Path, stubs: tuple[Path, Path], tmp_path: Path) -> None:
|
||||
"""The third arm: the map entry is **rewritten to the sink** (commenting it would
|
||||
fall through to the map's `default` and mis-route the domain), the sink file is
|
||||
created on first need with its header, and the re-enable — addressed by the sink
|
||||
value the entry carries while off — restores the file byte-identical and removes
|
||||
the sink the helper wrote. A `dry_run` writes nothing at all, not even the sink."""
|
||||
stubs_dir, log = stubs
|
||||
stream = tree / "etc/nginx/stream.d/stream.conf"
|
||||
sink = _sink(tree)
|
||||
original_line = " brain.reeseapps.com reeseapps-brain-of-reese.reeselink.com:443;"
|
||||
before = stream.read_bytes()
|
||||
|
||||
# the dry run: the unified diff and nothing else — no file, no checker, no reload
|
||||
proc, answer, calls = _run(tmp_path, stubs_dir, log, ["disable", "--id", MAP_ROUTE])
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
assert answer["ok"] is True and answer["applied"] is False
|
||||
assert answer["changed"] is True and answer["dry_run"] is True
|
||||
assert f"-{original_line}" in answer["diff"]
|
||||
assert "+ brain.reeseapps.com 127.0.0.1:44450; #off brain.reeseapps.com" in answer["diff"]
|
||||
assert stream.read_bytes() == before, "a dry run writes nothing"
|
||||
assert not sink.exists(), "a dry run writes no file at all (not even the sink's)"
|
||||
assert calls == [], f"a dry run neither validates nor reloads: {calls}"
|
||||
assert not (tmp_path / "state.json").exists()
|
||||
|
||||
proc, answer, _calls = _run(tmp_path, stubs_dir, log,
|
||||
["disable", "--id", MAP_ROUTE, "--apply"])
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
assert answer["state"] == "off"
|
||||
assert sink.is_file(), "the sink is created on first need, before the checker runs"
|
||||
sink_text = sink.read_text(encoding="utf-8")
|
||||
assert sink_text.splitlines()[0] == SINK_HEADER
|
||||
assert "listen 127.0.0.1:44450;" in sink_text
|
||||
assert "proxy_pass" not in sink_text, "the block, not a vhost, is what the page's `sink_endpoint` recognises"
|
||||
lines = stream.read_text().splitlines()
|
||||
assert " brain.reeseapps.com 127.0.0.1:44450; #off brain.reeseapps.com" in lines
|
||||
assert original_line not in lines
|
||||
|
||||
# the enable is addressed by the sink value the entry carries while off
|
||||
proc, answer, calls = _run(tmp_path, stubs_dir, log,
|
||||
["enable", "--id", MAP_SINK_ID, "--apply"])
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
assert answer["state"] == "on"
|
||||
assert stream.read_bytes() == before, "the re-enable restores the file byte-identical to the pre-drill capture"
|
||||
assert not sink.exists(), "the helper removes the sink it wrote once nothing points at it"
|
||||
assert json.loads((tmp_path / "state.json").read_text(encoding="utf-8")) == {}
|
||||
assert "systemctl reload nginx" in calls
|
||||
|
||||
|
||||
def test_the_map_default_is_refused_and_nothing_is_written(
|
||||
tree: Path, stubs: tuple[Path, Path], tmp_path: Path) -> None:
|
||||
"""The map's `default` is the fallback for every unmapped SNI, not a service:
|
||||
the id that addresses it is refused at the wire, before a byte moves or a
|
||||
checker runs."""
|
||||
stubs_dir, log = stubs
|
||||
before = _snapshot(tree)
|
||||
proc, answer, calls = _run(tmp_path, stubs_dir, log,
|
||||
["disable", "--id", MAP_DEFAULT, "--apply"])
|
||||
assert proc.returncode == 1
|
||||
assert answer == {"ok": False, "applied": False, "refused": "map-default"}
|
||||
assert _snapshot(tree) == before, "a refused write writes nothing"
|
||||
assert calls == [], f"a wire-level refusal reaches neither the checker nor the unit: {calls}"
|
||||
|
||||
|
||||
def test_an_unknown_id_is_refused(tree: Path, stubs: tuple[Path, Path],
|
||||
tmp_path: Path) -> None:
|
||||
stubs_dir, log = stubs
|
||||
before = _snapshot(tree)
|
||||
proc, answer, calls = _run(tmp_path, stubs_dir, log,
|
||||
["disable", "--id", "haproxy:9999->10.0.0.9:9999", "--apply"])
|
||||
assert proc.returncode == 1
|
||||
assert answer == {"ok": False, "detail": "not-found"}
|
||||
assert _snapshot(tree) == before
|
||||
assert calls == []
|
||||
|
||||
|
||||
def test_disabling_a_disabled_route_changes_nothing_and_reloads_nothing(
|
||||
tree: Path, stubs: tuple[Path, Path], tmp_path: Path) -> None:
|
||||
"""Step 2: a route that is already off is an answer, not a write — no file, no
|
||||
checker, no reload. The fake `systemctl` log stays empty on the second call."""
|
||||
stubs_dir, log = stubs
|
||||
hap = tree / "etc/haproxy/haproxy.cfg"
|
||||
_proc, _answer, calls = _run(tmp_path, stubs_dir, log,
|
||||
["disable", "--id", HAPROXY_ROUTE, "--apply"])
|
||||
after_first = hap.read_bytes()
|
||||
seen = len(calls)
|
||||
|
||||
proc, answer, calls = _run(tmp_path, stubs_dir, log,
|
||||
["disable", "--id", HAPROXY_ROUTE, "--apply"])
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
assert answer == {"ok": True, "applied": False, "changed": False,
|
||||
"id": HAPROXY_ROUTE, "state": "off"}
|
||||
assert hap.read_bytes() == after_first
|
||||
assert len(calls) == seen, f"an already-off route changes nothing and reloads nothing: {calls[seen:]}"
|
||||
|
||||
|
||||
def test_a_failing_validator_restores_the_previous_bytes(tree: Path, stubs: tuple[Path, Path],
|
||||
tmp_path: Path) -> None:
|
||||
"""Step 5: the checker says no, the previous bytes go back byte-identical,
|
||||
nothing reloads, and no state is recorded — the answer carries the checker's
|
||||
own first line and the non-zero exit says it out."""
|
||||
stubs_dir, log = stubs
|
||||
hap = tree / "etc/haproxy/haproxy.cfg"
|
||||
before = hap.read_bytes()
|
||||
|
||||
proc, answer, calls = _run(tmp_path, stubs_dir, log,
|
||||
["disable", "--id", HAPROXY_ROUTE, "--apply"],
|
||||
check_fail=True)
|
||||
assert proc.returncode == 1
|
||||
assert answer["ok"] is False and answer["applied"] is False
|
||||
assert answer["validated"] is False and answer["restored"] is True
|
||||
assert answer["detail"] == "fake haproxy: configuration file test failed"
|
||||
assert hap.read_bytes() == before, "the restore is byte-identical"
|
||||
assert [c for c in calls if c.startswith("haproxy -c")] == [
|
||||
f"haproxy -c -f {tmp_path / 'root' / 'etc/haproxy/haproxy.cfg'}"], calls
|
||||
assert not [c for c in calls if c.startswith("systemctl")], (
|
||||
f"a failed validator reloads nothing: {calls}")
|
||||
assert not (tmp_path / "state.json").exists(), "a failed write records no state"
|
||||
|
||||
|
||||
def test_a_failing_reload_says_so_and_leaves_the_file_applied(
|
||||
tree: Path, stubs: tuple[Path, Path], tmp_path: Path) -> None:
|
||||
"""Step 6: the config is correct and the reload is not — the file stays
|
||||
applied, the answer says so with the unit's own status line, and the state is
|
||||
still recorded (the write happened; the reload is the operator's next action)."""
|
||||
stubs_dir, log = stubs
|
||||
hap = tree / "etc/haproxy/haproxy.cfg"
|
||||
|
||||
proc, answer, calls = _run(tmp_path, stubs_dir, log,
|
||||
["disable", "--id", HAPROXY_ROUTE, "--apply"],
|
||||
reload_fail=True)
|
||||
assert proc.returncode == 0, "a reload failure is an answered call, not a crash"
|
||||
assert answer["ok"] is True and answer["applied"] is True
|
||||
assert answer["validated"] is True and answer["reloaded"] is False
|
||||
assert answer["status"] == "fake unit: reload failed"
|
||||
assert " #off iperf3" in hap.read_text(), "the applied file stays applied"
|
||||
assert "systemctl reload haproxy" in calls
|
||||
assert "systemctl status haproxy" in calls, "the answer carries the unit's own line"
|
||||
state = json.loads((tmp_path / "state.json").read_text(encoding="utf-8"))
|
||||
assert HAPROXY_ROUTE in state, "the write happened; the state says so"
|
||||
|
||||
|
||||
def test_reload_is_never_a_restart() -> None:
|
||||
"""The only verb this file executes is `systemctl reload <unit>`: a restart
|
||||
severs every live connection through the fleet's only ingress. Pinned at the
|
||||
call position (the docstring's negation is backticked, never an argv)."""
|
||||
src = HELPER.read_text(encoding="utf-8")
|
||||
verbs = re.findall(r'\["systemctl", "(\w+)"', src)
|
||||
assert verbs == ["reload", "status"], f"the only systemctl verbs: {verbs}"
|
||||
assert not re.search(r'["\']restart["\']', src), (
|
||||
"no quoted `restart` anywhere: the docstring refuses it in words, the argv never carries it")
|
||||
|
||||
|
||||
def test_the_helper_imports_the_agents_parser_and_refuses_without_it(
|
||||
tree: Path, stubs: tuple[Path, Path], tmp_path: Path) -> None:
|
||||
"""The parser is the agent's, imported by file path: a helper that cannot
|
||||
import it refuses to run at all, names the path, and writes nothing — two
|
||||
parsers reading one config are two opinions about the same file."""
|
||||
stubs_dir, log = stubs
|
||||
before = _snapshot(tree)
|
||||
missing = tmp_path / "no-such-agent.py"
|
||||
|
||||
proc, answer, calls = _run(tmp_path, stubs_dir, log,
|
||||
["disable", "--id", HAPROXY_ROUTE, "--apply"],
|
||||
agent_module=str(missing))
|
||||
assert proc.returncode == 1
|
||||
assert answer == {"ok": False,
|
||||
"detail": f"cannot import the agent module {missing}: not a file"}
|
||||
assert _snapshot(tree) == before
|
||||
assert calls == []
|
||||
|
||||
|
||||
def test_state_is_0600_and_drift_is_refused(tree: Path, stubs: tuple[Path, Path],
|
||||
tmp_path: Path) -> None:
|
||||
"""The sidecar's mode is its birth mode, and `enable` restores only what this
|
||||
helper wrote: a hand-edited line is `drift` — a refusal with no byte change,
|
||||
never a guess. The other arm is a line this helper never wrote (no state row)."""
|
||||
stubs_dir, log = stubs
|
||||
hap = tree / "etc/haproxy/haproxy.cfg"
|
||||
_proc, _answer, _calls = _run(tmp_path, stubs_dir, log,
|
||||
["disable", "--id", HAPROXY_ROUTE, "--apply"])
|
||||
state_path = tmp_path / "state.json"
|
||||
assert stat.S_IMODE(state_path.stat().st_mode) == 0o600, "the mode is the birth mode"
|
||||
|
||||
off_line = " server iperf3 web.reeselink.com:5201 check inter 5s #off iperf3"
|
||||
assert off_line in hap.read_text(encoding="utf-8")
|
||||
edited = hap.read_text(encoding="utf-8").replace(
|
||||
off_line, " server iperf3 web.reeselink.com:5201 check inter 5s backup")
|
||||
hap.write_text(edited, encoding="utf-8")
|
||||
|
||||
seen = len(_calls)
|
||||
proc, answer, calls = _run(tmp_path, stubs_dir, log,
|
||||
["enable", "--id", HAPROXY_ROUTE, "--apply"])
|
||||
assert proc.returncode == 1
|
||||
assert answer["ok"] is False and answer["applied"] is False and answer["drift"] is True
|
||||
assert answer["detail"] == "the line was edited after the helper wrote it"
|
||||
assert hap.read_text(encoding="utf-8") == edited, "drift is refused, not guessed at: no byte moves"
|
||||
assert len(calls) == seen, f"a drift refusal touches neither the checker nor the unit: {calls[seen:]}"
|
||||
|
||||
# the other arm: a line this helper never wrote (no state row at all)
|
||||
(tmp_path / "state.json").unlink()
|
||||
vhost = tree / "etc/nginx/conf.d/http.conf"
|
||||
before_vhost = vhost.read_bytes()
|
||||
proc, answer, _calls = _run(tmp_path, stubs_dir, log,
|
||||
["enable", "--id", VHOST_ROUTE, "--apply"])
|
||||
assert proc.returncode == 1
|
||||
assert answer["ok"] is False and answer["drift"] is True
|
||||
assert answer["detail"] == "no state row: this helper did not write the line"
|
||||
assert vhost.read_bytes() == before_vhost
|
||||
|
||||
|
||||
def test_serve_refuses_a_peer_that_is_not_the_agent_uid(tree: Path, stubs: tuple[Path, Path],
|
||||
tmp_path: Path) -> None:
|
||||
"""The second look, in the fake-peer test: a connected caller whose uid is not
|
||||
`--allow-uid` is refused before the body is read — the gate never opens for a
|
||||
caller it was not given."""
|
||||
if hasattr(os, "geteuid") and os.geteuid() == 0:
|
||||
pytest.skip("running as root: the foreign-uid arm needs a foreign uid")
|
||||
code, answer = _serve(tmp_path, tree, "root",
|
||||
(json.dumps({"id": HAPROXY_ROUTE, "action": "show"}) + "\n").encode())
|
||||
assert code == 1
|
||||
body = json.loads(answer)
|
||||
assert body["ok"] is False
|
||||
# the refusal names the caller's own uid and the user it was not: the gate
|
||||
# opened for no one, and the dialer gets the reason, not a timeout
|
||||
assert body["detail"] == f"peer: uid {os.geteuid()} is not root", (
|
||||
f"the refusal names the uid and the user: {body}")
|
||||
|
||||
|
||||
def test_serve_accepts_exactly_id_action_dry_run(tree: Path, stubs: tuple[Path, Path],
|
||||
tmp_path: Path) -> None:
|
||||
"""The wire's gate, from the accepted side: a named peer's `show` is answered,
|
||||
a wider body is refused with the wire's own sentence, and a `dry_run` write
|
||||
answers the diff without writing anything (not even the sink's file)."""
|
||||
if hasattr(os, "geteuid") and os.geteuid() == 0:
|
||||
pytest.skip("running as root: the accept arm needs the caller to be the allowed uid")
|
||||
me = pwd.getpwuid(os.geteuid()).pw_name
|
||||
|
||||
code, answer = _serve(tmp_path, tree, me,
|
||||
(json.dumps({"id": HAPROXY_ROUTE, "action": "show"}) + "\n").encode())
|
||||
assert code == 0
|
||||
body = json.loads(answer)
|
||||
assert body == {"ok": True, "id": HAPROXY_ROUTE, "state": "on",
|
||||
"file": "/etc/haproxy/haproxy.cfg"}, body
|
||||
|
||||
code, answer = _serve(tmp_path, tree, me,
|
||||
(json.dumps({"id": HAPROXY_ROUTE, "action": "show",
|
||||
"command": "reboot"}) + "\n").encode())
|
||||
assert code == 1
|
||||
body = json.loads(answer)
|
||||
assert body == {"ok": False, "detail": "body: exactly {id, action, dry_run}"}
|
||||
|
||||
hap = tree / "etc/haproxy/haproxy.cfg"
|
||||
before = hap.read_bytes()
|
||||
code, answer = _serve(tmp_path, tree, me,
|
||||
(json.dumps({"id": HAPROXY_ROUTE, "action": "disable",
|
||||
"dry_run": True}) + "\n").encode())
|
||||
assert code == 0
|
||||
body = json.loads(answer)
|
||||
assert body["ok"] is True and body["applied"] is False and body["dry_run"] is True
|
||||
assert "diff" in body
|
||||
assert hap.read_bytes() == before, "a dry run over the wire writes nothing"
|
||||
assert not _sink(tree).exists()
|
||||
|
||||
|
||||
def test_no_shell_and_no_path_from_the_wire() -> None:
|
||||
"""The structural pins (the `auth.py`/`certs.py` precedent): every subprocess
|
||||
call is a fixed argv list — no `shell=True` — and the wire takes an id, never a
|
||||
path, a line number or a pattern; `--serve` accepts exactly `{id, action,
|
||||
dry_run}` and every other key is a refusal."""
|
||||
src = HELPER.read_text(encoding="utf-8")
|
||||
assert not re.search(r"subprocess\.\w+\([^)]*shell\s*=", src, re.S), (
|
||||
"no `shell=` at a call position (the docstring's negation is backticked; "
|
||||
"every call is a fixed argv list — `shell=True` included)")
|
||||
flags = re.findall(r'add_argument\(\s*"([^"]+)"', src)
|
||||
assert flags, "the flags must be there for the pin to mean anything"
|
||||
assert not {flag for flag in flags if flag in ("--file", "--line", "--pattern")}, (
|
||||
f"the wire takes an id, never a path/line/pattern; the flags are {flags}")
|
||||
assert 'set(body) - {"id", "action", "dry_run"}' in src, (
|
||||
"the wire's gate: anything wider than {id, action, dry_run} is a refusal")
|
||||
assert '"body: exactly {id, action, dry_run}"' in src
|
||||
|
||||
|
||||
def test_the_sink_file_is_left_alone_when_someone_elses_wrote_it(
|
||||
tree: Path, stubs: tuple[Path, Path], tmp_path: Path) -> None:
|
||||
"""An operator's file at the sink's path is not the helper's to overwrite:
|
||||
the `map` disable refuses before the config write, and the file is untouched."""
|
||||
stubs_dir, log = stubs
|
||||
sink = _sink(tree)
|
||||
operator_file = "# an operator's file\nserver {\n listen 127.0.0.1:44451;\n}\n"
|
||||
sink.write_text(operator_file, encoding="utf-8")
|
||||
before = _snapshot(tree)
|
||||
|
||||
proc, answer, calls = _run(tmp_path, stubs_dir, log,
|
||||
["disable", "--id", MAP_ROUTE, "--apply"])
|
||||
assert proc.returncode == 1
|
||||
assert answer == {"ok": False,
|
||||
"detail": "sink-file-not-ours: /etc/nginx/stream.d/00-dashboards-sink.conf"}
|
||||
assert _snapshot(tree) == before, "the refusal writes nothing (the operator's file is byte-identical)"
|
||||
assert calls == [], f"the refusal happens before the checker and the unit: {calls}"
|
||||
|
||||
|
||||
def test_the_sink_is_removed_only_when_it_is_empty_of_our_routes(
|
||||
tree: Path, stubs: tuple[Path, Path], tmp_path: Path) -> None:
|
||||
"""Two routes off: two lines carry the sink value, and enabling by that id is
|
||||
`ambiguous` — two facts, never a guess, because no line number crosses the
|
||||
wire. After the operator restores one line by hand, the enable succeeds and the
|
||||
sink goes with it. The remains-arm is pinned on the helper's own function: a
|
||||
map entry still pointing at the sink keeps the file (the wire cannot reach that
|
||||
arm — the ambiguity above is why)."""
|
||||
stubs_dir, log = stubs
|
||||
stream = tree / "etc/nginx/stream.d/stream.conf"
|
||||
sink = _sink(tree)
|
||||
|
||||
_proc, _answer, _calls = _run(tmp_path, stubs_dir, log,
|
||||
["disable", "--id", MAP_ROUTE, "--apply"])
|
||||
_proc, _answer, _calls = _run(tmp_path, stubs_dir, log,
|
||||
["disable", "--id", IMMICH_ROUTE, "--apply"])
|
||||
assert sink.is_file()
|
||||
|
||||
proc, answer, _calls = _run(tmp_path, stubs_dir, log,
|
||||
["enable", "--id", MAP_SINK_ID, "--apply"])
|
||||
assert proc.returncode == 1
|
||||
assert answer["ok"] is False
|
||||
assert "ambiguous" in answer["detail"], f"two lines at the sink are two facts: {answer}"
|
||||
|
||||
text = stream.read_text(encoding="utf-8")
|
||||
text = text.replace(" immich.reeseapps.com 127.0.0.1:44450; #off immich.reeseapps.com",
|
||||
IMMICH_ORIGINAL)
|
||||
stream.write_text(text, encoding="utf-8")
|
||||
|
||||
proc, answer, _calls = _run(tmp_path, stubs_dir, log,
|
||||
["enable", "--id", MAP_SINK_ID, "--apply"])
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
assert answer["state"] == "on"
|
||||
assert not sink.exists(), "the helper removes the sink it wrote once no route still points at it"
|
||||
|
||||
# the remains-arm, on the helper's own function: a route still points at the sink
|
||||
helper = _load("routectl_under_test", HELPER)
|
||||
agent = _load("agent_under_test", AGENT)
|
||||
ctx = argparse.Namespace(root=str(tree),
|
||||
haproxy_config="/etc/haproxy/haproxy.cfg",
|
||||
nginx_config="/etc/nginx/nginx.conf",
|
||||
state_file=str(tmp_path / "sink-state.json"),
|
||||
sink_conf="/etc/nginx/stream.d/00-dashboards-sink.conf",
|
||||
sink_address="127.0.0.1:44450",
|
||||
nginx_unit="nginx", haproxy_unit="haproxy",
|
||||
actor="manual", allow_uid="nobody")
|
||||
write = helper.Write(ctx, agent, "nginx", 443, "127.0.0.1", 44450, MAP_SINK_ID)
|
||||
sink.write_text(f"{SINK_HEADER}\nserver {{\n listen 127.0.0.1:44450;\n}}\n",
|
||||
encoding="utf-8")
|
||||
documents = {"/etc/nginx/stream.d/stream.conf":
|
||||
"map $ssl_preread_server_name $targetBackend {\n"
|
||||
" default 127.0.0.1:4443;\n"
|
||||
" a.reeseapps.com 127.0.0.1:44450;\n"
|
||||
"}\n"}
|
||||
write.remove_sink_if_empty(documents)
|
||||
assert sink.is_file(), "a route still points at the sink: it stays"
|
||||
|
||||
|
||||
def test_the_helper_parses_on_python_3_10() -> None:
|
||||
"""The helper is installed beside the agent and must run on a host's Python
|
||||
3.10: the floor is in the header, and the file carries no 3.11+ syntax."""
|
||||
src = HELPER.read_text(encoding="utf-8")
|
||||
assert "# requires: python >=3.10" in src
|
||||
ast.parse(src, feature_version=(3, 10))
|
||||
Reference in New Issue
Block a user