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.
626 lines
31 KiB
Python
626 lines
31 KiB
Python
"""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))
|