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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user