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:
2026-11-01 11:50:51 -05:00
parent 0271387053
commit 86c8bf4992
91 changed files with 7568 additions and 519 deletions
+62 -1
View File
@@ -34,6 +34,14 @@ def pytest_addoption(parser: pytest.Parser) -> None:
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:
@@ -257,7 +265,8 @@ GATEWAY_TREE = {
"proc/net/tcp6": "gateway_proc_net_tcp6.txt",
}
GATEWAY_MODES = ("full", "resolved", "predates", "no_haproxy", "no_nginx", "sockets_silent")
GATEWAY_MODES = ("full", "resolved", "predates", "no_haproxy", "no_nginx", "sockets_silent",
"marked", "hand_edited")
@pytest.fixture
@@ -287,6 +296,16 @@ def gateway_snapshot(host_agent_module: Any, tmp_path: Path) -> Any:
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:
@@ -307,6 +326,48 @@ def gateway_snapshot(host_agent_module: Any, tmp_path: Path) -> Any:
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)