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
+9 -6
View File
@@ -59,8 +59,10 @@ PROTECTED_API = (
"/api/disks/history",
# Phase 29: the ingress's pipes. `…/snapshot` carries the gateway's whole routing map — every
# domain it serves and every address it dials — so it is listed here for the same reason the
# bundle download is, whatever the payload happens to hold today.
"/api/gateway/snapshot", "/api/gateway/history",
# bundle download is, whatever the payload happens to hold today. Phase 33 task 05 adds the
# surface's one write, `…/route`, beside them: it is the app's second upstream exception
# (invariant 1) and the gate is what keeps it from answering anonymously.
"/api/gateway/snapshot", "/api/gateway/history", "/api/gateway/route",
"/api/llm/snapshot", "/api/llm/history", "/api/ups/snapshot",
"/api/ups/history", "/api/ups/commands", "/api/auth/status",
# Phase 15: the certificate authority's own surface. `/api/certs/<name>/bundle` is every
@@ -80,7 +82,7 @@ PROTECTED_PAGES = ("/", "/system", "/disks", "/llm", "/ups", "/gateway", "/setti
"/certs")
#: The one write this app has, with the fields the route needs (the UPS is unconfigured, so a
#: The write this file exercises, with the fields the route needs (the UPS is unconfigured, so a
#: request that reaches the route answers 404 — that is the point).
WRITE = {"ups": "apc", "command": "beeper.disable", "confirm": "yes"}
@@ -162,8 +164,9 @@ def app(store: SampleStore) -> Any:
"""An app with **no** sources configured, so a route that runs is distinguishable.
No targets, no engines, no NUT: every protected answer that got as far as its handler says
`404 {"configured": false}`, and nothing this file does can touch the fleet. The login POST
and the UPS POST are the two writes on the app, and only one of them dials anything.
`404 {"configured": false}`, and nothing this file does can touch the fleet. The login POST,
the UPS POST, and the gateway route POST are the writes on the app, and only two of them
dial anything.
"""
return create_app(settings_for(store.path), store)
@@ -1643,7 +1646,7 @@ def test_every_route_that_is_not_public_requires_a_session(anon: TestClient) ->
gated = [path for path in paths if not is_public(path)]
assert len(gated) == len(paths) - 4, "the public surface grew without this test being read"
assert {"/api/ups/command", "/api/auth/logout"} <= { # the two writes, both gated
assert {"/api/ups/command", "/api/gateway/route", "/api/auth/logout"} <= { # the three writes, all gated
path for path, methods in served if "POST" in methods}
for path in gated:
page = anon.get(path, headers=BROWSER_HEADERS)