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
@@ -0,0 +1,54 @@
# Phase 34 — Radiance is a first-class engine: a third `/llm` panel that says what its /metrics and /stats publish, nothing invented
**Source:** operator request, 2026-10-24 — "add support for a new llm engine 'radiance' which is running on driveripper now. Check it out at http://driveripper.reeselink.com:8000/ and come up with a plan." + the two decisions taken with them in that conversation: **radiance replaced vLLM at driveripper:8000** (the dev config's `VLLM_BASE_URL` points at the engine that is now radiance, so the dev config moves to `RADIANCE_BASE_URL`; vLLM *code* stays — the API shape is additive-only and other deployments may run vLLM), and **the per-position spec-acceptance curve is in** (seven positions, each its own acceptance ratio).
**Story:** n/a
**Context:** `src/dashboards/sources/llm.py` (the gufo/vLLM field maps, the rule-2 throughput precedence, `HISTORY_METRICS`, `_sessions()`, `fleet_totals()`, `SEVERITY_CELLS`), `src/dashboards/config.py` (the `*_BASE_URL`/`*_API_KEY` engine settings, `any_source_enabled`), `src/dashboards/collectors.py` (`fetch_engine`, the best-effort `/slots` fetch, `llm_jobs`), `src/dashboards/api.py` (the `llm_router` 404 gate, `/api/config`'s llm block, the summary's llm block, the history defaults), `src/dashboards/alerts.py` (the cell-driven `_engine_alerts`), `src/dashboards/severity.py` (the only threshold table), `src/dashboards/web/static/page-llm.js` + `pages/llm.html` (the `only:` scoping, strip cells, slot grid), `tests/test_llm_source.py` / `tests/test_collectors.py` / `tests/test_api.py` / `tests/test_config.py` / `tests/test_live_fleet.py`, `scripts/capture-fixtures.sh`, `.env.dev` / `.env.dev.example`, `README.md`.
## Objective
The dashboard treats radiance (a vLLM-derived engine, v1.15.1, model `juggernaut`, on `driveripper:8000`) as a **first-class third engine** — `llm:radiance` — with its own field map pinned by a verbatim capture, its native windowed throughput gauges winning per rule 2, its `/stats` endpoint as the session source (radiance's answer to gufo's `/slots`), and a radiance strip on `/llm` (queue cap, version/uptime, per-card VRAM, the 7-bar spec-acceptance curve) — while gufo and vLLM keep their exact payloads, history names, and behaviour.
## Scope decisions taken with the operator (binding for this phase)
| # | Decision | Consequence |
|---|----------|-------------|
| 1 | **Radiance replaced vLLM at driveripper:8000.** | `.env.dev`'s `VLLM_BASE_URL` line becomes `RADIANCE_BASE_URL` (same address). The vLLM engine **code and env var stay** — `VLLM_BASE_URL` still configures a vLLM engine for other deployments; only the dev fleet's config moves. `AGENTS.md`'s fleet table says `vLLM :8000` for driveripper; that is the one stale fact the final pass fixes. |
| 2 | **The per-position spec-acceptance curve is in.** | `radiance:draft_position_acceptance_ratio{position="0".."6"}` renders as a 7-bar hand-rolled SVG on the radiance strip, `role="img"` + `aria-label`, pinned in `tests/test_api.py` beside every other chart. Snapshot-only: the seven per-position ratios do **not** get history series (a bounded store, rule 9); the overall `draft_acceptance_ratio` does. |
| 3 | **Engine internals the operator cannot act on are out.** | `link`/`passes`/`steps*`/`ckpt*`/`hit_cuts`/`released`/`kv_stream*`/`lookup*`/`linear*`/`attn_cached`/`client_disconnect`/`stream_overflow` stay out of the payload (flavour text is not helpful information). What the payload carries: native + measured throughputs, KV usage + max tokens/bytes, queue depth/cap/admission-rejected, preemptions, the prefix hit-rate gauge, overall + per-position spec acceptance, the decode-step gauges, `requests[]` sessions, per-card VRAM, `model`/`version`/`uptime_s`/`startup_s`, lifetime `tokens_total`. |
| 4 | **No new thresholds, no new write path.** | The only rated radiance cell is KV pressure, via the **existing** `llm.kv_cache_usage_pct` row (radiance publishes the same 0-1 ratio the vLLM map already feeds it). Spec acceptance is an efficiency number, not a verdict — uncoloured. No radiance endpoint accepts writes; the app's only upstream write stays `POST /api/ups/command` (invariant 1 unchanged). No new dependency, no new `/api` route — the served-route walk and the public-surface pin are untouched. |
## Dependencies
- `23_llm_rate_responsiveness` (complete) — the per-cadence `RateEstimator` and the refused-window → `n/a` + `Δ<window>` semantics this phase reuses for the measured Δ of radiance's vLLM-shaped counters.
- `13_llm_page_and_decode_rate` (complete) — the engine-panel/strip shape, the `only:` scoping, the served-byte pins this phase extends.
- `24_dev_mtls_rollout` (complete) — the direct-dial fleet; radiance is dialled the same plain way the other engines are (no token, no certificate — the operator's address answers unauthenticated).
- Independent of `33_gateway_route_switching` (todo, in progress) — orthogonal module; no shared file besides `api.py`'s already-present llm routes, which this phase does not touch.
## Tasks
1. `01_capture_radiance_ground_truth.md` — `capture-fixtures.sh` records radiance's `/metrics`, `/stats` and `/v1/models` verbatim into `tests/fixtures/`, and the fixture name-set is pinned hermetically.
2. `02_config_and_the_engine_registry.md` — `RADIANCE_BASE_URL` / `RADIANCE_API_KEY`, one `llm_engines()` registry replacing the five hardcoded gufo/vllm enumeration sites, the dev config moves to radiance.
3. `03_the_radiance_field_map.md` — `RADIANCE_SERIES`/`RADIANCE_HISTOGRAMS`, `parse_radiance_metrics()`, the radiance `_sessions()` arm, the additive history series, `fleet_totals`' seams copy.
4. `04_the_radiance_job.md` — `fetch_engine` learns the best-effort `/stats` fetch, the `llm:radiance` job.
5. `05_the_llm_page_learns_radiance.md` — the radiance strip (queue cap, version/uptime, per-card VRAM, the 7-bar spec curve), the `only:` scopes, `requests[]` rows, the empty-state text.
6. `06_docs_and_live_verify.md` — the live read-only test, the fixture re-capture, README/AGENTS.md pass, the live-data screenshots.
## Testing & Quality
- Unit/integration: the radiance parse against the verbatim fixtures (name-set drift pin, rule-2 precedence in both directions, absent → `n/a`, the refused-window arm, severity pass-through, the per-position shape, both session arms, `fleet_totals` with a radiance payload), the collector's best-effort `/stats` (a dead `/stats` keeps the `/metrics` poll healthy — invariant 4), the registry (one place lists the engines), the API surface (`/api/config.llm.radiance`, the 404 gate, the history defaults, the summary), and the live read-only test.
- Coverage: **>90%** on new/modified code; the whole suite 100%; `ruff` + `pyright` clean.
- The phase is "Complete" only when the **entire** project test suite passes 100% (every test in the project, not just this phase's — pre-existing failures included) and achieves **>90%** code coverage on new/modified code.
- Every UI change is verified in a real browser against live data, screenshot in `data/logs/` (AGENTS.md rule 10) — with gufo and radiance both answering, the cross-engine fleet block finally renders on the dev fleet, which is the phase's own proof that the sum is honest.
## Documentation & Consistency
- The final pass reviews `README.md` in full against the project as it stands after this phase — fixing every inconsistency found, **including pre-existing ones not caused by this phase**. For `AGENTS.md` it fixes only *stale statements* (a dead command, a changed rule, an outdated fact) and **never adds or expands its content** (a file that states its own update policy, e.g. "updated rarely, if ever", wins).
- Tasks update `README.md` immediately when their own changes are user-visible (setup, commands, configuration, features, conventions), and `AGENTS.md` only when a statement in it became stale.
## Completion Criteria
- [ ] `RADIANCE_BASE_URL` set and `GUFO_BASE_URL` set: `curl -s http://127.0.0.1:8090/api/llm/snapshot | jq .engines | jq 'map(.engine)'` lists `gufo`, `radiance` (and `vllm` only if configured), and `fleet.engines_live` is 2; `/api/config.llm.radiance.enabled` is `true`.
- [ ] `RADIANCE_BASE_URL` unset: `/api/llm/snapshot` answers `404 {"configured": false}` with no dial (spy-pinned), and no `llm:radiance` job exists in the hub.
- [ ] `tests/fixtures/metrics_radiance.txt` and `tests/fixtures/stats_radiance.json` exist, are verbatim captures, and a hermetic test fails if the fixture's `radiance:*`/`vllm:*` name set drifts from `RADIANCE_SERIES`/`RADIANCE_HISTOGRAMS`.
- [ ] The radiance panel on `/llm` shows the native `Δ<window>`-labelled throughputs when the gauge is absent, the KV bar with its severity colour, the 7-bar spec curve with its `aria-label`, the `requests[]` rows when a request is live and the counts-otherwise arm at rest — screenshot in `data/logs/`.
- [ ] `uv run pytest -m live --live` includes a radiance test that passes read-only against the running engine.
- [ ] full test suite **100% passing** (every test in the project, not just this phase's), coverage >90%; `ruff` + `pyright` clean; `bash .agents/validate.sh` green.
- [ ] README.md is accurate and consistent with the whole project; AGENTS.md has no stale statements (verified in the final pass — the `vLLM :8000` fleet fact included).
- [ ] no behaviour change in completed work: the gufo and vLLM payloads, history names, alerts, and served pages are byte-identical where this phase does not touch them (the suite's existing pins untouched).
@@ -0,0 +1,34 @@
# Task 01 — Ground truth first: radiance's /metrics, /stats and /v1/models recorded verbatim
**Phase:** `34_radiance_llm_engine` · **Story:** n/a
## Objective
`scripts/capture-fixtures.sh` records radiance's three endpoints verbatim into `tests/fixtures/`, driven by the same env the app reads (AGENTS.md rule 7: field maps are captured from the running engine, never trusted to a README or a memory of an upstream), and a hermetic test pins the recorded `radiance:*`/`vLLM-compat` name set so a metric rename fails the suite before the dashboard does.
## Work
1. `.env.dev` — the engine block: `VLLM_BASE_URL=http://driveripper.reeselink.com:8000` becomes `RADIANCE_BASE_URL=http://driveripper.reeselink.com:8000` with a comment line (the operator's decision of 2026-10-24: radiance replaced vLLM at that address; the vLLM engine code stays for other deployments). `.env.dev` is gitignored — this edit is what makes the live capture below dial the right thing.
2. `scripts/capture-fixtures.sh` — the engine section learns radiance, the same way it reads the engine URLs (nothing hard-coded, so a capture can never come from a different topology than the dashboard polls):
- `[ -n "${RADIANCE_BASE_URL:-}" ] && ENGINES+=("radiance:${RADIANCE_BASE_URL%/}/metrics")` (the existing `--with-llm` gate stays the only switch).
- The radiance entry captures **three** files, not one: `metrics_radiance.txt` (the `/metrics` body, verbatim), `stats_radiance.json` (the `/stats` body, verbatim JSON), `models_radiance.json` (the `/v1/models` body — small, and the model's `capabilities`/`max_model_len` are the only place the engine says what it can do). Each gets the same verbatim-header comment the other fixtures carry: recorded by the script, volatile values move between captures, the contract asserts **shape and name set, never the numbers** (the `nut_*.vars` precedent).
- The `requests[]` shape: the capture at rest records `requests: []` (the engine was idle at first contact). The header comment says so, and the script prints a hint line (`requests[] was empty — re-run with a request in flight to record its shape`) when it sees `in_flight == 0` in the stats body. Task 03's session arm must handle the empty shape; if the operator runs a re-capture with live traffic before task 03 lands, the fixture carries the real rows and task 03's defensive arm is tested against both.
- `--with-llm`'s `ENGINES` loop currently appends `<name>:<url>/metrics` only; restructure it so each engine entry is `<name> <url>` and the loop writes the `<name>.txt` metrics file plus, for radiance only, the two JSON sidecars (gufo keeps its separate `/slots` pass exactly as today).
3. `tests/test_llm_source.py` — a fixture-loader following the existing skip pattern (`tests/conftest.py` skips a contract test when its fixture is missing, naming the capture command in the skip message — the new test skips the same way). With the fixtures present it asserts, against `metrics_radiance.txt`:
- the sorted set of series names in the file equals the sorted set `RADIANCE_SERIES` names + `RADIANCE_HISTOGRAMS` names that the fixture actually carries (the drift pin; a renamed `radiance:*` series fails this test, the way the gufo/vLLM captures are pinned);
- every name in the map that the fixture does not carry is listed in a module-level `RADIANCE_OPTIONAL` set (names that exist on other radiance builds), so "absent here" and "absent everywhere" are different things in the test (rule 2's absence arms need to be tested against real absence, not a typo).
- a second loader for `stats_radiance.json`: the top-level key set is pinned as a frozenset (shape, not values), `cards[]` is a non-empty list of dicts with `index`/`name`/`vram_used`/`vram_total`, `requests` is a list, and `draft_positions` is a list of dicts with `reached`/`accepted`/`rate`.
## Testing & Quality
- Unit: the fixture-loader + name-set + shape tests above (they are the unit — the fixtures are the input contract).
- Coverage: **>90%** on this task's new/modified code (the script is shell; its behaviour is pinned by `tests/test_deploy_scripts.py`'s existing capture-script tests — extend one of them to assert the radiance branch exists and writes the three files when `RADIANCE_BASE_URL` is set, using the script's existing sandbox/fake-curl pattern).
- The live run itself (operator's machine, this dev fleet): `set -a; . ./.env.dev; set +a && ./scripts/capture-fixtures.sh --with-llm` produces the three files with the recorded headers.
## Completion Criteria
- [ ] `./scripts/capture-fixtures.sh --with-llm` (with `.env.dev` sourced) writes `tests/fixtures/metrics_radiance.txt`, `stats_radiance.json`, `models_radiance.json`, each with a verbatim header; the `requests[]`-empty hint printed at rest.
- [ ] `tests/fixtures/metrics_radiance.txt` carries both families on one file: `grep -c '^radiance:' tests/fixtures/metrics_radiance.txt` > 0 and `grep -c '^vllm:' tests/fixtures/metrics_radiance.txt` > 0 (the vLLM-derived shape is part of the contract).
- [ ] The hermetic name-set/shape tests pass (and skip with the capture command named when the fixtures are absent).
- [ ] `tests/test_deploy_scripts.py`'s capture-script pin covers the radiance branch.
- [ ] full test suite **100% passing** (every test in the project, not just this task's).
- [ ] README.md updated for anything user-visible this task changes (the capture script's `--with-llm` line now records three engine endpoints); AGENTS.md touched only if a statement in it became stale (the phase's final pass re-checks both)
- [ ] no behavior change in completed work (gufo's `/slots` pass and the agent/UPS captures behave exactly as before)
@@ -0,0 +1,38 @@
# Task 02 — One registry: `RADIANCE_BASE_URL` and the five hardcoded engine lists become one function
**Phase:** `34_radiance_llm_engine` · **Story:** n/a
## Objective
Radiance is configured the way the other two engines are (a base URL, an optional API key, unset by default → "not configured", rule 3), and the **five** places that enumerate the gufo/vllm pair by hand — `collectors.llm_jobs`, the `llm_router` 404 gate, the history-route defaults, `/api/config`'s llm block, and the summary's `llm.enabled` — all read one `llm_engines()` registry, so the next engine is a one-line addition and no enumeration site can silently keep a two-engine world.
## Work
1. `src/dashboards/config.py`
- Beside the existing engine settings: `radiance_url: str = _base_url("RADIANCE_BASE_URL")` and `radiance_api_key: str = _env("RADIANCE_API_KEY")` — the same shapes, the same comment idiom (unset = "this deployment does not run it", never a red panel).
- `any_source_enabled` gains `or bool(self.radiance_url)`.
- `def llm_engines(self) -> list[tuple[str, str, str, str]]` — ordered `(key, display, url, api_key)` for the three engines, in **stable display order** `gufo, vllm, radiance` (the UI's card order and the history defaults follow it; the order is part of the contract, pinned in a test), with a comment that the list is the single place an engine's presence is decided. A `bool(self.radiance_url)`-style `configured` test lives at the call sites (`url` non-empty), matching how `nut_enabled`/`gateway_enabled` work — no new property for nothing.
2. `src/dashboards/collectors.py` — `llm_jobs` rewrites its two `if` blocks over `settings.llm_engines()`: one `Job(f"llm:{key}", "llm", display, settings.llm_interval, …)` per **configured** engine; `with_slots=True` when `key == "gufo"`, `with_stats=True` when `key == "radiance"` (the flag plumbing lands in task 04; this task introduces the flag on `fetch_engine` as a no-op parameter so `llm_jobs` reads its final shape now).
3. `src/dashboards/api.py`
- The `llm_router` 404 gate (today `if not (ctx.settings.gufo_url or ctx.settings.vllm_url)`) becomes "no configured engine" over the registry — same `404 {"configured": false, "detail": …}` shape, and the `detail` sentence lists all three variable names (`GUFO_BASE_URL` / `VLLM_BASE_URL` / `RADIANCE_BASE_URL`) so the not-configured arm names the fix.
- The `/api/llm/history` defaults (`(("gufo", ctx.settings.gufo_url), ("vllm", ctx.settings.vllm_url))`) become the registry's configured `(display, url)` pairs — the `engines` query-param filtering behaves exactly as today.
- The summary's `llm.enabled` (today `bool(ctx.settings.gufo_url or ctx.settings.vllm_url)`) becomes "any engine configured" over the registry.
- `/api/config`'s llm block gains a third key, **additively**: `"radiance": {"enabled": bool(settings.radiance_url), "base_url": strip_userinfo(settings.radiance_url), "auth": bool(settings.radiance_api_key)}` — the existing `gufo`/`vllm` keys keep their exact names and shape (the API shape is additive-only, LOCKED).
4. `.env.dev.example` — the engines block keeps the gufo line, keeps the vllm line (vLLM is still a supported engine for other deployments), and adds `export RADIANCE_BASE_URL=http://driveripper.example.net:8000` with the same documentation idiom. `README.md`'s env-var table row (`GUFO_BASE_URL / VLLM_BASE_URL …`) gains radiance in the same sentence shape (the "unset means *not deployed here*" clause applies unchanged).
5. `tests/test_config.py`
- `RADIANCE_BASE_URL` unset → `radiance_url == ""`, no job, `any_source_enabled` unchanged by it; set → job present, `any_source_enabled` true.
- `llm_engines()` pins: the exact ordered triple, the display names (`gufo`, `vLLM`, `radiance`), the api-key passthrough (a set `RADIANCE_API_KEY` travels into the tuple), and that an unset engine is **still listed** with an empty url (the registry is the full fleet, configured-ness is a url test — the `parse_targets` idiom).
6. `tests/test_api.py` — `/api/config.llm.radiance` absent-by-default pin (additive key, `enabled: false`); the 404 gate with only `RADIANCE_BASE_URL` set (no 404) and none set (404 + the three-variable detail sentence); the history defaults listing a configured radiance.
## Testing & Quality
- Unit/integration: the registry pins above; the 404 gate in all three single-engine configurations; the config block's additivity (a diff-pin: the gufo/vllm keys' bytes are unchanged when radiance is added).
- Coverage: **>90%** on `config.py` and the touched `api.py`/`collectors.py` paths; the whole suite 100%; `ruff` + `pyright` clean.
## Completion Criteria
- [ ] `RADIANCE_BASE_URL=… uv run python -c "from dashboards.config import load; e=[x for x in load().llm_engines() if x[0]=='radiance']; print(e)"` prints the one configured entry; unset, the entry is present with an empty url.
- [ ] `grep -n "gufo_url\|vllm_url" src/dashboards/collectors.py src/dashboards/api.py` shows no engine enumeration outside the registry (the settings fields themselves remain — they are the env surface).
- [ ] The 404 gate and the history defaults answer from the registry in all configurations (spy-pinned: an unconfigured radiance is never dialled — no client, no fetch).
- [ ] `/api/config` serves `llm.radiance` beside byte-identical `llm.gufo`/`llm.vllm`.
- [ ] full test suite **100% passing** (every test in the project, not just this task's).
- [ ] README.md updated for anything user-visible this task changes (the env-var table row); AGENTS.md touched only if a statement in it became stale (the phase's final pass re-checks both)
- [ ] no behavior change in completed work (a gufo-only or vLLM-only deployment's `/api/config`, 404, history and summary bodies are byte-identical to before, modulo the new additive `radiance` key in the config block)
@@ -0,0 +1,43 @@
# Task 03 — The radiance field map: rule-2 precedence, `/stats` as the session source, no invented number
**Phase:** `34_radiance_llm_engine` · **Story:** n/a
## Objective
`src/dashboards/sources/llm.py` parses a radiance poll the way it parses gufo and vLLM — a field map **built from the task-01 fixture** (rule 7), the native windowed throughput gauges winning over a measured Δ (rule 2's precedence, the vLLM-native-gauge case), `n/a` where the engine is silent (never `0`), the KV cell rated through the **existing** `llm.kv_cache_usage_pct` severity row, and `/stats` as the session source beside gufo's `/slots` and vLLM's counts-only arm.
## Work
1. `src/dashboards/sources/llm.py` — the map, **names taken from `tests/fixtures/metrics_radiance.txt`, not from memory** (the module docstring's capture ritual already says how; the vLLM-compat subset is part of the same file, so one `Prom` parse serves both families):
- `RADIANCE_SERIES` (UI key → (names, agg)), the radiance gauges plus the vLLM-shaped series this build carries: `decode_tokens_per_second_native` (`radiance:decode_tokens_per_second`, avg — the scheduler's recent-window aggregate, the thing that wins), `prompt_tokens_per_second_native` (`radiance:prefill_tokens_per_second`, avg), `num_requests_running` / `num_requests_waiting` (`vllm:…`, sum), `gpu_cache_usage` (`vllm:kv_cache_usage_perc`, max — the same 0-1 ratio the vLLM map feeds `SEVERITY_CELLS`, so `attach_cell_severity` rates it with **no new row**), `kv_cache_max_tokens` / `kv_cache_max_bytes` (`radiance:…`, max), `queue_depth` (`radiance:http_queue_depth`), `admission_rejected_total` (`radiance:http_admission_rejected_total`), `num_preemptions_total` (`vllm:num_preemptions_total`), `prompt_tokens_total` / `generation_tokens_total` (`vllm:…`, sum — the measured-Δ source), `gpu_prefix_cache_hit_rate` (`vllm:gpu_prefix_cache_hit_rate`, avg — a 0-1 gauge; **this build publishes no prefix counters**, so a hits/queries ratio would be an invented number: the gauge is the answer, source named, not derived — the documented asymmetry, the gufo `*_native` note's sibling), `spec_accept_rate` (`radiance:draft_acceptance_ratio`, avg — a 0-1 gauge, **reusing gufo's UI key** so the history name, the fleet block and the UI row all work without a new vocabulary), `decode_step_seconds_recent` / `decode_step_seconds_best` (`radiance:…`, max).
- `RADIANCE_HISTOGRAMS`: `time_to_first_token_seconds` (`vllm:time_to_first_token_seconds`), `time_per_output_token_seconds` (`vllm:time_per_output_token_seconds`, `vllm:inter_token_latency_seconds`), `e2e_request_latency_seconds` (`vllm:e2e_request_latency_seconds`) — the vLLM-shaped histograms the build carries. This build has **no** `request_queue_time_seconds`, so `queue_seconds` is `n/a` for radiance by absence, never back-filled.
- `RADIANCE_OPTIONAL` (task 01's set of map names absent from the fixture — e.g. the `vllm:num_requests_swapped`-class names) so the hermetic test distinguishes "not on this build" from "not anywhere".
2. `parse_radiance_metrics(text, rates, now, stats=None)` — the gufo/vLLM parse functions' idiom, with the **vLLM-side** throughput precedence (native gauge is the aggregate, so it wins):
- `decode_tokens_per_second` / `prompt_tokens_per_second`: the native gauge if present (source = the gauge's name, `derived=False`); else the measured Δ of the token counter **per label set** via the existing `_series_delta` (keyed by resolved name + labels — phase 13's carried-counter rule; a refused window is still an answer: `n/a` beside its honest `Δ<window>`); else `field(None, None)`. **No third estimate** (no `running × 1/tpot`): a gauge + a counter + a guess stacked on one cell is exactly the aggregation rule 2 exists to catch, and the engine's own page shows only the first two.
- `tokens_total` from the two `vllm:` token counters (prompt + generated, as the vLLM path does); `prefix_cache_hit_ratio` from the gauge (0-1, source named); `spec_positions` from the labelled series `radiance:draft_position_acceptance_ratio` via `parsed.by_label(…, "position")` — an **ordered** list of 7 `{position, ratio}` entries (0-1, in the order 0..6 the engine publishes; absent positions stay absent, not zeroed).
- `attach_cell_severity(out)` at the end, unchanged (the `gpu_cache_usage` key carries the verdict; **no** new severity row for spec acceptance — an efficiency number, decision 4).
3. `_sessions()` — the third arm, keyed on the `stats` dict task 04's fetch supplies:
- `stats` present: `running` / `waiting` / `queue_cap` / `requests` come from it (the engine's own summary — the numbers its own page prints). `requests` empty → `detail` = counts + cap, one honest sentence (the vLLM arm's idiom: "radiance reports counts, not sessions: N running of cap C. Per-request detail appears here while requests are in flight."). Non-empty → `list` = the rows verbatim (the fixture's shape is the contract; a row's fields render as-is, task 05), `source` = "radiance /stats".
- `stats` missing (the fetch failed — invariant 4: the poll itself is healthy) → fall back to the `/metrics` gauges `num_requests_running`/`num_requests_waiting` for the counts (they are real numbers, different provenance — the detail says `/stats` did not answer this poll), `queue_cap` `null` (never `0` — rule 2).
- `active` stays `metrics["num_requests_running"]["value"]` in every arm (one definition, the `/metrics` gauge).
4. `engine_result(engine, base_url, text, rates, *, slots=None, stats=None, now=None)` — the parser dispatch becomes the three-way map (gufo / vllm / radiance); a radiance poll attaches `payload["stats"]` = the small additive subset `{model, version, uptime_s, startup_s, total_requests, cards[]}` (each card `+ vram_used_pct` computed `used/total×100`, `derived: true` — the only derived number in the block; the raw bytes travel as the engine published them). The subset is **whitelisted from the fixture's shape**, so a radiance upgrade that renames a `/stats` key degrades to absent, not a crash (invariant 4's poll-body rule).
5. `HISTORY_METRICS` — **additive candidates only** (the existing keys and their candidate order are untouched): `queue_depth` → `("queue_depth",)`, `queue_cap` → `("queue_cap",)` (the `queue_cap` metrics key is the `/stats` passthrough), `decode_step_seconds_recent` → `("decode_step_seconds_recent",)`, `vram_used_pct` → `("vram_used_pct",)` (card 0, the engine's own view of the same card `/system` reads through the agent — different source, different host key, both honest). The existing keys already cover radiance: `decode/prompt_tokens_per_second` (the UI keys the precedence logic fills), `kv_cache_usage` (the `gpu_cache_usage` candidate), `requests_running`/`requests_waiting` (the `num_requests_*` candidates), `tokens_total`, `spec_accept_rate` (the gauge reuses gufo's key). The seven per-position ratios are **snapshot-only** (decision 2: a bounded store, rule 9).
6. `series_names(engine)` — the three-way branch; `fleet_totals(engines)` — the code needs **no change** (the aliases `num_requests_running`/`num_requests_waiting`, the `decode/prompt_tokens_per_second` keys, `tokens_total` and the `gpu_cache_usage` max all already cover a radiance payload); **only** the `seams` sentence is rewritten to state, briefly and honestly, the known asymmetries: gufo's prompt counter excludes cache-hit tokens; the summed throughput is per-engine provenance-labelled (native scheduler window vs measured Δ). It does not claim what radiance's `vllm:prompt_tokens_total` includes or excludes about cached tokens — the engine does not say, and neither does the dashboard (rule 2).
## Testing & Quality
- Unit/integration in `tests/test_llm_source.py` (fixture-driven, the conftest skip pattern from task 01):
- the fixture parse: every mapped key resolves (shape + source strings), the name set equals the fixture's (task 01's pin), `spec_positions` is the ordered 7, `stats`-arm sessions as specced (empty / non-empty / missing, three fakes), the `payload["stats"]` whitelist (a fixture with a renamed `version` key degrades to absent, the poll still returns), the radiance history-sample names (`llm.queue_depth`, `llm.queue_cap`, `llm.decode_step_seconds_recent`, `llm.vram_used_pct`, …) appear and the gufo/vLLM sample names are untouched.
- rule 2, three arms: gauge present → source is the gauge name, `derived` false; gauge line removed from the fixture copy → measured Δ, `derived` true, the window (or the refused-window `n/a` + `Δ` label, a fresh-estimator fake); both absent → `n/a`, no `0` anywhere; `queue_cap` with `stats` missing → `null`, not `0`.
- the severity pass-through: the `gpu_cache_usage` cell carries a `severity` verdict from the existing table at a fixture value above the warn bound; `spec_accept_rate` carries **no** severity key (decision 4 pinned both ways — an absence-only assertion, so it is asserted on the payload).
- `fleet_totals` with a gufo + radiance payload: the running/waiting sums, the true max KV, `engines_live` 2, the seams copy names the provenance labelling.
- the gufo and vLLM parse functions are byte-behaviour-unchanged (their existing pins untouched).
- Coverage: **>90%** on `sources/llm.py`; the whole suite 100%; `ruff` + `pyright` clean.
## Completion Criteria
- [ ] `parse_radiance_metrics` against the fixture returns every mapped key with its pinned source string; a series removed from a fixture copy answers `{"value": null}` — never `0`.
- [ ] the three throughput arms are proven in both directions (gauge wins / measured fills / `n/a` with the honest `Δ` label on a refused window).
- [ ] `spec_positions` is the ordered 7 from the fixture; `queue_cap` is `null` (not `0`) when `/stats` is absent; the KV cell carries the severity verdict and the spec cell carries none.
- [ ] `engine_result("radiance", …, stats=…)` carries the whitelisted `stats` block with the one computed `vram_used_pct`; a renamed stats key degrades to absent without raising.
- [ ] full test suite **100% passing** (every test in the project, not just this task's).
- [ ] README.md updated for anything user-visible this task changes (none expected — the payload surface is task 05's); AGENTS.md touched only if a statement in it became stale (the phase's final pass re-checks both)
- [ ] no behavior change in completed work (gufo and vLLM payloads, samples, alerts and pages behave exactly as before — the suite's existing pins untouched)
@@ -0,0 +1,33 @@
# Task 04 — The `llm:radiance` job: a `/stats` sidecar that can fail without failing the poll
**Phase:** `34_radiance_llm_engine` · **Story:** n/a
## Objective
`collectors.fetch_engine` fetches radiance's `/stats` **best-effort** — a dead or partial `/stats` degrades the session arm (task 03's missing-stats fallback) and never takes the `/metrics` poll down (invariant 4: a failed sidecar keeps the last good payload and marks nothing stale, while a dead `/metrics` is the real `UpstreamError`) — and `llm_jobs` (in the task-02 registry shape) builds the `llm:radiance` job with its own `Job` key, display name and the shared LLM cadence/estimator.
## Work
1. `src/dashboards/collectors.py` — `fetch_engine(…, with_slots=False, with_stats=False)`:
- The `with_stats` path, beside the existing `/slots` loop: one `GET {base_url}/stats` (no alias — radiance's own page uses exactly that path); on any `httpx.HTTPError` **or** a status ≥ 400 **or** a JSON that is not a dict, `stats` stays `None` and the function returns the metrics result with the missing-stats session arm. No `UpstreamError` is raised from this sidecar, no retry, no second dial — one best-effort read, the `/slots` idiom.
- The `UpstreamError` messages for the **metrics** dial keep the existing `strip_userinfo` redaction (invariant 6) — a `RADIANCE_API_KEY`-free URL never leaks, a tokenised one never leaks its token.
- `llm_jobs` (task 02's registry shape) passes `with_stats=True` for the radiance entry. No new `RateEstimator`: radiance rides the LLM cadence's existing estimator (task: `app.build_hub` is untouched — one estimator per measuring cadence stays the rule).
2. `src/dashboards/app.py` — nothing to change is the check: the `llm` jobs already flow from `llm_jobs` into the hub; confirm `llm:radiance` gets the LLM interval and the LLM-span estimator by the existing wiring (assert in a test, not by comment).
3. `tests/test_collectors.py`:
- a configured `RADIANCE_BASE_URL` (and no gufo/vllm) builds exactly one `llm:radiance` `Job` with `dashboard == "llm"`, display `radiance`, the LLM interval (the registry's shape from task 02, extended);
- a fake server with `/metrics` 200 and `/stats` 500 → the result is a healthy `Result` (payload present, `stats` absent, the session detail names the missing sidecar); `/stats` 200 with a non-JSON body → the same healthy degradation; `/stats` 200 with a valid dict → `stats` travels into the result;
- `/metrics` 500 with `/stats` 200 → the `UpstreamError` is the metrics one (the sidecar never masks the real failure);
- a tokenised `RADIANCE_BASE_URL` (`http://secret@host:8000`) whose metrics dial fails → the `UpstreamError` message contains no `secret` (the redaction pin, the existing engine-fetch precedent extended);
- the job list never dials an unconfigured engine (the existing spy pattern).
## Testing & Quality
- Unit/integration: the five pins above (healthy / degraded / masked / redacted / undialled).
- Coverage: **>90%** on the touched `collectors.py` paths; the whole suite 100%; `ruff` + `pyright` clean.
## Completion Criteria
- [ ] `set -a; . ./.env.dev; set +a` + `./scripts/dev-server.sh restart` → `./scripts/dev-server.sh status` shows `llm:radiance` ok (or its real failure) beside the other sources, and `/api/llm/snapshot` (authenticated) carries the radiance payload with the `stats` block.
- [ ] a faked dead `/stats` leaves the poll healthy (the test above, and the page shows the metrics panel with the counts-otherwise session arm).
- [ ] `grep -n "with_stats" src/dashboards/collectors.py` shows the flag feeding `fetch_engine` from `llm_jobs` only.
- [ ] full test suite **100% passing** (every test in the project, not just this task's).
- [ ] README.md updated for anything user-visible this task changes (none expected — no new env surface beyond task 02's); AGENTS.md touched only if a statement in it became stale (the phase's final pass re-checks both)
- [ ] no behavior change in completed work (the gufo `/slots` pass and the vLLM job behave exactly as before — the suite's existing pins untouched)
@@ -0,0 +1,39 @@
# Task 05 — `/llm` learns radiance: the strip, the 7-bar curve, and the honest empty arms
**Phase:** `34_radiance_llm_engine` · **Story:** n/a
## Objective
The radiance card on `/llm` shows the cells its payload carries — the native/measured throughputs with their provenance tags, the KV bar with its severity colour, the radiance strip (queue cap, build, uptime, per-card VRAM, the 7-bar spec-acceptance curve), and the `requests[]` rows when a request is live, the counts-otherwise sentence at rest — with every chart named for assistive tech and **no verdict colour** on any radiance cell except the KV bar (decision 4).
## Work
1. `src/dashboards/web/static/page-llm.js`
- The `only:` scopes extend, not new: `ttft`, `tpot`, `queue_mean`, `running` gain `'radiance'` (the build carries the vLLM-shaped histograms and the `num_requests_*` gauges — task 03's map fills the same cell keys); `spec_accept` becomes `['gufo', 'radiance']` (the gauge reuses gufo's key and is the same 0-1 scale, so the existing `%` row renders unchanged).
- New **radiance-only** strip cells (the `STRIP_CELLS` idiom, `only: ['radiance']`, each with a `tip` that tells the operator what the number is for):
- `cap` — "queue cap", the engine's `queue_cap` (the admission ceiling; `null` when `/stats` did not answer → the cell prints `n/a`, never `0`);
- `build` — `stats.version` (e.g. `1.15.1`), tip "The engine's own version string — a radiance upgrade changes it, the panel does not";
- `uptime` — `stats.uptime_s` through the existing `app.js` duration formatter;
- `vram` — card 0's `vram_used`/`vram_total` as `32.8 / 34.2 GiB` (GiB = 2³⁰, the only conversion on the page, labelled as such in the tip: "the engine's own view of the card; `/system`'s GPU row is the agent's view"), `n/a` when no card answered.
- The **7-bar spec curve** (decision 2): a small hand-rolled SVG on the radiance strip (the `charts.js` idiom, no library, no CDN — rule 5), one bar per position in `spec_positions` order (0..6), height ∝ the 0-1 ratio, drawn in a **neutral** token (the page's existing track/cool colours — never a severity colour, the value is an efficiency number, decision 4), with `role="img"` and an `aria-label` that names the chart and its range (e.g. `Spec-decode draft acceptance by position: 82% at position 0 falling to 65% at position 6`) — the label is **computed from the payload** (the "every chart names itself" rule, pinned below).
- The `requests[]` arm: non-empty `sessions.list` (source "radiance /stats") renders the rows as a compact table beside the counts (the gufo slot-grid idiom, shrunk — radiance rows are the engine's own fields, rendered **as-is**: the fixture's shape is the contract; a field that is a number prints `n/a`-free only when it is a number, anything else prints its string verbatim, no conversion, no invented key). Empty list → the `sessions.detail` sentence (task 03's counts + cap) in the existing detail slot.
- `SLOT_ENGINES` stays `['gufo']` (the slot grid is gufo's); the radiance rows live in the strip, not the slot grid.
2. `src/dashboards/web/pages/llm.html`
- The empty-state paragraph names all three variables: "Set `GUFO_BASE_URL` (llama.cpp / gufo), `VLLM_BASE_URL` and/or `RADIANCE_BASE_URL`."
- The fleet block's `fleet-note`/seams copy is payload-driven (`fleet.seams` from `fleet_totals`, task 03's rewritten sentence) — no new hardcoded copy.
3. `src/dashboards/web/static/dash.css` — only the tokens the 7-bar curve and the `requests` table need (reuse the existing neutral/cool/edge tokens; **no** new colour vocabulary — colour comes only from the payload's severity verdict, which for radiance is the KV bar alone).
4. `tests/test_api.py` — the served-byte pins extend: the radiance strip markup (the four new cells' `data-key`s), the curve's `role="img"` + `aria-label` joined to the existing chart-aria-label pin list (an absent label fails the test), and the requests-table container present-when-the-payload-carries-rows (a fixture-driven payload), absent-at-rest with the detail sentence.
5. Live verification (AGENTS.md rule 10): `./scripts/dev-server.sh restart` with `.env.dev` (gufo + radiance now both configured — the fleet block finally renders: two answering engines), sign in, `/llm`, screenshot the radiance card **at rest** (counts arm, curve, strip) into `data/logs/34-llm-radiance-rest.png`; if a request is in flight during the pass, a second screenshot with the rows visible.
## Testing & Quality
- Integration: the `test_api.py` pins above (markup, aria-label, both session arms) against fixture-driven payloads.
- Coverage: the UI is proven by the payload-contract pins + the screenshots (the JS-tests-none decision, PLAN.md); Python-side **>90%** on anything touched (none expected); the whole suite 100%; `ruff` + `pyright` clean.
## Completion Criteria
- [ ] The radiance card renders all task-03 cells; a payload with `stats` missing shows `n/a` on `cap`/`build`/`uptime`/`vram` (never `0`) and the counts-otherwise session sentence.
- [ ] The 7-bar curve is present with its computed `aria-label` (the pin fails on an absent label); no severity-colour token in its markup.
- [ ] The `only:` scopes: the vLLM-shaped rows (TTFT/TPOT/queue-time/running) and `spec accept` render on the radiance card from the fixture payload; no radiance row on the gufo card and no gufo row on the radiance card.
- [ ] `data/logs/34-llm-radiance-rest.png` exists, showing the card against live data with the fleet block rendered (two engines).
- [ ] full test suite **100% passing** (every test in the project, not just this task's).
- [ ] README.md updated for anything user-visible this task changes (the `/llm` page section, if it names the engines it draws); AGENTS.md touched only if a statement in it became stale (the phase's final pass re-checks both)
- [ ] no behavior change in completed work (the gufo and vLLM cards are byte-identical where this task does not touch them — the served-byte pins)
@@ -0,0 +1,35 @@
# Task 06 — Final pass: live read-only proof, fixture re-capture, README/AGENTS.md, the definition of done
**Phase:** `34_radiance_llm_engine` · **Story:** n/a
## Objective
The phase's verification pass: a read-only `live` test that drives the production radiance path against the running engine, the fixture re-capture with a diff read, the documentation pass (README in full; AGENTS.md stale-statements-only), and the definition-of-done checklist run against the dev server.
## Work
1. `tests/test_live_fleet.py` — the radiance test (the file's idiom: skipped unless `--live`, each test skips when its source is unset, **live tests must not write** — radiance exposes no write endpoint, so "read-only" is a list of GETs):
- skip when `RADIANCE_BASE_URL` is unset (the source gate, rule 3's live twin);
- dial the engine through the **production** path — `collectors.fetch_engine` with the LLM estimator (never a hand-rolled `client.get`), one poll;
- assert shape, not numbers: the payload's `engine == "radiance"`, the `stats` block's keys (task 03's whitelist), `spec_positions` length 7 when the fixture/running build publishes it, the session arm is one of the two specced arms, every cell that is present is either a number or `None` (never a fake `0` for an absent gauge — the live twin of rule 2), and `unparsed` is empty (a parse error on the live wire is drift, not a tolerance).
2. `./scripts/capture-fixtures.sh --with-llm` (`.env.dev` sourced) — re-record the radiance sidecars (and everything else), **read the diff**: a volatile value moving is expected (the headers say so); a renamed or new `radiance:*` series is a contract change — extend task 01's pin (and `RADIANCE_OPTIONAL` where a name is build-optional), never a silent ignore. If the earlier capture's `requests[]` was empty and a request is in flight now, re-capture so the fixture carries the real row shape, and re-run the session-arm tests against it.
3. `README.md` — the full final-pass review (the phase's documentation mandate): the env-var table (`RADIANCE_BASE_URL` / `RADIANCE_API_KEY` beside the engine rows, the "unset means *not deployed here*" clause), the LLM-engines section (a radiance paragraph in the gufo/vLLM idiom: what it is, the endpoints it serves, the native-gauge/measured-Δ provenance, the `/stats` sidecar), the capture-script section (three radiance endpoints under `--with-llm`), and every other place the engine count or the driveripper fact appears — fixing **every** inconsistency found, including pre-existing ones.
4. `AGENTS.md` — **stale statements only, no expansion** (its own update policy wins): the fleet table's driveripper line `vLLM :8000` → `radiance :8000` (the operator's decision of 2026-10-24); the `/llm` route row's source column (`gufo /metrics + /slots, vLLM /metrics`) gains `radiance /metrics + /stats`; `config.py`'s paragraph names the radiance settings beside the others; `sources/llm.py`'s bullet says the three field maps (gufo, vLLM, radiance) with the rule-2 precedence note already there. Nothing else — no phase narrative, no new rules.
5. The definition-of-done run, in order (AGENTS.md):
- `uv run pytest` green; `uv run pytest --cov` — coverage floor 94 % held, new/modified Python > 90 %; `uv run ruff check src tests agent && uv run pyright` clean; `uv pip list --outdated` empty (no dependency moved — confirm, do not assume).
- `set -a; . ./.env.dev; set +a && ./scripts/dev-server.sh restart` — `/api/health` sane (per-source ok/DOWN; `llm:radiance` answering), `./scripts/dev-server.sh status` read.
- `uv run pytest -m live --live` — the new radiance test passes read-only; the existing live tests (gufo, vLLM-where-configured, upsd, agents) still pass.
- Screenshots in `data/logs/`: the task-05 rest shot (and the in-flight shot if one was possible), and the overview page (`/`) showing the radiance engine's alert row when its KV bar is rated — the cell-driven `_engine_alerts` needs no code, only proof.
- `bash .agents/validate.sh` green.
## Testing & Quality
- The live test is the integration proof; the re-captured fixtures re-pin the hermetic suite; the documentation pass is checked by reading the two files against the project as it stands.
- Coverage: no new Python beyond the live test; the whole suite 100 %; `ruff` + `pyright` clean.
## Completion Criteria
- [ ] `uv run pytest -m live --live` includes the passing read-only radiance test (skipped, correctly, when unset — proven by one run without the env var).
- [ ] the fixture re-capture diff is read and either clean or folded into the pins (`RADIANCE_SERIES`/`RADIANCE_OPTIONAL`); the suite re-run is green.
- [ ] README.md: the engine section, env-var table, and capture-script section all name radiance; a full read found no remaining engine-count or driveripper stale statement.
- [ ] AGENTS.md: the three stale statements fixed (fleet line, route row, the two module bullets), nothing added or expanded beyond them.
- [ ] `./scripts/dev-server.sh status` shows `llm:radiance` answering; the `data/logs/` screenshots exist; `bash .agents/validate.sh` is green; the coverage floor held.
- [ ] full test suite **100% passing** (every test in the project, not just this task's) — the phase's "Complete" gate.
@@ -0,0 +1,25 @@
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.
@@ -0,0 +1,71 @@
== uv run pytest --cov (hermetic: no network, live tests skip) ==
........................................................................ [ 3%]
........................................................................ [ 7%]
........................................................................ [ 10%]
........................................................................ [ 14%]
........................................................................ [ 18%]
........................................................................ [ 21%]
........................................................................ [ 25%]
........................................................................ [ 28%]
........................................................................ [ 32%]
.........................s.............................................. [ 36%]
........................................................................ [ 39%]
........................................................................ [ 43%]
........................................................................ [ 47%]
........................................................................ [ 50%]
........................................................................ [ 54%]
........................................................................ [ 57%]
........................................................................ [ 61%]
........................................................................ [ 65%]
........................................................................ [ 68%]
........................................................................ [ 72%]
...................................................ssssssssssssss....... [ 76%]
........................................................................ [ 79%]
........................................................................ [ 83%]
........................................................................ [ 86%]
........................................................................ [ 90%]
........................................................................ [ 94%]
..........................................................sss........... [ 97%]
............................................ [100%]
=============================== warnings summary ===============================
.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1
/home/ducoterra/Projects/Personal/dashboards/.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.14.8-final-0 ________________
Name Stmts Miss Cover Missing
------------------------------------------------------------------
src/dashboards/__init__.py 3 0 100%
src/dashboards/__main__.py 14 1 93% 32
src/dashboards/alerts.py 201 0 100%
src/dashboards/api.py 625 9 99% 229, 1273-1275, 1310-1313, 1382, 1390, 1446, 1449
src/dashboards/app.py 190 0 100%
src/dashboards/auth.py 265 0 100%
src/dashboards/certs.py 292 0 100%
src/dashboards/collectors.py 244 5 98% 386-387, 391-392, 409
src/dashboards/config.py 128 0 100%
src/dashboards/debug.py 14 0 100%
src/dashboards/poller.py 165 3 98% 192-194, 279
src/dashboards/prom.py 112 5 96% 126, 163, 177, 180, 183
src/dashboards/rates.py 73 0 100%
src/dashboards/severity.py 49 0 100%
src/dashboards/sources/__init__.py 0 0 100%
src/dashboards/sources/disks.py 134 0 100%
src/dashboards/sources/gateway.py 613 10 98% 339, 588, 601-602, 652, 785, 1089, 1140, 1207, 1569
src/dashboards/sources/host.py 209 0 100%
src/dashboards/sources/llm.py 178 1 99% 285
src/dashboards/sources/nut.py 413 20 95% 153, 172, 186, 198, 204, 217, 225, 241, 319-320, 322, 579, 591, 626, 657, 661, 718-719, 724, 736
src/dashboards/sources/nut_sim.py 134 0 100%
src/dashboards/store.py 313 2 99% 553-554
------------------------------------------------------------------
TOTAL 4369 56 99%
Required test coverage of 94% reached. Total coverage: 98.72%
1970 passed, 18 skipped, 1 warning in 192.42s (0:03:12)
== uv run ruff check src tests agent ==
All checks passed!
== uv run pyright ==
0 errors, 0 warnings, 0 informations
== node --check src/dashboards/web/static/*.js ==
@@ -0,0 +1 @@
Let me look at the agent's file-reading helpers and the `parse_nginx_config`/`nginx_include_paths` functions.
@@ -0,0 +1,69 @@
== uv run pytest --cov (hermetic: no network, live tests skip) ==
........................................................................ [ 3%]
........................................................................ [ 7%]
........................................................................ [ 11%]
........................................................................ [ 15%]
........................................................................ [ 19%]
........................................................................ [ 23%]
........................................................................ [ 26%]
........................................................................ [ 30%]
........................................s............................... [ 34%]
........................................................................ [ 38%]
........................................................................ [ 42%]
........................................................................ [ 46%]
........................................................................ [ 50%]
........................................................................ [ 53%]
........................................................................ [ 57%]
........................................................................ [ 61%]
........................................................................ [ 65%]
........................................................................ [ 69%]
........................................................................ [ 73%]
.........................sssssssssssss.................................. [ 76%]
........................................................................ [ 80%]
........................................................................ [ 84%]
........................................................................ [ 88%]
........................................................................ [ 92%]
........................................................................ [ 96%]
..............sss....................................................... [100%]
=============================== warnings summary ===============================
.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/dashboards/.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.14.8-final-0 ________________
Name Stmts Miss Cover Missing
------------------------------------------------------------------
src/dashboards/__init__.py 3 0 100%
src/dashboards/__main__.py 14 1 93% 32
src/dashboards/alerts.py 201 0 100%
src/dashboards/api.py 491 1 99% 220
src/dashboards/app.py 190 0 100%
src/dashboards/auth.py 265 0 100%
src/dashboards/certs.py 292 0 100%
src/dashboards/collectors.py 216 5 98% 328-329, 333-334, 351
src/dashboards/config.py 126 0 100%
src/dashboards/debug.py 14 0 100%
src/dashboards/poller.py 165 3 98% 192-194, 279
src/dashboards/prom.py 112 5 96% 126, 163, 177, 180, 183
src/dashboards/rates.py 73 0 100%
src/dashboards/severity.py 49 0 100%
src/dashboards/sources/__init__.py 0 0 100%
src/dashboards/sources/disks.py 134 0 100%
src/dashboards/sources/gateway.py 584 8 99% 315, 558, 569, 678, 971, 1022, 1089, 1451
src/dashboards/sources/host.py 209 0 100%
src/dashboards/sources/llm.py 178 1 99% 285
src/dashboards/sources/nut.py 413 20 95% 153, 172, 186, 198, 204, 217, 225, 241, 319-320, 322, 579, 591, 626, 657, 661, 718-719, 724, 736
src/dashboards/sources/nut_sim.py 134 0 100%
src/dashboards/store.py 313 2 99% 553-554
------------------------------------------------------------------
TOTAL 4176 46 99%
Required test coverage of 94% reached. Total coverage: 98.90%
1855 passed, 17 skipped, 1 warning in 201.21s (0:03:21)
== uv run ruff check src tests agent ==
All checks passed!
== uv run pyright ==
0 errors, 0 warnings, 0 informations
== node --check src/dashboards/web/static/*.js ==
@@ -0,0 +1 @@
Now let me look at the existing deploy-script test harness and the agent's unit template in `install-agent.sh`, plus the agent's main guard.
@@ -0,0 +1,69 @@
== uv run pytest --cov (hermetic: no network, live tests skip) ==
........................................................................ [ 3%]
........................................................................ [ 7%]
........................................................................ [ 11%]
........................................................................ [ 15%]
........................................................................ [ 19%]
........................................................................ [ 23%]
........................................................................ [ 26%]
........................................................................ [ 30%]
........................................s............................... [ 34%]
........................................................................ [ 38%]
........................................................................ [ 42%]
........................................................................ [ 46%]
........................................................................ [ 50%]
........................................................................ [ 53%]
........................................................................ [ 57%]
........................................................................ [ 61%]
........................................................................ [ 65%]
........................................................................ [ 69%]
........................................................................ [ 73%]
.........................sssssssssssss.................................. [ 76%]
........................................................................ [ 80%]
........................................................................ [ 84%]
........................................................................ [ 88%]
........................................................................ [ 92%]
........................................................................ [ 96%]
..............sss....................................................... [100%]
=============================== warnings summary ===============================
.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/dashboards/.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.14.8-final-0 ________________
Name Stmts Miss Cover Missing
------------------------------------------------------------------
src/dashboards/__init__.py 3 0 100%
src/dashboards/__main__.py 14 1 93% 32
src/dashboards/alerts.py 201 0 100%
src/dashboards/api.py 491 1 99% 220
src/dashboards/app.py 190 0 100%
src/dashboards/auth.py 265 0 100%
src/dashboards/certs.py 292 0 100%
src/dashboards/collectors.py 216 4 98% 329, 333-334, 351
src/dashboards/config.py 126 0 100%
src/dashboards/debug.py 14 0 100%
src/dashboards/poller.py 165 3 98% 192-194, 279
src/dashboards/prom.py 112 4 96% 126, 163, 180, 183
src/dashboards/rates.py 73 0 100%
src/dashboards/severity.py 49 0 100%
src/dashboards/sources/__init__.py 0 0 100%
src/dashboards/sources/disks.py 134 0 100%
src/dashboards/sources/gateway.py 584 8 99% 315, 558, 569, 678, 971, 1022, 1089, 1451
src/dashboards/sources/host.py 209 0 100%
src/dashboards/sources/llm.py 178 1 99% 285
src/dashboards/sources/nut.py 413 20 95% 153, 172, 186, 198, 204, 217, 225, 241, 319-320, 322, 579, 591, 626, 657, 661, 718-719, 724, 736
src/dashboards/sources/nut_sim.py 134 0 100%
src/dashboards/store.py 313 2 99% 553-554
------------------------------------------------------------------
TOTAL 4176 44 99%
Required test coverage of 94% reached. Total coverage: 98.95%
1855 passed, 17 skipped, 1 warning in 175.99s (0:02:55)
== uv run ruff check src tests agent ==
All checks passed!
== uv run pyright ==
0 errors, 0 warnings, 0 informations
== node --check src/dashboards/web/static/*.js ==
@@ -0,0 +1 @@
Now let me look at the dashboard side — the `connections[].id` scheme in `sources/gateway.py`, the Handler and `serve`/`main` in the agent, and the phase-32 additivity control script.
@@ -0,0 +1,71 @@
== uv run pytest --cov (hermetic: no network, live tests skip) ==
........................................................................ [ 3%]
........................................................................ [ 7%]
........................................................................ [ 11%]
........................................................................ [ 15%]
........................................................................ [ 19%]
........................................................................ [ 23%]
........................................................................ [ 26%]
........................................................................ [ 30%]
........................................s............................... [ 34%]
........................................................................ [ 38%]
........................................................................ [ 42%]
........................................................................ [ 46%]
........................................................................ [ 50%]
........................................................................ [ 53%]
........................................................................ [ 57%]
........................................................................ [ 61%]
........................................................................ [ 65%]
........................................................................ [ 69%]
........................................................................ [ 73%]
.........................sssssssssssss.................................. [ 76%]
........................................................................ [ 80%]
........................................................................ [ 84%]
........................................................................ [ 88%]
........................................................................ [ 92%]
........................................................................ [ 96%]
..............sss....................................................... [100%]
=============================== warnings summary ===============================
.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1
/var/home/ducoterra/Projects/Personal/dashboards/.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.14.8-final-0 ________________
Name Stmts Miss Cover Missing
------------------------------------------------------------------
src/dashboards/__init__.py 3 0 100%
src/dashboards/__main__.py 14 1 93% 32
src/dashboards/alerts.py 201 0 100%
src/dashboards/api.py 491 1 99% 220
src/dashboards/app.py 190 0 100%
src/dashboards/auth.py 265 0 100%
src/dashboards/certs.py 292 0 100%
src/dashboards/collectors.py 216 5 98% 328-329, 333-334, 351
src/dashboards/config.py 126 0 100%
src/dashboards/debug.py 14 0 100%
src/dashboards/poller.py 165 3 98% 192-194, 279
src/dashboards/prom.py 112 5 96% 126, 163, 177, 180, 183
src/dashboards/rates.py 73 0 100%
src/dashboards/severity.py 49 0 100%
src/dashboards/sources/__init__.py 0 0 100%
src/dashboards/sources/disks.py 134 0 100%
src/dashboards/sources/gateway.py 584 8 99% 315, 558, 569, 678, 971, 1022, 1089, 1451
src/dashboards/sources/host.py 209 0 100%
src/dashboards/sources/llm.py 178 1 99% 285
src/dashboards/sources/nut.py 413 20 95% 153, 172, 186, 198, 204, 217, 225, 241, 319-320, 322, 579, 591, 626, 657, 661, 718-719, 724, 736
src/dashboards/sources/nut_sim.py 134 0 100%
src/dashboards/store.py 313 2 99% 553-554
------------------------------------------------------------------
TOTAL 4176 46 99%
Required test coverage of 94% reached. Total coverage: 98.90%
1855 passed, 17 skipped, 1 warning in 174.63s (0:02:54)
== uv run ruff check src tests agent ==
All checks passed!
== uv run pyright ==
0 errors, 0 warnings, 0 informations
== node --check src/dashboards/web/static/*.js ==
advisory: dependencies are out of date (AGENTS.md rule 8 — update on purpose):
ruff 0.17.2 0.17.3 wheel
@@ -0,0 +1,28 @@
Task 04 is complete. Short report:
## Task 04 — `enabled` joins the payload, `off` becomes reachable, warnings learn the marker
**Verdict: done.** All completion criteria met; `bash .agents/validate.sh` **exit 0**.
### What this run added (the tree carried the source work from the earlier session; I verified every behaviour and added the missing mandated surface)
- **`tests/conftest.py`** — two new fixture modes, both the *captured* fleet tree plus an edit, run through the real agent `Collector` on a fake `--root`: `marked` (the helper's three edits: `gitea` `server` line, nextcloud vhost `proxy_pass`, `brain` map entry → sink, each with the exact `#off <name>` trailing comment, plus the sink block in `stream.d/00-dashboards-sink.conf`) and `hand_edited` (same three shapes, markers removed).
- **`tests/test_gateway_source.py`** — the six mandated tests: three-marker fixture → three `enabled: false` rows with selectors/endpoints intact, `warnings == []` with the shapes proven present (suppression, not vacuous); the marker-vs-hand-deleted **pair** (silence ↔ `haproxy_backend_empty` + `nginx_map_route_missing`); sink recognised by block, not a hard-coded port; `0` only when the table answered, `null` + no samples when it didn't; stub/answer key-set parity (stub's `None` = absence of the claim).
- **`tests/test_api.py`** — `disabled` restated in the pinned 13-key set with its reason; summary test gains `marked` (=3) and `hand_edited` (=0) beside `full` (=0); the overview-line test learns `GATEWAY_DISABLED_FIELD` and pins the `> 0` / `Number.isInteger` guards (`0` prints nothing, `null` prints nothing).
- **`page-index.js`** — the `switched off` tally clause beside the `warnings` one, same guard.
- **Docs** — README + AGENTS.md name `enabled`/`off`, the `#off` id suffix, and the marker-aware warnings rule.
- **Rule 8** — `uv pip list --outdated` was non-empty before the gate; updated on purpose (pytest 9.2.0 → needed `asyncio_default_fixture_loop_scope = "function"` in `pyproject.toml`, pydantic 2.14.2, ruff 0.17.3, websockets 17.4). Now empty.
### Evidence
- **Gate**: 1877 passed / 17 skipped · coverage 98.86% (floor 94%; `gateway.py` 98%, `api.py` 99% — both >90%) · ruff clean · pyright 0 · `node --check` clean.
- **Live (rule 10)**: dev server restarted; all sources ok; live `/api/summary` serves `gateway: {… 'disabled': 0, 'connections': 33, 'warnings': 0}` (the real fleet has no markers — honest zero, live config untouched); browser pass on the overview page: `gateway 10 listeners · 33 connections · 15 live in · 15 live out` — four figures, **no `· 0 switched off` clause**, exactly the empty-state rule restated for the switch. Screenshot: `data/logs/gateway-switch-04-overview-line.png`.
- Report written to `.agents/reports/33_gateway_route_switching/…04….a2.md`; ntfy sent.
### Flags (left to the driver/operator)
1. **Task 05's `complete/` copy is from an aborted run** — its code is **not** in the tree (no `POST /api/gateway/route`, no `GATEWAY_ALLOW_WRITES`, no `collectors.post_route`), and its transcript ends mid-work. 05 needs a clean re-run; its `todo/` copy is still there, which is what the driver looks for.
2. **04's `todo/` copy is orphaned** (identical file in `complete/`; 01–03 follow the move convention). Per the working protocol I didn't touch `todo/` — the driver's move step should remove it.
3. None of the phase-33 work is committed yet.
Ready for task 05 on your go — or let the pipeline re-run it.
@@ -0,0 +1,70 @@
== uv run pytest --cov (hermetic: no network, live tests skip) ==
........................................................................ [ 3%]
........................................................................ [ 7%]
........................................................................ [ 11%]
........................................................................ [ 15%]
........................................................................ [ 19%]
........................................................................ [ 22%]
........................................................................ [ 26%]
........................................................................ [ 30%]
...............................................s........................ [ 34%]
........................................................................ [ 38%]
........................................................................ [ 41%]
........................................................................ [ 45%]
........................................................................ [ 49%]
........................................................................ [ 53%]
........................................................................ [ 57%]
........................................................................ [ 60%]
........................................................................ [ 64%]
........................................................................ [ 68%]
........................................................................ [ 72%]
...............................................sssssssssssss............ [ 76%]
........................................................................ [ 79%]
........................................................................ [ 83%]
........................................................................ [ 87%]
........................................................................ [ 91%]
........................................................................ [ 95%]
....................................sss................................. [ 98%]
...................... [100%]
=============================== warnings summary ===============================
.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1
/home/ducoterra/Projects/Personal/dashboards/.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.14.8-final-0 ________________
Name Stmts Miss Cover Missing
------------------------------------------------------------------
src/dashboards/__init__.py 3 0 100%
src/dashboards/__main__.py 14 1 93% 32
src/dashboards/alerts.py 201 0 100%
src/dashboards/api.py 492 1 99% 220
src/dashboards/app.py 190 0 100%
src/dashboards/auth.py 265 0 100%
src/dashboards/certs.py 292 0 100%
src/dashboards/collectors.py 216 5 98% 328-329, 333-334, 351
src/dashboards/config.py 126 0 100%
src/dashboards/debug.py 14 0 100%
src/dashboards/poller.py 165 3 98% 192-194, 279
src/dashboards/prom.py 112 5 96% 126, 163, 177, 180, 183
src/dashboards/rates.py 73 0 100%
src/dashboards/severity.py 49 0 100%
src/dashboards/sources/__init__.py 0 0 100%
src/dashboards/sources/disks.py 134 0 100%
src/dashboards/sources/gateway.py 613 10 98% 339, 588, 601-602, 652, 785, 1089, 1140, 1207, 1569
src/dashboards/sources/host.py 209 0 100%
src/dashboards/sources/llm.py 178 1 99% 285
src/dashboards/sources/nut.py 413 20 95% 153, 172, 186, 198, 204, 217, 225, 241, 319-320, 322, 579, 591, 626, 657, 661, 718-719, 724, 736
src/dashboards/sources/nut_sim.py 134 0 100%
src/dashboards/store.py 313 2 99% 553-554
------------------------------------------------------------------
TOTAL 4206 48 99%
Required test coverage of 94% reached. Total coverage: 98.86%
1877 passed, 17 skipped, 1 warning in 171.02s (0:02:51)
== uv run ruff check src tests agent ==
All checks passed!
== uv run pyright ==
0 errors, 0 warnings, 0 informations
== node --check src/dashboards/web/static/*.js ==
@@ -0,0 +1,110 @@
# Task 04 completion report (attempt 2, manual run)
**Status:** complete — all completion criteria met, `bash .agents/validate.sh` green (exit 0).
Note: attempt `a1`'s transcript died mid-work (it ends at "Now let me read the current
`warnings()` and `config_connections()` in the source:") yet its `.status` read `ok`; the
`a1` report for task 05 likewise claims tasks 01–04 were unimplemented and was itself
aborted. The tree carried the task-04 source work from the earlier session; this run
verified every mandated behaviour end-to-end and added the missing mandated tests, the
two fixture modes, the overview clause and its pins, and the doc sentences.
## What this run added (on top of the pre-existing task-04 source work)
- `tests/conftest.py` — two new `GATEWAY_MODES`, both the **captured** tree plus an edit,
run through the real agent `Collector` with a fake `--root` tree (wire-shape renames
fail tests, never draw empty pipes):
- `marked` — the helper's three edits: the `gitea` `server` line, the nextcloud vhost
`proxy_pass` line and the `brain.reeseapps.com` stream `map` entry (value rewritten
to the sink `127.0.0.1:44450`) each carrying the agent's `#off <name>` trailing
comment (the whole comment, one name — `split_marker`'s grammar), plus the helper's
sink `server` block in `stream.d/00-dashboards-sink.conf`.
- `hand_edited` — the same three shapes without the markers: the `server` line deleted,
the `proxy_pass` line plain, the map entry's value deleted, no sink file.
- `tests/test_gateway_source.py` — the six mandated tests, all passing:
- `test_a_marker_off_route_is_declared_and_disabled` — the `marked` fixture yields
exactly three `enabled: false` rows, selectors and endpoints intact (`gitea-2222` →
`reeseapps-gitea.reeselink.com:2222`, `nextcloud.reeseapps.com:11000`,
`127.0.0.1:44450`), every other row `true`, `#off` id suffix on every off row,
`warnings == []` with the marker-caused shapes proven present in the raw sections.
- `test_the_source_never_calls_a_route_broken_because_the_operator_disabled_it` /
`test_the_same_shape_without_a_marker_is_still_a_warning` — the pair: marker-caused
shapes warn nothing; the same shapes hand-deleted bring back
`haproxy_backend_empty` (subject `gitea-2222`) and `nginx_map_route_missing`
(subject `targetBackend:brain.reeseapps.com`), with no `#off` row in that arm.
- `test_the_sink_address_is_recognised_not_hard_coded` — a sink block on a different
port (`45999`) still reads as off from the payload's own text; a loopback target
that is *not* the block's port is on.
- `test_a_disabled_line_still_publishes_only_a_measured_count` — table answered and
empty: off rows `0` with a `0.0` sample (measured, never stamped); table absent
(`available: null`): every row `null`, no samples.
- `test_the_stub_and_the_answer_share_one_key_set` — answered rows carry `true`/`false`
(never null, never absent); the stub carries no `enabled`/`disabled` key anywhere
(nobody read the config, so its `None` is the absence of the claim); top-level
parity modulo `unreachable` / `agent_version`.
- plus the extended no-verdict walk (`marked`, `hand_edited` added to the modes).
- `tests/test_api.py` — `GATEWAY_BLOCK_FIELDS` comment restates the thirteenth key with
its reason; `test_the_summary_gateway_block_is_the_payloads_sums...` gains `marked`
(`disabled == 3`) and `hand_edited` (`disabled == 0`, the markers gone → the off state
gone) beside `full`'s `0`; the stub test's `disabled` pin was already in the tree;
`test_the_overview_line_reads_the_gateway_block_it_claims` learns
`GATEWAY_DISABLED_FIELD` (table resolved against the live block) and pins the guard:
printed only `> 0` and `Number.isInteger` — `0` prints nothing, `null` prints nothing.
- `src/dashboards/web/static/page-index.js` — `GATEWAY_DISABLED_FIELD`
(`disabled` / `switched off`) and the second non-zero-guarded clause beside the
`warnings` one.
- `pyproject.toml` — `asyncio_default_fixture_loop_scope = "function"` (pytest 9.2.0
errors at configure without it; the deprecation's own prescription).
- `README.md` / `AGENTS.md` — the `/gateway` paragraph names `enabled`/`off`, the `#off`
id suffix, and the marker-aware warnings rule (marker-caused shapes warn nothing; the
same shape from a hand-deleted line still warns). Task 08's docs pass re-checks both
project-wide.
## Dependency maintenance (rule 8)
`uv pip list --outdated` was non-empty before this run's gate; updated on purpose:
`uv lock --upgrade` + `uv sync` — pydantic 2.14.2, pydantic-core 2.50.2, pytest 9.2.0,
ruff 0.17.3, websockets 17.4 (nodeenv/opentelemetry-api resolved by the same pass).
Now empty.
## Criteria evidence
- 3-marker fixture → three `enabled: false` rows, intact, `warnings == []`: the source
test above; the API arm asserts `block["disabled"] == 3`.
- Markers removed → warnings back: the pair above (one test pair, both directions).
- `gateway.disabled`: `3` marked / `0` clean capture / `null` stub (stub test asserts
`block["disabled"] is None`); the overview line prints nothing for `0` — pinned in
the JS test and observed on the live page (below).
- No `severity` key at any depth: the recursive walk passes, extended, unmodified in
meaning.
- Gate: `uv run pytest` 1877 passed / 17 skipped; coverage 98.86% (floor 94%;
`sources/gateway.py` 98%, `api.py` 99%, both >90%); ruff clean; pyright 0;
`node --check` clean; `uv pip list --outdated` empty; `bash .agents/validate.sh`
exit 0.
## Live verification (rule 10)
Dev server restarted (`./scripts/dev-server.sh restart`); `/api/health` ok for all
sources; the restarted server's live `/api/summary` gateway block serves the new key:
`{'host': 'gateway', 'listeners': 10, 'connections': 33, 'disabled': 0, 'live_in': 14,
'live_out': 14, 'map_targets': 18, 'warnings': 0}` — `disabled: 0` is the honest live
value (the real fleet carries no `#off` marker; the live config was not touched).
Real-browser pass signed in with `testpass`: the ingress line renders
`gateway 10 listeners · 33 connections · 15 live in · 15 live out` — the four figures
present, no `· 0 switched off` clause (the guard suppresses it at `0`, the
empty-state rule restated for the switch). Screenshot:
`data/logs/gateway-switch-04-overview-line.png`.
## Flags for the operator (bookkeeping, left to the driver)
1. `04_...md` now exists **identically in both** `todo/` and `complete/` (the `a1` run
copied it without removing the `todo/` copy; 01–03 follow the move convention).
Per the working protocol I did not touch `todo/`; the driver's move step should
remove the `todo/` copy.
2. `05_...md` also exists in `complete/` from the aborted `a1` run — **task 05's work
is not in the tree** (no `POST /api/gateway/route`, no `GATEWAY_ALLOW_WRITES`, no
`collectors.post_route`), and its `a1` transcript ends mid-work ("Now let me read
the code I'm touching"). Task 05 needs a clean re-run; its `todo/` copy is still
present, which is exactly what the driver looks for.
3. None of the phase-33 file moves or code is committed yet (`git status`: 01–03 moved,
04/05 in `complete/` untracked, all code changes uncommitted).
@@ -0,0 +1,10 @@
All changes are in the working tree, nothing staged. Task complete — final report:
**Phase 33 task 05 — done. `POST /api/gateway/route` is the app's second and last upstream write.**
- `config.py`: `GATEWAY_ALLOW_WRITES` (default **off**, NUT truthy rule) + `GATEWAY_WRITE_TIMEOUT_SECONDS` (35 s, outlives the agent's 30 s peer timeout); read budget untouched.
- `collectors.py`: `post_route` — per-dial `AgentTls`, revocation before any socket, no `Authorization` on the certificate path, redacted URLs, helper's object verbatim.
- `api.py`: the route with the ten numbered gates (1–3 middleware; 4 `DASH_READ_ONLY`; 5 flag; 6 `route:<address>` throttle + `Retry-After`; 7 hand-parsed body 422; 8 payload state 409 `{changed: false}`; 9 freshness 409; 10 `danger="DANGER"` for both verbs); one event per call, no diff/path/config text in any event; `/api/config` serves `gateway.writes_enabled`.
- Tests: surface test rewritten (two fleet writes, fails on a third), all ten gates parametrised with a dial spy, revoked-agent no-socket, no-diff event pin, danger/throttle/read-only/write-timeout pins, wire-id base spelling; `test_auth_api` walk + CSRF on the wire.
- Docs same commit: AGENTS.md invariant 1 now names two gated exceptions (plus the api/collectors/config bullets, gap 8 removed), README + `.env.example` env rows; `DASH_READ_ONLY` greps in both write paths.
- Dev-server curl checks: writes-off answers the `GATEWAY_ALLOW_WRITES` sentence; live 1.3.0 fleet answers the version gate first (correct order).
- `bash .agents/validate.sh` **exit 0**: 1915 passed / 17 skipped, coverage 98.72% (94 floor; `api.py` 99%, `collectors.py` 98%), ruff + pyright clean.
@@ -0,0 +1,70 @@
== uv run pytest --cov (hermetic: no network, live tests skip) ==
........................................................................ [ 3%]
........................................................................ [ 7%]
........................................................................ [ 11%]
........................................................................ [ 14%]
........................................................................ [ 18%]
........................................................................ [ 22%]
........................................................................ [ 26%]
........................................................................ [ 29%]
.......................................................................s [ 33%]
........................................................................ [ 37%]
........................................................................ [ 40%]
........................................................................ [ 44%]
........................................................................ [ 48%]
........................................................................ [ 52%]
........................................................................ [ 55%]
........................................................................ [ 59%]
........................................................................ [ 63%]
........................................................................ [ 67%]
........................................................................ [ 70%]
........................................................................ [ 74%]
.............sssssssssssss.............................................. [ 78%]
........................................................................ [ 81%]
........................................................................ [ 85%]
........................................................................ [ 89%]
........................................................................ [ 93%]
........................................................................ [ 96%]
..sss....................................................... [100%]
=============================== warnings summary ===============================
.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1
/home/ducoterra/Projects/Personal/dashboards/.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.14.8-final-0 ________________
Name Stmts Miss Cover Missing
------------------------------------------------------------------
src/dashboards/__init__.py 3 0 100%
src/dashboards/__main__.py 14 1 93% 32
src/dashboards/alerts.py 201 0 100%
src/dashboards/api.py 622 9 99% 229, 1273-1275, 1310-1313, 1379, 1387, 1436, 1439
src/dashboards/app.py 190 0 100%
src/dashboards/auth.py 265 0 100%
src/dashboards/certs.py 292 0 100%
src/dashboards/collectors.py 244 4 98% 387, 391-392, 409
src/dashboards/config.py 128 0 100%
src/dashboards/debug.py 14 0 100%
src/dashboards/poller.py 165 3 98% 192-194, 279
src/dashboards/prom.py 112 4 96% 126, 163, 180, 183
src/dashboards/rates.py 73 0 100%
src/dashboards/severity.py 49 0 100%
src/dashboards/sources/__init__.py 0 0 100%
src/dashboards/sources/disks.py 134 0 100%
src/dashboards/sources/gateway.py 613 10 98% 339, 588, 601-602, 652, 785, 1089, 1140, 1207, 1569
src/dashboards/sources/host.py 209 0 100%
src/dashboards/sources/llm.py 178 1 99% 285
src/dashboards/sources/nut.py 413 20 95% 153, 172, 186, 198, 204, 217, 225, 241, 319-320, 322, 579, 591, 626, 657, 661, 718-719, 724, 736
src/dashboards/sources/nut_sim.py 134 0 100%
src/dashboards/store.py 313 2 99% 553-554
------------------------------------------------------------------
TOTAL 4366 54 99%
Required test coverage of 94% reached. Total coverage: 98.76%
1915 passed, 17 skipped, 1 warning in 173.60s (0:02:53)
== uv run ruff check src tests agent ==
All checks passed!
== uv run pyright ==
0 errors, 0 warnings, 0 informations
== node --check src/dashboards/web/static/*.js ==
@@ -0,0 +1,10 @@
Task 06 complete. Final report:
**33/06 — the switch obeys `gateway.writes_enabled`**
- **Two-arm switch** (`page-gateway.js`): the arm is `gateway.writes_enabled` read from `/api/config` (never inferred; a dead fetch leaves the safe arm). Writes-off: `aria-disabled="true"`, `cursor: not-allowed`, no listener, title = the API's refusal byte-for-byte. Writes-on: focusable `role="switch"`, `cursor: pointer`, one click listener, a title naming the config-file edit → validator → reload. Only the switch's own `aria-checked` may be optimistic; the row's word/class/spine stay the payload's.
- **Flow**: click → server `dry_run: true` (Gate 10 now exempts it; it asks nothing, writes nothing, and writes no `route-*` event) → dialog prints the diff verbatim (`+`/`−` tinted from the existence pair, `--gate-on`/`--gate-off`) → typed `DANGER` (wrong word sends nothing) → one send, `aria-busy`, no retry loop. `applied: true` flips the switch early + `refresh()`; `applied: false` prints the helper's word and flips nothing.
- **Pins**: the spec's four named tests added (`…wired_only_when_the_deployment_enables_writes`, `…preview_then_the_word_then_sends_once`, `…painted_from_the_answer_not_the_click`, `…sentence_matches_the_routes_refusal` — the last reads both files via `ast`); hover allowed on the live arm only; `routeInConfig` pin moved to `conn.enabled !== false` with `connections.enabled` in `GATEWAY_PAGE_FIELDS`; dialog/diff pins added.
- **Gate**: `.agents/validate.sh` green — 1919 passed / 17 skipped, coverage 98.72% (floor 94), ruff + pyright clean, `node --check` clean.
- **Browser** (Node Playwright): hermetic writes-on smoke (patched `post_route`, `marked` fixture) — live arm, dry-run diff, wrong word, `DANGER` applied, optimistic flip, refused arm; 360px viewport (no sideways scroll, dialog in view), focus in the confirm field, Esc closes and returns focus; hover tint verified. Live dev server (restarted, all sources ok): off arm exact sentence + `not-allowed`.
- **Docs**: README switch section + agent 1.4.0; AGENTS.md invariant 1 gains the dry-run clause. Screenshots in `data/logs/33-task06-*.png`.
@@ -0,0 +1,70 @@
== uv run pytest --cov (hermetic: no network, live tests skip) ==
........................................................................ [ 3%]
........................................................................ [ 7%]
........................................................................ [ 11%]
........................................................................ [ 14%]
........................................................................ [ 18%]
........................................................................ [ 22%]
........................................................................ [ 26%]
........................................................................ [ 29%]
........................................................................ [ 33%]
...s.................................................................... [ 37%]
........................................................................ [ 40%]
........................................................................ [ 44%]
........................................................................ [ 48%]
........................................................................ [ 52%]
........................................................................ [ 55%]
........................................................................ [ 59%]
........................................................................ [ 63%]
........................................................................ [ 66%]
........................................................................ [ 70%]
........................................................................ [ 74%]
.................sssssssssssss.......................................... [ 78%]
........................................................................ [ 81%]
........................................................................ [ 85%]
........................................................................ [ 89%]
........................................................................ [ 92%]
........................................................................ [ 96%]
......sss....................................................... [100%]
=============================== warnings summary ===============================
.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1
/home/ducoterra/Projects/Personal/dashboards/.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.14.8-final-0 ________________
Name Stmts Miss Cover Missing
------------------------------------------------------------------
src/dashboards/__init__.py 3 0 100%
src/dashboards/__main__.py 14 1 93% 32
src/dashboards/alerts.py 201 0 100%
src/dashboards/api.py 625 9 99% 229, 1273-1275, 1310-1313, 1382, 1390, 1446, 1449
src/dashboards/app.py 190 0 100%
src/dashboards/auth.py 265 0 100%
src/dashboards/certs.py 292 0 100%
src/dashboards/collectors.py 244 5 98% 386-387, 391-392, 409
src/dashboards/config.py 128 0 100%
src/dashboards/debug.py 14 0 100%
src/dashboards/poller.py 165 3 98% 192-194, 279
src/dashboards/prom.py 112 5 96% 126, 163, 177, 180, 183
src/dashboards/rates.py 73 0 100%
src/dashboards/severity.py 49 0 100%
src/dashboards/sources/__init__.py 0 0 100%
src/dashboards/sources/disks.py 134 0 100%
src/dashboards/sources/gateway.py 613 10 98% 339, 588, 601-602, 652, 785, 1089, 1140, 1207, 1569
src/dashboards/sources/host.py 209 0 100%
src/dashboards/sources/llm.py 178 1 99% 285
src/dashboards/sources/nut.py 413 20 95% 153, 172, 186, 198, 204, 217, 225, 241, 319-320, 322, 579, 591, 626, 657, 661, 718-719, 724, 736
src/dashboards/sources/nut_sim.py 134 0 100%
src/dashboards/store.py 313 2 99% 553-554
------------------------------------------------------------------
TOTAL 4369 56 99%
Required test coverage of 94% reached. Total coverage: 98.72%
1919 passed, 17 skipped, 1 warning in 171.78s (0:02:51)
== uv run ruff check src tests agent ==
All checks passed!
== uv run pyright ==
0 errors, 0 warnings, 0 informations
== node --check src/dashboards/web/static/*.js ==
@@ -0,0 +1,13 @@
No interactive channel is available to confirm operator presence, so per the task protocol I stop after step 3 and report.
---
**Task 07 report — steps 1–3 complete; step 4 (live drill) not started, operator absence**
1. **Backups** — all four live configs + agent unit hashed to `/root/dashboards-33-backup-20261101-0950`; `diff -r` byte-identical.
2. **Rehearsal** — sandbox tree with real config copies: 3 `--apply` arms (haproxy server, nginx vhost, nginx map→sink) and the `map-default` refusal all behaved; checker-failure restore proven; sandbox left byte-identical.
3. **Install** — 1.4.0 + `--allow-writes` (installer diff: exactly one line), routectl helper/socket/template on the box; two live-kernel bugs found and fixed, both pinned in tests.
4. **Live-kernel fixes (repo + box)** — `Accept=yes` socket activation (systemd 257 rejected `Service=`); `serve()` reads fd 3 then fd 0 with `recv()`; refusals sent on the wire; ucred layout calibrated (this kernel delivers `{pid,uid,gid}`) with a root `setuid` probe so a matching gid on a foreign uid **cannot** open the gate; a `refused()` tuple-inversion that serialised refusals as JSON lists was caught by the live chain and fixed.
5. **Gates proven live** — true uid-994 client passes; `nobody` and uid-1000 get EACCES; root-with-matching-gid is refused by the peer gate; full 4-hop dry run (dashboard TLS dial → agent `POST /route` → socket → helper) returned the helper's verbatim diff (`#off iperf3`), nothing written.
6. **Post-dry-run state** — all four config hashes equal pre-drill, state sidecar empty, zero proxy reloads; full suite/Ruff/Pyright/`validate.sh` green on the final code.
7. **Step 4 blocked** — the off→on drill on a named route awaits an operator present to watch; the ingress is left byte-identical to its pre-work state.
@@ -0,0 +1 @@
error: compaction failed: Auto-compaction failed: Summarization failed: generation hit the token cap and the summary is incomplete; compaction failed: Auto-compaction…
@@ -0,0 +1,73 @@
== uv run pytest --cov (hermetic: no network, live tests skip) ==
........................................................................ [ 3%]
........................................................................ [ 7%]
........................................................................ [ 10%]
........................................................................ [ 14%]
........................................................................ [ 18%]
........................................................................ [ 21%]
........................................................................ [ 25%]
........................................................................ [ 29%]
........................................................................ [ 32%]
.........................s.............................................. [ 36%]
........................................................................ [ 40%]
........................................................................ [ 43%]
........................................................................ [ 47%]
........................................................................ [ 51%]
........................................................................ [ 54%]
........................................................................ [ 58%]
........................................................................ [ 62%]
........................................................................ [ 65%]
........................................................................ [ 69%]
........................................................................ [ 73%]
.................................................sssssssssssss.......... [ 76%]
........................................................................ [ 80%]
........................................................................ [ 84%]
........................................................................ [ 87%]
........................................................................ [ 91%]
........................................................................ [ 95%]
......................................sss............................... [ 98%]
........................ [100%]
=============================== warnings summary ===============================
.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1
/home/ducoterra/Projects/Personal/dashboards/.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.14.8-final-0 ________________
Name Stmts Miss Cover Missing
------------------------------------------------------------------
src/dashboards/__init__.py 3 0 100%
src/dashboards/__main__.py 14 1 93% 32
src/dashboards/alerts.py 201 0 100%
src/dashboards/api.py 625 9 99% 229, 1273-1275, 1310-1313, 1382, 1390, 1446, 1449
src/dashboards/app.py 190 0 100%
src/dashboards/auth.py 265 0 100%
src/dashboards/certs.py 292 0 100%
src/dashboards/collectors.py 244 5 98% 386-387, 391-392, 409
src/dashboards/config.py 128 0 100%
src/dashboards/debug.py 14 0 100%
src/dashboards/poller.py 165 3 98% 192-194, 279
src/dashboards/prom.py 112 5 96% 126, 163, 177, 180, 183
src/dashboards/rates.py 73 0 100%
src/dashboards/severity.py 49 0 100%
src/dashboards/sources/__init__.py 0 0 100%
src/dashboards/sources/disks.py 134 0 100%
src/dashboards/sources/gateway.py 613 10 98% 339, 588, 601-602, 652, 785, 1089, 1140, 1207, 1569
src/dashboards/sources/host.py 209 0 100%
src/dashboards/sources/llm.py 178 1 99% 285
src/dashboards/sources/nut.py 413 20 95% 153, 172, 186, 198, 204, 217, 225, 241, 319-320, 322, 579, 591, 626, 657, 661, 718-719, 724, 736
src/dashboards/sources/nut_sim.py 134 0 100%
src/dashboards/store.py 313 2 99% 553-554
------------------------------------------------------------------
TOTAL 4369 56 99%
Required test coverage of 94% reached. Total coverage: 98.72%
1951 passed, 17 skipped, 1 warning in 193.21s (0:03:13)
== uv run ruff check src tests agent ==
All checks passed!
== uv run pyright ==
0 errors, 0 warnings, 0 informations
== node --check src/dashboards/web/static/*.js ==
advisory: dependencies are out of date (AGENTS.md rule 8 — update on purpose):
anyio 4.15.1 4.16.0 wheel
@@ -0,0 +1,10 @@
All green. Task 07 re-run is complete:
1. **Fixed:** anyio 4.15.1 → 4.16.0 (`uv lock` + `uv sync`; `uv pip list --outdated` now empty, rule 8); completed the two missing task-07 deliverables.
2. **Added** the opt-in live test (step 8): `test_the_switch_round_trip_on_one_named_route` — `live`-marked, gated by a separate `--allow-live-writes` flag (new `conftest.py` option), skips by default with a reason naming its route, restores in a `finally`; dials the app's own `collectors.post_route` and proves the round trip on the production read (dry-run diff, helper's `applied/validated/reloaded`, row `enabled` both ways, byte-identical haproxy section pre/post).
3. **Wrote** `data/logs/NOTES-33-switching.md`: first line = *all routes enabled*; before/after hashes identical (drill never wrote); guarded unit diff = exactly ` --allow-writes`; socket `0600 dashboards-agent:dashboards-agent`; three probe transcripts (`list` → `{"ok":true,"state":[]}`, foreign uid → EACCES, wire body gate); journal window (two activations, no reload).
4. **Decisions:** drill stopped at step 4 — (a) no operator said go in-session (the task's own stop condition) and (b) independently, the checker precondition cannot pass: no host `nginx`/`haproxy` binary on the box (podman container units), so a real `--apply` refuses at the checker and restores; the checker fix (container path or host binaries) is a task-08 decision, not made unilaterally.
5. **New finding (recorded, not fixed):** this box's systemd 257 build silently drops the helper unit's `ProtectSystem=read-only` (gperf bug — `off/yes/strict/full` parse clean); the socket gate is intact; candidate `full` + state-dir `ReadWritePaths` fix left for operator review with the unit diff.
6. **Gates:** `validate.sh` exit 0 — **1951 passed, 18 skipped**, coverage **98.72%** (floor 94%), Ruff + Pyright clean, JS syntax + hygiene pass; dev server: 10 sources `ok`.
7. **Live read-only:** `pytest -m live --live` → 13 passed, 1 skipped (the round-trip, skip reason names `haproxy:5201->web.reeselink.com:5201`).
8. **Next pending:** task 08 (docs + live verify) — the drill itself (needs an operator's go plus the checker fix) and the `ProtectSystem` decision.
@@ -0,0 +1,71 @@
== uv run pytest --cov (hermetic: no network, live tests skip) ==
........................................................................ [ 3%]
........................................................................ [ 7%]
........................................................................ [ 10%]
........................................................................ [ 14%]
........................................................................ [ 18%]
........................................................................ [ 21%]
........................................................................ [ 25%]
........................................................................ [ 29%]
........................................................................ [ 32%]
.........................s.............................................. [ 36%]
........................................................................ [ 40%]
........................................................................ [ 43%]
........................................................................ [ 47%]
........................................................................ [ 51%]
........................................................................ [ 54%]
........................................................................ [ 58%]
........................................................................ [ 62%]
........................................................................ [ 65%]
........................................................................ [ 69%]
........................................................................ [ 73%]
.................................................ssssssssssssss......... [ 76%]
........................................................................ [ 80%]
........................................................................ [ 84%]
........................................................................ [ 87%]
........................................................................ [ 91%]
........................................................................ [ 95%]
.......................................sss.............................. [ 98%]
......................... [100%]
=============================== warnings summary ===============================
.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1
/home/ducoterra/Projects/Personal/dashboards/.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.14.8-final-0 ________________
Name Stmts Miss Cover Missing
------------------------------------------------------------------
src/dashboards/__init__.py 3 0 100%
src/dashboards/__main__.py 14 1 93% 32
src/dashboards/alerts.py 201 0 100%
src/dashboards/api.py 625 9 99% 229, 1273-1275, 1310-1313, 1382, 1390, 1446, 1449
src/dashboards/app.py 190 0 100%
src/dashboards/auth.py 265 0 100%
src/dashboards/certs.py 292 0 100%
src/dashboards/collectors.py 244 4 98% 387, 391-392, 409
src/dashboards/config.py 128 0 100%
src/dashboards/debug.py 14 0 100%
src/dashboards/poller.py 165 3 98% 192-194, 279
src/dashboards/prom.py 112 5 96% 126, 163, 177, 180, 183
src/dashboards/rates.py 73 0 100%
src/dashboards/severity.py 49 0 100%
src/dashboards/sources/__init__.py 0 0 100%
src/dashboards/sources/disks.py 134 0 100%
src/dashboards/sources/gateway.py 613 10 98% 339, 588, 601-602, 652, 785, 1089, 1140, 1207, 1569
src/dashboards/sources/host.py 209 0 100%
src/dashboards/sources/llm.py 178 1 99% 285
src/dashboards/sources/nut.py 413 20 95% 153, 172, 186, 198, 204, 217, 225, 241, 319-320, 322, 579, 591, 626, 657, 661, 718-719, 724, 736
src/dashboards/sources/nut_sim.py 134 0 100%
src/dashboards/store.py 313 2 99% 553-554
------------------------------------------------------------------
TOTAL 4369 55 99%
Required test coverage of 94% reached. Total coverage: 98.74%
1951 passed, 18 skipped, 1 warning in 189.82s (0:03:09)
== uv run ruff check src tests agent ==
All checks passed!
== uv run pyright ==
0 errors, 0 warnings, 0 informations
== node --check src/dashboards/web/static/*.js ==
@@ -0,0 +1,10 @@
All green. Final report:
**Task 08 complete — docs, browser pass, full gate.**
- **Docs**: `AGENTS.md` (invariant 1's two gated writes, 1.4.0 agent + marker grammar + no-retry rule, routectl deploy surface + units + byte-identical agent unit, `sources/gateway.py` `disabled`/merge/warnings + `/api/summary`, `api.py` gate order + audit events, `post_route` revoke-before-socket, fleet record, UI conventions, gap 8) and `README.md` (Route switching section: Enabling it + undo-everything, Switching a route with the three recovery commands and the deliberate-outage sentence, host-agent write, table row, two config rows, stale "unwired" paragraph fixed, orphaned UPS text restored) — plus `GATEWAY_ALLOW_WRITES` declared (never assigned) in both env templates, pinned by the new `tests/test_auth_docs.py` `WRITE_GATES` test.
- **Browser pass**: `33-switching.cjs` **45/45 checks, exit 0** — both arms, `--gate-off`/`--gate-on` equality end-to-end in dark + light (on-half distinct from `--warn`/`--fail`), paint follows the payload never the click, `applied: false` arm, Escape, reduced-motion still, 360 px zero sideways scroll; 8 screenshots + `33-switching.out`.
- **Mobile audit**: `27-task02-mobile-audit.cjs` re-run → **exit 0, 371 PASS, 0 FAIL, 0 finding** (`33-task08-mobile-audit.out`).
- **Gate**: `validate.sh` **exit 0** (1953 passed, coverage 98.76 % ≥ 94, ruff clean incl. `deploy`, pyright 0, outdated empty); plain `uv run pytest` green; live suite **13 passed + the write round-trip skipped by design** (reason names the route it would touch).
- **Status checks**: dev server (writes off) 10/10 ok unchanged; smoke server (flag on, fake agent) 10/10 ok honest (real mTLS dials — the script now seeds the dev DB's `certs.*` rows read-only, since a fresh DB mints a CA the agents refuse) and a curl write round-trips: dry-run diff → `applied/validated/reloaded` → next payload's row `#off`/`enabled: false`.
- **Live call (conservative, no live changes)**: no real `DANGER` sent. The dry-run rehearsal stands from task 07; the phase's own decision leaves the drill to the operator, and this fleet's proxies run in podman containers so the host lacks the `nginx`/`haproxy` binary the helper's checker needs — a live drill here would refuse by construction (recorded in `NOTES-33-switching.md`). Read-only live verification is green.
@@ -0,0 +1,71 @@
== uv run pytest --cov (hermetic: no network, live tests skip) ==
........................................................................ [ 3%]
........................................................................ [ 7%]
........................................................................ [ 10%]
........................................................................ [ 14%]
........................................................................ [ 18%]
........................................................................ [ 21%]
........................................................................ [ 25%]
........................................................................ [ 29%]
........................................................................ [ 32%]
.........................s.............................................. [ 36%]
........................................................................ [ 40%]
........................................................................ [ 43%]
........................................................................ [ 47%]
........................................................................ [ 51%]
........................................................................ [ 54%]
........................................................................ [ 58%]
........................................................................ [ 62%]
........................................................................ [ 65%]
........................................................................ [ 69%]
........................................................................ [ 73%]
...................................................ssssssssssssss....... [ 76%]
........................................................................ [ 80%]
........................................................................ [ 84%]
........................................................................ [ 87%]
........................................................................ [ 91%]
........................................................................ [ 94%]
.........................................sss............................ [ 98%]
........................... [100%]
=============================== warnings summary ===============================
.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1
/home/ducoterra/Projects/Personal/dashboards/.venv/lib64/python3.14/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
================================ tests coverage ================================
_______________ coverage: platform linux, python 3.14.8-final-0 ________________
Name Stmts Miss Cover Missing
------------------------------------------------------------------
src/dashboards/__init__.py 3 0 100%
src/dashboards/__main__.py 14 1 93% 32
src/dashboards/alerts.py 201 0 100%
src/dashboards/api.py 625 9 99% 229, 1273-1275, 1310-1313, 1382, 1390, 1446, 1449
src/dashboards/app.py 190 0 100%
src/dashboards/auth.py 265 0 100%
src/dashboards/certs.py 292 0 100%
src/dashboards/collectors.py 244 5 98% 386-387, 391-392, 409
src/dashboards/config.py 128 0 100%
src/dashboards/debug.py 14 0 100%
src/dashboards/poller.py 165 3 98% 192-194, 279
src/dashboards/prom.py 112 5 96% 126, 163, 177, 180, 183
src/dashboards/rates.py 73 0 100%
src/dashboards/severity.py 49 0 100%
src/dashboards/sources/__init__.py 0 0 100%
src/dashboards/sources/disks.py 134 0 100%
src/dashboards/sources/gateway.py 613 10 98% 339, 588, 601-602, 652, 785, 1089, 1140, 1207, 1569
src/dashboards/sources/host.py 209 0 100%
src/dashboards/sources/llm.py 178 1 99% 285
src/dashboards/sources/nut.py 413 20 95% 153, 172, 186, 198, 204, 217, 225, 241, 319-320, 322, 579, 591, 626, 657, 661, 718-719, 724, 736
src/dashboards/sources/nut_sim.py 134 0 100%
src/dashboards/store.py 313 2 99% 553-554
------------------------------------------------------------------
TOTAL 4369 56 99%
Required test coverage of 94% reached. Total coverage: 98.72%
1953 passed, 18 skipped, 1 warning in 191.57s (0:03:11)
== uv run ruff check src tests agent ==
All checks passed!
== uv run pyright ==
0 errors, 0 warnings, 0 informations
== node --check src/dashboards/web/static/*.js ==
+6
View File
@@ -50,6 +50,12 @@ export HOST_TARGETS="driveripper=https://driveripper.example.net:9120 deskwork=h
# else, or to poll faster while developing the page. # else, or to poll faster while developing the page.
# export GATEWAY_HOST=gateway # export GATEWAY_HOST=gateway
# export GATEWAY_INTERVAL_SECONDS=10 # export GATEWAY_INTERVAL_SECONDS=10
# The route switch's write gate — a decision of its own: off by default (one flag does not
# carry NUT_ALLOW_ACTIONS), and in a dev checkout it is a write to the **real fleet**: it
# authorises the dashboard's session to POST /api/gateway/route through the agent's
# certificate-first gate to the routectl helper, which can mark a route in the gateway's
# config on or off, validate the file with the proxy's own checker, and reload that proxy.
# export GATEWAY_ALLOW_WRITES=1
# The engines are dialled directly on their hostnames, like the agents. # The engines are dialled directly on their hostnames, like the agents.
export GUFO_BASE_URL=http://deskwork.example.net:8731 export GUFO_BASE_URL=http://deskwork.example.net:8731
+8 -2
View File
@@ -88,6 +88,12 @@ GATEWAY_HOST=gateway
# tables — with headroom because the same handler may be mid-smartctl pass for /disks. # tables — with headroom because the same handler may be mid-smartctl pass for /disks.
GATEWAY_INTERVAL_SECONDS=30 GATEWAY_INTERVAL_SECONDS=30
GATEWAY_TIMEOUT_SECONDS=10 GATEWAY_TIMEOUT_SECONDS=10
# The route switch is the app's second write path and a decision of its own: off by
# default, the same truthy rule as NUT_ALLOW_ACTIONS, and one flag does not carry the
# other. The write's budget outlives the agent's 30 s peer timeout on purpose, so the
# dashboard never times out first and leaves you guessing whether the reload happened.
# GATEWAY_ALLOW_WRITES=1
# GATEWAY_WRITE_TIMEOUT_SECONDS=35
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# LLM dashboard: either engine is optional — unset means "not deployed here" # LLM dashboard: either engine is optional — unset means "not deployed here"
@@ -137,8 +143,8 @@ NUT_USER=
NUT_PASSWORD= NUT_PASSWORD=
NUT_INTERVAL_SECONDS=5 NUT_INTERVAL_SECONDS=5
NUT_TIMEOUT_SECONDS=4 NUT_TIMEOUT_SECONDS=4
# Instant commands (battery test, beeper, calibration) are the only write path in the # Instant commands (battery test, beeper, calibration) are one of the app's two write
# app. Requires NUT_USER plus this flag; the UI still confirms every command. # paths. Requires NUT_USER plus this flag; the UI still confirms every command.
NUT_ALLOW_ACTIONS=0 NUT_ALLOW_ACTIONS=0
# NUT_SIMULATE=1 replaces upsd with the labelled simulator (sources/nut_sim.py) so the UPS page # NUT_SIMULATE=1 replaces upsd with the labelled simulator (sources/nut_sim.py) so the UPS page
# is developable with no battery in the house. Every panel it produces says SIMULATED, and it is # is developable with no battery in the house. Every panel it produces says SIMULATED, and it is
+109 -34
View File
@@ -28,24 +28,31 @@ file — plus an overview page:
| `/disks` | Are any physical disks failing, and which — health, temp, wear | the same agents, `smartctl -i -A -H` per whole drive | | `/disks` | Are any physical disks failing, and which — health, temp, wear | the same agents, `smartctl -i -A -H` per whole drive |
| `/llm` | Tokens/s, active sessions, queue depth, KV pressure, latency | gufo `/metrics` + `/slots`, vLLM `/metrics` | | `/llm` | Tokens/s, active sessions, queue depth, KV pressure, latency | gufo `/metrics` + `/slots`, vLLM `/metrics` |
| `/ups` | Battery health, runtime left, power events, test buttons | NUT `upsd` (our own wire client; port **3051**, not 3493) | | `/ups` | Battery health, runtime left, power events, test buttons | NUT `upsd` (our own wire client; port **3051**, not 3493) |
| `/gateway` | Which domains on which port, and where each goes | the same agent's `haproxy` + `nginx` sections joined to `/proc/net/tcp{,6}` on the one `GATEWAY_HOST` host | | `/gateway` | Which domains on which port, and where each goes | the same agent's `haproxy` + `nginx` sections joined to `/proc/net/tcp{,6}` on the one `GATEWAY_HOST` host (the page needs agent **≥ 1.2.0**; the route switch's `#off` marker read is **1.4.0**) |
History is a first-class feature: every number worth graphing is stored, rolled up History is a first-class feature: every number worth graphing is stored, rolled up
(raw → 1m → 1h) and served from `/api/*/history`. (raw → 1m → 1h) and served from `/api/*/history`.
None of it is public. One admin signs in at `/login` (the only page an anonymous visitor None of it is public. One admin signs in at `/login` (the only page an anonymous visitor
reaches); `/settings` (⚙ Account) and `/certs` (⚷ Certs) live behind it. Every page and reaches); `/settings` (⚙ Account) and `/certs` (⚷ Certs) live behind it. Every page and
every `/api` route requires the session except the public entries in rule 12. every `/api` route requires the session except the public entries in invariant 12.
## Non-negotiable rules ## Non-negotiable rules
Breaking one is a bug even if the page still renders. Breaking one is a bug even if the page still renders.
1. **Read-only by construction, with exactly one exception.** Nothing writes to an upstream 1. **Read-only by construction, with exactly two exceptions, and both are gated on purpose.**
except `POST /api/ups/command`, gated four times (env allow, not `DASH_READ_ONLY`, the Nothing writes to an upstream except `POST /api/ups/command` and
upsd account actually having the command, a UI confirmation — typed `DANGER` for `POST /api/gateway/route`, and each is re-checked on every request by its own gates:
anything that can cut power) and re-checked on every request; power-cutting commands are the UPS one is gated four times (env allow, not `DASH_READ_ONLY`, the upsd account
refused outright while on battery. Hiding a button is not a safety control. actually having the command, a UI confirmation — typed `DANGER` for anything that can
cut power) and power-cutting commands are refused outright while on battery; the gateway
one is gated ten (not `DASH_READ_ONLY`, `GATEWAY_ALLOW_WRITES` — off by default — a
per-address write throttle, a hand-parsed body, the route's state in the current
payload, the freshness of the read end to end, and a typed `danger="DANGER"` for
**both** verbs — a `dry_run` preview asks nothing and writes nothing — for anything
that can cut a route through the fleet's only ingress), and it dials once through
the same revocation gate the reads take. Hiding a button is not a safety control.
2. **Never invent a number.** A value the upstream does not publish renders as `n/a`, never 2. **Never invent a number.** A value the upstream does not publish renders as `n/a`, never
as `0`. Every derived value carries its `source` string and `derived: true`; the rate as `0`. Every derived value carries its `source` string and `derived: true`; the rate
estimator is keyed by the *resolved* series name **plus its label set** and refuses a estimator is keyed by the *resolved* series name **plus its label set** and refuses a
@@ -108,9 +115,9 @@ Breaking one is a bug even if the page still renders.
session's `X-CSRF-Token`. A process whose lifespan never produced `ctx.auth` answers session's `X-CSRF-Token`. A process whose lifespan never produced `ctx.auth` answers
`503` for everything, public paths included. `tests/test_auth_api.py` walks the `503` for everything, public paths included. `tests/test_auth_api.py` walks the
**served** route table so a route that forgets the gate fails a test instead of **served** route table so a route that forgets the gate fails a test instead of
shipping — the middleware guards by default. Nothing may be added to `/api/health` shipping — the middleware guards by default. `/api/health` is a contract with
without re-deciding that: it is public, and a new field there is a field the whole deployment, which means **nothing may be added to it without re-deciding that**: it is
internet reads. public, and a new field there is a field the whole internet reads.
## Architecture ## Architecture
@@ -118,7 +125,7 @@ Breaking one is a bug even if the page still renders.
agent/host_agent.py (each host) gufo / vLLM upsd (NUT) agent/host_agent.py (each host) gufo / vLLM upsd (NUT)
│ GET /snapshot │ GET /metrics │ LIST UPS / VAR / CMD │ GET /snapshot │ GET /metrics │ LIST UPS / VAR / CMD
▼ ▼ ▼ ▼ ▼ ▼
collectors.py ── fetch, one UpstreamError type, no writes except run_ups_command collectors.py ── fetch, one UpstreamError type, no writes except run_ups_command and post_route
│ parse + Δcounter/Δt │ parse + Δcounter/Δt
▼ ▼
sources/host.py · disks.py · llm.py · nut.py · gateway.py poller.py (task per source) sources/host.py · disks.py · llm.py · nut.py · gateway.py poller.py (task per source)
@@ -144,10 +151,25 @@ One line each — the load-bearing behaviour is in the bullet, the rest is in th
error line per non-`true` arm — the missing one saying **not found**, the pinned wording error line per non-`true` arm — the missing one saying **not found**, the pinned wording
the dashboard reads to answer "is it installed?" — and `--no-proxy` / `--no-resolve` the dashboard reads to answer "is it installed?" — and `--no-proxy` / `--no-resolve`
remove their keys in silence. Nothing in a section can fail `/snapshot` or `/health`. remove their keys in silence. Nothing in a section can fail `/snapshot` or `/health`.
Counters stay counters: the dashboard computes rates. Counters stay counters: the dashboard computes rates. **The one write (1.4.0, gateway
hosts):** a single `POST /route`, refused without a verified, non-revoked client
certificate **and** the agent's own `--allow-writes` flag (certificate first, in that
order), forwarding exactly `{id, action, dry_run}` to the socket-activated root helper
`dashboards-routectl` and answering its JSON verbatim — **one connection, one
request, one answer, no retry**, because a retry of a write is a second reload. The
privilege is the helper's socket, not the agent's: the unit keeps
`NoNewPrivileges=true` + `ProtectSystem=strict` byte-identical with and without
`--with-routectl`, and no sudoers line is involved. Every `GET` path is untouched —
`#off` markers are read on the normal proxy sections (the marker's own grammar is the
agent's: `ROUTE_MARKER` / `split_marker` / `config_line_index` — `disabled` on
`server`/`proxy_pass`/`map` nodes; a marked line stays in the document).
- `config.py` — env only, no config file format, no CLI flags. `.env` is a *seed*; a real - `config.py` — env only, no config file format, no CLI flags. `.env` is a *seed*; a real
env var always wins. Safe defaults except the sources, which default to unset. The env var always wins. Safe defaults except the sources, which default to unset. The
gateway source is enabled only when `GATEWAY_HOST` names a `HOST_TARGETS` entry. gateway source is enabled only when `GATEWAY_HOST` names a `HOST_TARGETS` entry
(default `gateway`). The write's two settings are beside it: `GATEWAY_ALLOW_WRITES`
(default **off**, the NUT flag's truthy rule) and `GATEWAY_WRITE_TIMEOUT_SECONDS`
(default 35 s, deliberately longer than the agent's 30 s peer timeout so the dashboard
never times out first); the read's 10 s budget stays the read's alone.
- `poller.py` — job loop, health records, stale/last-good semantics, transition events. - `poller.py` — job loop, health records, stale/last-good semantics, transition events.
`disks:<host>` is a source of its own (300 s / 30 s) and `gateway` is **one** job (30 s `disks:<host>` is a source of its own (300 s / 30 s) and `gateway` is **one** job (30 s
/ 10 s) that exists only when configured, because the page is about the single ingress; / 10 s) that exists only when configured, because the page is about the single ingress;
@@ -160,7 +182,11 @@ One line each — the load-bearing behaviour is in the bullet, the rest is in th
- `collectors.py` — the fetch half. `AgentTls` makes an `https://` `HOST_TARGETS` entry a - `collectors.py` — the fetch half. `AgentTls` makes an `https://` `HOST_TARGETS` entry a
certificate-authenticated dial (per-dial client, this app's client cert verified against certificate-authenticated dial (per-dial client, this app's client cert verified against
its own root; the revocation answer is a fresh read per poll, so a `/certs` revoke bites its own root; the revocation answer is a fresh read per poll, so a `/certs` revoke bites
on the next poll with no restart, before any socket opens). Failure messages name the on the next poll with no restart, before any socket opens). `post_route` is the write's
dial beside `fetch_snapshot`: the same per-dial client and the same revocation check
before any socket opens, but **`Authorization` is never sent on the certificate path** —
the bundle is the credential there, and the token travels only on the plain token path.
The answer is the helper's own object, verbatim. Failure messages name the
**redacted** URL. Agent `errors` become `degraded` **except** the add-on lines **redacted** URL. Agent `errors` become `degraded` **except** the add-on lines
(`ADDON_ERROR_PREFIXES`: `smart`, `haproxy`, `nginx`, `sockets`, `resolver`) — a host (`ADDON_ERROR_PREFIXES`: `smart`, `haproxy`, `nginx`, `sockets`, `resolver`) — a host
without smartctl or a proxy config is unconfigured, not broken (rule 3); those lines without smartctl or a proxy config is unconfigured, not broken (rule 3); those lines
@@ -179,8 +205,15 @@ One line each — the load-bearing behaviour is in the bullet, the rest is in th
credited **only through an address the gateway's own `resolver` section published** — credited **only through an address the gateway's own `resolver` section published** —
the module never calls a resolver itself (spy-pinned). Pipes are `null`, never `0`, when the module never calls a resolver itself (spy-pinned). Pipes are `null`, never `0`, when
unmeasured. `warnings()` judges the two config files against themselves (seven kinds, unmeasured. `warnings()` judges the two config files against themselves (seven kinds,
each `{id, kind, subject, message}`) and never joins a warning to a row. No `severity` each `{id, kind, subject, message}`) and never joins a warning to a row. Each row's
anywhere in the payload (a busy proxy is the job). `enabled` carries the config's own state (the 1.4.0 `#off` marker, or a map entry
pointed at the helper's sink); a merge that would mix states splits into two rows
keyed by `enabled` (a union of selectors with one disabled line is two facts; the
`#off` suffix keeps the halves distinct); the marker-caused warning shapes — an
emptied backend, a targetless route, a sink-pointed entry — warn nothing, while the
same shape from a hand-deleted line still warns; the switch-off tally is published
as `/api/summary`'s `disabled` field. No `severity` anywhere in the payload (a busy
proxy is the job).
- `sources/llm.py` — gufo + vLLM field maps, measured Δ rates **diffed per label set**, - `sources/llm.py` — gufo + vLLM field maps, measured Δ rates **diffed per label set**,
prefill speed, the session list from gufo `/slots`. A refused estimator window is still prefill speed, the session list from gufo `/slots`. A refused estimator window is still
an answer: the cell keeps its `source` and an honest `Δ<window>` beside a `null`. an answer: the cell keeps its `source` and an honest `Δ<window>` beside a `null`.
@@ -225,11 +258,18 @@ One line each — the load-bearing behaviour is in the bullet, the rest is in th
the startup read (it mints; nothing else may call it); `read_state` is the request-time the startup read (it mints; nothing else may call it); `read_state` is the request-time
read (same validation, never a write). `cryptography` is the app's only new dependency, read (same validation, never a write). `cryptography` is the app's only new dependency,
used here alone; the agent stays stdlib. used here alone; the agent stays stdlib.
- `api.py` — GETs everywhere plus the one POST that dials an upstream. Every source's - `api.py` — GETs everywhere plus two POSTs that dial an upstream (`/api/ups/command` and
`/api/gateway/route`, invariant 1's two exceptions). Every source's
configured gate runs **before** anything that could reach the network (spy-pinned: no configured gate runs **before** anything that could reach the network (spy-pinned: no
client, no fetch). `/api/config` is a redaction boundary. All five history routes go client, no fetch). `/api/config` is a redaction boundary and serves
through one door (`history_many`). `/api/ups/command` stays the app's only upstream `gateway.writes_enabled` so the page can render the honest arm. All five history routes go
write (pinned by enumerating every served route's verbs). through one door (`history_many`). The upstream write set is exactly those two (pinned by
enumerating every served route's verbs; a third fails by name). The route write's gates
(invariant 1) run in a pinned order — read-only, flag, per-address throttle, body
shape, the id's state in the current payload, end-to-end freshness, typed `DANGER` for
both verbs — and every answered call stores one audit event under its own kind
(`route-disabled`/`route-enabled`/`route-denied`; a dry run stores none) in a fixed
shape: the id, the action, the helper's words — **never the config text**.
- `app.py` — assembly and the **session gate** middleware (invariant 12), answering in - `app.py` — assembly and the **session gate** middleware (invariant 12), answering in
this order: no `AuthState` → `503` for everything; no session and no such route → the this order: no `AuthState` → `503` for everything; no session and no such route → the
router's `404`; public → through (the origin check still applies to a public write); router's `404`; public → through (the origin check still applies to a public write);
@@ -247,8 +287,10 @@ One line each — the load-bearing behaviour is in the bullet, the rest is in th
Metric naming: dotted (`cpu.usage_pct`, `mem.used_pct`, `disk.used_pct@/srv`, Metric naming: dotted (`cpu.usage_pct`, `mem.used_pct`, `disk.used_pct@/srv`,
`gpu.busy_pct@0`, `ups.charge_pct`), `@stream` for per-device series. `sample.source` is `gpu.busy_pct@0`, `ups.charge_pct`), `@stream` for per-device series. `sample.source` is
the dashboard family — `host`, `llm`, `ups`, `disks` (the SMART series) and `gateway` the dashboard family — `host`, `llm`, `ups`, `disks` (the SMART series) and `gateway`
(the connection gauges) — so `GET /api/metrics?source=…` answers "what does *this page* (the connection gauges: `gateway.in_conns@<listen-port>`, `gateway.out_conns@<host>:<port>`
have history for". Treat the scheme as a public interface of the history API. — the port belongs in the outbound name because two services on one host are two facts) —
so `GET /api/metrics?source=…` answers "what does *this page* have history for".
Treat the scheme as a public interface of the history API.
## The fleet (real environment) ## The fleet (real environment)
@@ -256,19 +298,43 @@ have history for". Treat the scheme as a public interface of the history API.
| ------------- | ------------- | ------------------------------------------------------------------------- | | ------------- | ------------- | ------------------------------------------------------------------------- |
| `driveripper` | `driveripper-ops` | Threadripper 24c/48t + Radeon AI PRO R9700, vLLM `:8000`, **upsd**, the dashboards. 16 whole disks; the agent unit runs `User=root` with `CapabilityBoundingSet=CAP_SYS_RAWIO CAP_SYS_ADMIN` (kernel 7.2's unprivileged-passthrough gate; an empty bounding set is capability-less, `CapEff 0`) | | `driveripper` | `driveripper-ops` | Threadripper 24c/48t + Radeon AI PRO R9700, vLLM `:8000`, **upsd**, the dashboards. 16 whole disks; the agent unit runs `User=root` with `CapabilityBoundingSet=CAP_SYS_RAWIO CAP_SYS_ADMIN` (kernel 7.2's unprivileged-passthrough gate; an empty bounding set is capability-less, `CapEff 0`) |
| `deskwork` | `deskwork-ops` | Strix Halo (Radeon 8060S iGPU), gufo `:8731` | | `deskwork` | `deskwork-ops` | Strix Halo (Radeon 8060S iGPU), gufo `:8731` |
| `gateway` | `gateway-ops` | the router, the fleet's ingress: haproxy (`/etc/haproxy/haproxy.cfg`, six frontends → six backends) + nginx (`/etc/nginx/nginx.conf` plus `conf.d/` vhosts and `stream.d/`'s `map` on `:443`); runs agent **1.3.0** | | `gateway` | `gateway-ops` | the router, the fleet's ingress: haproxy (`/etc/haproxy/haproxy.cfg`, six frontends → six backends) + nginx (`/etc/nginx/nginx.conf` plus `conf.d/` vhosts and `stream.d/`'s `map` on `:443`); runs agent **1.4.0** with the `dashboards-routectl` helper units |
- **Transport is direct dial; the fleet is mTLS.** `HOST_TARGETS` are `https://` with no - **Transport is direct dial; the fleet is mTLS.** `HOST_TARGETS` are `https://` with no
userinfo — the certificate bundle is the credential (the token in a URL is the userinfo — the certificate bundle is the credential (the token in a URL is the
compatibility path for a host nobody has bundled). All three hosts run the installed compatibility path for a host nobody has bundled). All three hosts run the installed
`dashboards-agent` systemd service, installed by `deploy/install-agent.sh` under the `dashboards-agent` systemd service, installed by `deploy/install-agent.sh` under the
sudo grant. **Re-stage `/home/ops/agent-install/` first — the grant names a path, not a sudo grant — registration is the install: `--fetch-bundle <url> --cert-name <name>
checkout —** and a re-run whose rendered unit differs from the installed one prints the --session <token>` pulls the host's own bundle from this dashboard during the install, so a
diff and refuses without `--force`. `deskwork`/`driveripper` still run **1.1.0**: the host is never registered onto a token URL because a `curl` was forgotten. **Re-stage
1.2.0/1.3.0 sections are purely additive (a 1.1.0 snapshot has none of the keys, which `/home/ops/agent-install/` first — the grant names a path, not a checkout —** and a re-run
`sources/gateway.py` answers as `supported: false`), so they move at their next re-stage. whose rendered unit differs from the installed one prints the diff and refuses without
`--force`. `deskwork`/`driveripper` still run **1.1.0**: the
1.2.0/1.3.0/1.4.0 sections are purely additive (a 1.1.0 snapshot has none of the keys,
which `sources/gateway.py` answers as `supported: false`), so they move at their next
re-stage.
A TLS agent serves https only — rolling a host back means removing the bundle lines too, A TLS agent serves https only — rolling a host back means removing the bundle lines too,
not just the URL. not just the URL. **The gateway's write (1.4.0) installs beside the agent, never into
it**: `install-agent.sh --allow-writes --with-routectl` adds one argument to the agent
unit's `ExecStart` (`--allow-writes` — a diff that shows a security directive moving
is a stop, not a `--force`) and installs `dashboards-routectl.py` (0755, root,
`/usr/local/sbin/`) plus the two units **`dashboards-routectl.socket`** (`0600`, owned
by the agent's user) and **`dashboards-routectl@.service`** (root, one instance per
connection, `ReadWritePaths` = `/etc/haproxy` + `/etc/nginx/conf.d` +
`/etc/nginx/stream.d` only), verifying the agent's uid can connect before reporting
success. **No sudoers line is involved** — the grant is a file mode systemd enforces,
and `--with-routectl` alone leaves the agent's unit byte-identical; the helper edits
one `#off` route (the marker carries the line's original text after its `was:`, a
per-route state sidecar records who last changed it and the file's hash, an `enable`
restores only an unedited line — an edited one is a `drift` refusal, no byte moves —
and a switched-off `map` entry points at the helper's own sink `server`), validates
with the proxy's own checker (restore on failure) and **reloads, never restarts**.
`--without-routectl` removes it, printing the still-off routes first;
`deploy/gateway-routectl-README.md` is the operator's page. The live **drill has not
run**: the install is verified end to end (task 07), but this fleet's proxies run in
podman containers, so the host has no `nginx`/`haproxy` binary for the checker —
`data/logs/NOTES-33-switching.md` records it, and a real `DANGER` is still the
operator's call.
- **NUT 2.8.5 on driveripper: configs in `/etc/ups/` (not `/etc/nut/`), upsd on port - **NUT 2.8.5 on driveripper: configs in `/etc/ups/` (not `/etc/nut/`), upsd on port
3051 (not 3493 — nothing listens on 3493).** NUT 2.8 has no `ACL` directive; 3051 (not 3493 — nothing listens on 3493).** NUT 2.8 has no `ACL` directive;
per-source restriction is a **firewall** rule, never an `upsd.conf` line. Two UPSes: per-source restriction is a **firewall** rule, never an `upsd.conf` line. Two UPSes:
@@ -367,7 +433,12 @@ operator's how-to for both.
configured routes branching off; its only colour pair is existence configured routes branching off; its only colour pair is existence
(`--gate-on`/`--gate-off`) — no verdict colour anywhere on the page; its filter box is (`--gate-on`/`--gate-off`) — no verdict colour anywhere on the page; its filter box is
an **eye, not a query** (it hides rows whose own payload text does not contain what was an **eye, not a query** (it hides rows whose own payload text does not contain what was
typed; no DNS, no re-ranking). typed; no DNS, no re-ranking). Each row's switch runs on `GATEWAY_ALLOW_WRITES`: off
is a `aria-disabled` reading with the flag's own sentence (no hover, no listener, no
wire); on is a real control whose press is the dry-run diff → typed `DANGER` → one
send, and the row's colour is the payload's word from the next poll — never the
click's — with `off` reading `--gate-off` end to end (row, spine, the dialog's diff
tints) and the diff scrolling in its own `<pre>`.
- Every chart names itself for assistive tech (`role="img"` + `aria-label`); every chart - Every chart names itself for assistive tech (`role="img"` + `aria-label`); every chart
on every page has one, pinned in `tests/test_api.py`. on every page has one, pinned in `tests/test_api.py`.
@@ -376,9 +447,9 @@ operator's how-to for both.
1. The UPS gates are the control: the upsd account has `instcmds = all`, and NUT has no 1. The UPS gates are the control: the upsd account has `instcmds = all`, and NUT has no
call for a user's privileges — `enabled: true` cannot mean "may send", so the risk call for a user's privileges — `enabled: true` cannot mean "may send", so the risk
tiers and typed `DANGER` are load-bearing, not decoration. tiers and typed `DANGER` are load-bearing, not decoration.
2. One admin, one factor: no TOTP/WebAuthn, no per-user accounts; a logout ends only 2. One admin, one factor — no second factor and no per-user accounts; a logout ends only
this browser's cookie, and the only revocation is a password change (which rotates the this browser's cookie, and the only revocation is a password change (which rotates the
session secret). The login throttle is per-process and keys on the socket peer address session secret). The throttle is per-process and keys on the socket peer address
(behind nginx every login shares one bucket; a restart clears it). (behind nginx every login shares one bucket; a restart clears it).
3. GPU coverage is AMD only — an NVIDIA host would report no GPU at all. 3. GPU coverage is AMD only — an NVIDIA host would report no GPU at all.
4. UPS history depth: events and numeric series are stored; long-window capacity fade has 4. UPS history depth: events and numeric series are stored; long-window capacity fade has
@@ -392,8 +463,12 @@ operator's how-to for both.
7. The fleet trusts **this** deployment's CA — a second deployment with its own 7. The fleet trusts **this** deployment's CA — a second deployment with its own
`certs.*` rows cannot dial these agents until it carries the same root or re-issues `certs.*` rows cannot dial these agents until it carries the same root or re-issues
and re-installs every bundle. and re-installs every bundle.
8. The `/gateway` route switches are built but **not wired** — a route write path is 8. Route switching is closed, and its honest limits are the gap list's:
phase 33's decision and amends invariant 1; until then the knobs stay `aria-disabled`. `GATEWAY_ALLOW_WRITES` is off by default (a fresh deployment sees no switch arm at
all); the switch is one admin (gap 2) — whoever holds the session can switch a route
— with no per-host kill switch and no scheduler; and there is no runtime `disable
server` state: the marker is a config line, so a hand reload or a reboot does not
undo it (a runtime switch would be quietly undone by the next reload).
## Locked decisions (and why) ## Locked decisions (and why)
+114 -25
View File
@@ -10,7 +10,7 @@ file:
| `/disks` | Are any of my **physical** disks failing, and which one — health, temperature, power-on hours, wear, sector alarms | the same agents, running `smartctl -i -A -H` per whole drive (never a partition or a mount); a host whose agent has no `smartctl` is not a host on this page | | `/disks` | Are any of my **physical** disks failing, and which one — health, temperature, power-on hours, wear, sector alarms | the same agents, running `smartctl -i -A -H` per whole drive (never a partition or a mount); a host whose agent has no `smartctl` is not a host on this page |
| `/llm` | Tokens/sec, active sessions, queue depth, KV-cache pressure, latency percentiles | llama.cpp-family (gufo) `/metrics` + `/slots`, vLLM `/metrics` — read-only | | `/llm` | Tokens/sec, active sessions, queue depth, KV-cache pressure, latency percentiles | llama.cpp-family (gufo) `/metrics` + `/slots`, vLLM `/metrics` — read-only |
| `/ups` | Battery health, runtime left, power events, and battery-test buttons | NUT `upsd`, on whatever port it really listens on (its own wire-protocol client) | | `/ups` | Battery health, runtime left, power events, and battery-test buttons | NUT `upsd`, on whatever port it really listens on (its own wire-protocol client) |
| `/gateway` | Which domains are coming in on which port, and where each one goes — **two connection tables, one per proxy config file** (`nginx`, then `haproxy`): a trunk per bound port with its configured routes branching off it, each line reading the config's own **inbound** word (`server_name`, `map` key, `backend` name) on line 1 over the counterpart it routes to on line 2 (`→ host:port`), plus its live connection count — and one filter box above the map that hides the routes whose own labels do not contain what you typed, asking nothing of the server | the agent on **one** host, `GATEWAY_HOST` (default `gateway`), reading its `haproxy.cfg` + nginx tree and `/proc/net/tcp{,6}` — so it needs **agent 1.2.0**, plus **1.3.0**'s `resolver` section to put a number on a route whose target the config writes as a *name* (without it those lines are drawn and dashed), and the whole page is *unconfigured* (never red) unless `GATEWAY_HOST` names a `HOST_TARGETS` entry | | `/gateway` | Which domains are coming in on which port, and where each one goes — **two connection tables, one per proxy config file** (`nginx`, then `haproxy`): a trunk per bound port with its configured routes branching off it, each line reading the config's own **inbound** word (`server_name`, `map` key, `backend` name) on line 1 over the counterpart it routes to on line 2 (`→ host:port`), plus its live connection count — and one filter box above the map that hides the routes whose own labels do not contain what you typed, asking nothing of the server | the agent on **one** host, `GATEWAY_HOST` (default `gateway`), reading its `haproxy.cfg` + nginx tree and `/proc/net/tcp{,6}` — so it needs **agent 1.2.0**, plus **1.3.0**'s `resolver` section to put a number on a route whose target the config writes as a *name* (without it those lines are drawn and dashed), plus **1.4.0**'s `#off` marker read to show a route the switch has turned off **or back on** (without it an off route is drawn on, because the marker is a comment a 1.3.0 agent does not read) — an off route stays drawn and counted, its row simply reads `enabled: false` (its id gains a `#off` suffix), and the config-integrity warnings the marker *caused* (a backend emptied of its last `server`, a map entry pointed at the switch's sink) are read as a chosen state while the same shape from a hand-deleted line is still a warning — and the whole page is *unconfigured* (never red) unless `GATEWAY_HOST` names a `HOST_TARGETS` entry |
No Prometheus, no Grafana, no CDN, no build step. Nothing is fetched from the internet at No Prometheus, no Grafana, no CDN, no build step. Nothing is fetched from the internet at
runtime; every asset is served by this app. runtime; every asset is served by this app.
@@ -111,8 +111,12 @@ sudo deploy/install-agent.sh --fetch-bundle https://dashboards.example.com \
sudo deploy/install-agent.sh --as-root --caps CAP_SYS_RAWIO,CAP_SYS_ADMIN sudo deploy/install-agent.sh --as-root --caps CAP_SYS_RAWIO,CAP_SYS_ADMIN
# ...or a kernel-gated host (SMART passthrough, below) # ...or a kernel-gated host (SMART passthrough, below)
sudo deploy/install-agent.sh --force # ...only when it must overwrite a unit it would change sudo deploy/install-agent.sh --force # ...only when it must overwrite a unit it would change
sudo deploy/install-agent.sh --with-routectl # ...plus the route-switch helper (the gateway, below)
sudo deploy/install-agent.sh --allow-writes --with-routectl # the gateway's full write install:
# the flag on the agent unit + the helper beside it
deploy/install-agent.sh --status # unit state and /health (https when TLS) deploy/install-agent.sh --status # unit state and /health (https when TLS)
sudo deploy/install-agent.sh --uninstall sudo deploy/install-agent.sh --uninstall
sudo deploy/install-agent.sh --without-routectl # remove helper + units, keep config and state
``` ```
Re-running the installer is the silent, idempotent path it has always been **as long as the unit Re-running the installer is the silent, idempotent path it has always been **as long as the unit
@@ -127,9 +131,30 @@ No root shell on the monitored host? `deploy/agent-sudoers` is the single sudoer
lets the fleet's unprivileged `ops` account run the installer; run it without root and it lets the fleet's unprivileged `ops` account run the installer; run it without root and it
prints that exact line with the script's own resolved path instead of saying "run as root". prints that exact line with the script's own resolved path instead of saying "run as root".
It is read-only by construction: there is no code path that writes, and the HTTP server It is read-only by construction **except the one write below**: every `GET` path answers
only answers `GET`. Counters are reported as counters — the dashboard computes rates over a read, and the HTTP server's one other path is the gated `POST /route`. Counters are
a labelled window, so the agent never has to remember who polled it last. reported as counters — the dashboard computes rates over a labelled window, so the agent
never has to remember who polled it last.
**The one write (1.4.0, the gateway).** 1.4.0 adds a single `POST /route`, reachable only
with a verified client certificate and the agent's own `--allow-writes` flag, and it dials
a **socket-activated root service** rather than giving the agent any privilege. The full
write install is `--allow-writes --with-routectl`: the first adds exactly one argument to
the agent unit's `ExecStart` (a re-run diff that shows a security directive moving is a
stop, not a `--force`), and the second installs `deploy/dashboards-routectl.py` to
`/usr/local/sbin/` (0755, root) and the two units **`dashboards-routectl.socket`** (a
`0600` stream socket at `/run/dashboards-routectl.sock`, owned by the agent's user) and
**`dashboards-routectl@.service`** (the helper, root, one instance per connection, `StandardInput=socket`,
`ReadWritePaths` limited to `/etc/haproxy`, `/etc/nginx/conf.d` and `/etc/nginx/stream.d`), then
verifies that the agent's own uid can connect before reporting success. **No sudoers line is
involved**: the grant is a file mode systemd enforces, the helper's `SO_PEERCRED` check is
the second look, and with `--with-routectl` alone the rendered `dashboards-agent.service` is
byte-identical. The helper
edits exactly one route (a `#off <name>` marker), validates the file with the proxy's own
checker before it means anything, restores the previous bytes on a checker failure, and
**reloads, never restarts**; `--without-routectl` removes it again, printing the list of
still-off routes first. `deploy/gateway-routectl-README.md` is the operator's page for all
of it.
Per-disk `smart` health is a best-effort add-on on the whole-disk `/sys/block` list (no Per-disk `smart` health is a best-effort add-on on the whole-disk `/sys/block` list (no
partitions): the agent runs `smartctl -i -A -H` per physical disk — 5 s bound per device, partitions): the agent runs `smartctl -i -A -H` per physical disk — 5 s bound per device,
@@ -271,8 +296,9 @@ a documentation one (RFC 5737 / RFC 3849), because a global IPv6 on Linux is EUI
visitor's address is not ours to publish. Every port, state, column and width in those tables is visitor's address is not ours to publish. Every port, state, column and width in those tables is
verbatim, and those files are volatile by nature — a proxy in service has a different connection set verbatim, and those files are volatile by nature — a proxy in service has a different connection set
every second — so the tests assert shape and the ports the configs claim, never a particular row. every second — so the tests assert shape and the ports the configs claim, never a particular row.
`AGENT_VERSION` is **1.3.0**, the version that adds the `resolver` section on top of the three `AGENT_VERSION` is **1.4.0** — the `resolver` section (1.3.0) and the `#off` marker read
phase 28 shipped; `GET /schema` names all four, each with its `available` flag, and `--no-proxy` plus the one write (below) on top of the three phase 28 shipped; `GET /schema` names all
four, each with its `available` flag, and `--no-proxy`
and `--no-resolve` are listed there too. A host still running 1.2.0 answers with a snapshot that and `--no-resolve` are listed there too. A host still running 1.2.0 answers with a snapshot that
simply has no `resolver` key — which is why the version spread costs nothing: a section an agent simply has no `resolver` key — which is why the version spread costs nothing: a section an agent
did not collect is absence, and the gateway page counts the address-written routes it has and draws did not collect is absence, and the gateway page counts the address-written routes it has and draws
@@ -714,10 +740,12 @@ curl -s -H "Authorization: Bearer $SESSION" -X POST -H 'content-type: applicatio
-d '{"ups":"apc","command":"test.panel.start"}' http://127.0.0.1:8090/api/ups/command -d '{"ups":"apc","command":"test.panel.start"}' http://127.0.0.1:8090/api/ups/command
``` ```
That is the app's one write endpoint **that dials anything** (the `/api/auth/*` and `/api/certs/*` That is the UPS half of the app's two write endpoints **that dial anything** (the
writes change only this process and its SQLite `meta` table — invariant 1 is about the fleet), and `/api/auth/*` and `/api/certs/*` writes change only this process and its SQLite `meta` table —
the bearer header changes nothing about its gates: a command that can cut output still needs invariant 1 is about the fleet; the other half is the `/gateway` route switch,
`"confirm":"DANGER"`, and is still refused while the UPS is on battery. `GATEWAY_ALLOW_WRITES`-gated), and the bearer header changes nothing about its gates:
a command that can cut output still needs `"confirm":"DANGER"`, and is still refused
while the UPS is on battery.
### Lockout ### Lockout
@@ -754,7 +782,9 @@ documentation and is never loaded, and `scripts/dev-server.sh` exports `.env.dev
| `GUFO_API_KEY` / `VLLM_API_KEY` | only if an engine is behind a bearer token; they never leave the server | | `GUFO_API_KEY` / `VLLM_API_KEY` | only if an engine is behind a bearer token; they never leave the server |
| `NUT_HOST` / `NUT_PORT` | upsd address — **whatever port it really listens on**, not always 3493. Reads are unauthenticated in stock NUT | | `NUT_HOST` / `NUT_PORT` | upsd address — **whatever port it really listens on**, not always 3493. Reads are unauthenticated in stock NUT |
| `NUT_USER` / `NUT_PASSWORD` | only needed for instant commands (battery tests) | | `NUT_USER` / `NUT_PASSWORD` | only needed for instant commands (battery tests) |
| `NUT_ALLOW_ACTIONS` | `1` enables the one write endpoint that dials **anything** — the `/api/auth/*` and `/api/certs/*` writes touch only this process and its `meta` table | | `NUT_ALLOW_ACTIONS` | `1` enables the UPS write endpoint that dials **anything** — the `/api/auth/*` and `/api/certs/*` writes touch only this process and its `meta` table |
| `GATEWAY_ALLOW_WRITES` | `1` enables the second write endpoint, the `/gateway` route switch (`POST /api/gateway/route`); off by default, the same truthy rule as `NUT_ALLOW_ACTIONS`, and the two writes are two decisions — one flag does not carry the other |
| `GATEWAY_WRITE_TIMEOUT_SECONDS` | the route write's own budget (default 35 s), deliberately longer than the agent's 30 s peer timeout so the dashboard never times out first and leaves you guessing whether the reload happened; the read's 10 s stays the read's own |
| `NUT_SIMULATE` | `1` replaces upsd with a **labelled** simulator (development only) | | `NUT_SIMULATE` | `1` replaces upsd with a **labelled** simulator (development only) |
| `DASH_READ_ONLY` | `1` disables every mutating endpoint regardless of other flags | | `DASH_READ_ONLY` | `1` disables every mutating endpoint regardless of other flags |
| `DASH_DB` | SQLite path (default `data/dashboards.sqlite3`) | | `DASH_DB` | SQLite path (default `data/dashboards.sqlite3`) |
@@ -1218,17 +1248,23 @@ certs.py ─ root CA + client cert, minted once at startup (unreadable row → n
behind a collapsed **Charts** toggle like `/system`'s, one `/api/gateway/history` request per behind a collapsed **Charts** toggle like `/system`'s, one `/api/gateway/history` request per
panel per refresh. With no `GATEWAY_HOST` the page is the setup sentence rather than a red panel, panel per refresh. With no `GATEWAY_HOST` the page is the setup sentence rather than a red panel,
and an ingress running an agent older than **1.2.0** says so in the agent's own words with every and an ingress running an agent older than **1.2.0** says so in the agent's own words with every
count `n/a` and nothing coloured. Each line's head carries the sketched **switch** — a real count `n/a` and nothing coloured. Each line's head carries the **switch** — a real
`role="switch"` button, green and to the position because the route is in the config, named in full `role="switch"` button, in the **on** position and tinted `--gate-on` because the route is in the config, named in full
for a screen reader (`endpoint, reached by <selector>, in the nginx config, on`) — and it is for a screen reader (`endpoint, reached by <selector>, in the nginx config, on`) — and it has two
**unwired on purpose**: `aria-disabled` with a tooltip that says so, and this app writes to no arms on `GATEWAY_ALLOW_WRITES`. With the flag off — the default — it is **unwired on
upstream, so a route is disabled by editing `haproxy.cfg` or an nginx `server` block and by nothing purpose**: `aria-disabled` with a tooltip that says so, and a route changes only by
in a browser. Giving either of those two dashes or knobs a real backing store is deliberately **not** editing `haproxy.cfg` or an nginx `server` block, never in a browser. With
documented here as a thing you can have: name-based liveness needs the agent to report resolved `GATEWAY_ALLOW_WRITES=1` the same control is live:
targets (a future agent version), and switching a route needs a second gated write path in front of activation opens one confirm dialog that first dials the helper as a dry run (`dry_run: true` —
the proxy configs, which is a change to this app's read-only promise rather than a feature. What runs it validates and answers with the one-line diff without writing a byte), then asks the operator
today is **agent 1.3.0** — name resolution on the gateway, counts on every declared route, and no to type `DANGER`, then sends exactly once. The helper edits the route in its config file,
switching. validates the file with the proxy's own checker and reloads that proxy — nothing is restarted —
and the switch flips the moment the helper answers `applied: true`, with the row re-read from
the next gateway poll. The write takes the read's own revocation gate on the same dial, and its
gates (read-only, the flag, a per-address throttle, the route's state in the current payload, the
read's freshness, the typed `DANGER` for both verbs) all run before anything reaches the host.
What runs today is **agent 1.4.0** — name resolution on the gateway, counts on every declared
route, the `#off` marker read, and the switch when the flag is set.
The four event feeds (`/system`, `/disks`, `/llm`, `/ups`) are a **paged window** over the `event` The four event feeds (`/system`, `/disks`, `/llm`, `/ups`) are a **paged window** over the `event`
table rather than a printout of it: the list is a bounded box that scrolls, 50 events to a table rather than a printout of it: the list is a bounded box that scrolls, 50 events to a
page, with `1–50 of 200` between `‹ prev` and `next ›` (disabled at the ends) underneath. page, with `1–50 of 200` between `‹ prev` and `next ›` (disabled at the ends) underneath.
@@ -1444,9 +1480,10 @@ advertising a window no poll ever measured.
## Battery-test buttons ## Battery-test buttons
Instant commands are the only endpoint in the application that changes anything **outside this Instant commands are one of exactly two endpoints in the application that change anything
process** (`POST /api/ups/command`; the session and certificate-authority writes change only this **outside this process** (the other is the route switch, in the next section; the session
app's own rows), and they are gated four times: `NUT_ALLOW_ACTIONS=1`, not `DASH_READ_ONLY`, an `upsd.users` and certificate-authority writes change only this app's own rows), and they are gated
four times: `NUT_ALLOW_ACTIONS=1`, not `DASH_READ_ONLY`, an `upsd.users`
account that actually has the command, and a confirmation in the UI — destructive ones account that actually has the command, and a confirmation in the UI — destructive ones
(deep test, calibration, `load.off`) require typing `DANGER`. Two more sit in front of those (deep test, calibration, `load.off`) require typing `DANGER`. Two more sit in front of those
(since the login landed): a session, and this session's CSRF token when the write arrived on the (since the login landed): a session, and this session's CSRF token when the write arrived on the
@@ -1493,6 +1530,58 @@ and an unauthenticated `LIST VAR` from any routed source is the normal state of
`NUT_SIMULATE=1` stands in for upsd during `NUT_SIMULATE=1` stands in for upsd during
development; everything it produces is badged `SIMULATED`, and it is never the default. development; everything it produces is badged `SIMULATED`, and it is never the default.
## Route switching
`/gateway` can change its own subject: each row's switch turns a route **off** and, the
other way around, **back on**. Off is the helper's `#off` marker on the config line
(a switched-off `map` entry is pointed at the helper's own sink `server` instead); on is
the marker removed and the line's original text restored — but only if the line is
unedited since (an edited line is a `drift` refusal and no byte moves). The switch is
**a decision of its own**, off by default, and it is the application's second write path
(the first is the UPS command buttons above).
### Enabling it
The write is the flag `GATEWAY_ALLOW_WRITES` (the same truthy rule as
`NUT_ALLOW_ACTIONS`, and one flag does not carry the other) and it only exists on a
host the operator installed `--allow-writes --with-routectl` on — the certificate-first
gate on the agent and the socket-activated root helper beside it (*The host agent*
section above). The install is the agent unit's one extra argument plus the helper and
its two units, `dashboards-routectl.socket` and `dashboards-routectl@.service`; to undo
everything, `sudo ./deploy/install-agent.sh --without-routectl` removes the helper and
its units, **leaves the proxy configs and the state directory alone**, and prints the
still-off routes first — those are yours to switch back on. With the flag off, every
switch is a `disabled` reading with the flag's own sentence, and a click does nothing:
no wire is touched, no request is sent.
### Switching a route
The press runs the flow the operator can see: the **dry-run diff first** — the
helper's own word for what the change will do, before the operator is asked for the
word — then the confirmation that carries the diff and a **typed `DANGER`** (both verbs
ask for it: turning a route back on is a write to the fleet's only ingress, just as
turning it off is), then **exactly one send** — one connection, one request, one
answer, **no retry** (a retry of a write is a second reload). The answer's words are
printed verbatim: `applied: true` flips the switch and the row's colour follows the
**payload's** word on the next gateway poll — never the click's; `applied: false`
flips nothing. Every call stores one audit event — `route-disabled`, `route-enabled`, or `route-denied`
for the gates' and the helper's refusals (a dry run stores none: it changed nothing) —
so the audit trail is one line per call with who and when — and a switch **outages the
route on purpose: it will appear in the event feed**.
The honest limits: the switch is one admin (the sign-in's one factor, no more), with no
per-host kill switch and no scheduler; and there is no runtime `disable server` state —
the marker is a config line, so a hand reload or a reboot does not undo it (a runtime
switch would be quietly undone by the next reload).
When the dashboard is the thing that is broken at 03:00, the recovery is on the box:
`/usr/local/sbin/dashboards-routectl.py` (its operator page is
`deploy/gateway-routectl-README.md`). `sudo /usr/local/sbin/dashboards-routectl.py list`
says what is off (the state sidecar), `sudo … verify` checks the sidecar against the
files (drift is reported, never repaired), and `sudo … enable --id <id>` puts one route
back on through the same protocol — the write, the proxy's own checker, restore on a
checker failure, the reload (never a restart), the state record.
## Deployment ## Deployment
`Containerfile` is a two-stage build: `uv.lock` is resolved in the builder (exactly the `Containerfile` is a two-stage build: `uv.lock` is resolved in the builder (exactly the
+297 -31
View File
@@ -2,13 +2,19 @@
"""host_agent.py — read-only host metrics agent for the homelab dashboards. """host_agent.py — read-only host metrics agent for the homelab dashboards.
**Stdlib only, one file, Python 3.10+.** Copy it to a host, run it, and the dashboard can **Stdlib only, one file, Python 3.10+.** Copy it to a host, run it, and the dashboard can
read that host. It never writes anything anywhere: every number comes from reading read that host. Every number comes from reading /proc, /sys and statvfs, and the HTTP
/proc, /sys and statvfs, and the HTTP server only answers GET. server answers GET — with exactly one exception: 1.4.0's `POST /route`, the fleet's one
narrow write (switching a route's `#off` marker). Its gates are the client certificate and
`--allow-writes`, and it never touches a config file itself: it dials the socket-activated
root helper (`--routectl-socket`) once and passes the helper's JSON through verbatim.
GET /health -> {"ok": true, "hostname": ..., "version": ...} GET /health -> {"ok": true, "hostname": ..., "version": ...}
GET /info -> slow-changing identity (os, kernel, cpu model, cores, gpu list) GET /info -> slow-changing identity (os, kernel, cpu model, cores, gpu list)
GET /snapshot -> everything, one JSON document (see SCHEMA below) GET /snapshot -> everything, one JSON document (see SCHEMA below)
GET /schema -> this docstring's SCHEMA section, for self-documenting deploys GET /schema -> this docstring's SCHEMA section, for self-documenting deploys
POST /route -> the one write: forward exactly {id, action, dry_run} to the
routectl helper and answer its JSON verbatim (1.4.0; certificate
plus --allow-writes, in that order — a token-mode POST is refused)
Design rules Design rules
------------ ------------
@@ -89,6 +95,20 @@ Design rules
are simply absent — `null` a pass that could not run), and `--no-resolve` removes the key in are simply absent — `null` a pass that could not run), and `--no-resolve` removes the key in
silence the way `--no-smart` does; `--no-proxy` removes it too, because a box that collects no silence the way `--no-smart` does; `--no-proxy` removes it too, because a box that collects no
proxy has no declared names to look up. proxy has no declared names to look up.
* **The one write is a forward, not a file edit.** `POST /route` (1.4.0) is the only non-GET
path and the only thing this process ever sends to an upstream. The order is the design:
the client certificate (the handshake is the identity — a bearer token never gets this
path), then `--allow-writes` (the operator's decision, off by default), then the body
(exactly `{id, action, dry_run}`, ≤ 4096 bytes, an `action` of `disable`/`enable`, an
`id` of at most 256 characters — a refused body never reaches the peer), then the
helper's socket. The dial is `AF_UNIX` with a 30 s budget (the helper may run `nginx -t`
and a reload), one newline-delimited request, one line back, and **never a retry — a
retry of a write is a second reload**: every failure is a 502 (an unreadable or
oversized answer, a timeout, or the errno word) and the helper's JSON is passed through
verbatim, refusal included. Without `--routectl-socket` (the flag's value set empty) the
answer is `503 no route helper on this host`. The bytes are written by the socket-
activated root helper (`deploy/dashboards-routectl.py`); this process opens no config
file for writing, so `--allow-writes` on a box without the helper is a 503, not a write.
* **The state** is a small pid->cpu-time cache so `processes.top_cpu` can answer a * **The state** is a small pid->cpu-time cache so `processes.top_cpu` can answer a
question /proc cannot (CPU needs two samples), bounded and cleared when stale, plus question /proc cannot (CPU needs two samples), bounded and cleared when stale, plus
the short-TTL SMART result cache above. the short-TTL SMART result cache above.
@@ -139,18 +159,27 @@ SCHEMA (GET /snapshot)
top_cpu: [{pid, name, cpu_pct}]} top_cpu: [{pid, name, cpu_pct}]}
haproxy {available: true|false|null, config: str, frontends: [{name, binds: haproxy {available: true|false|null, config: str, frontends: [{name, binds:
[{addr, port}], default_backend: str|null, backends: [str]}], [{addr, port}], default_backend: str|null, backends: [str]}],
backends: [{name, servers: [{name, target, port}]}]} backends: [{name, servers: [{name, target, port,
disabled: bool}]}]}
# parsed from --haproxy-config; additive (phase 28) and absent entirely # parsed from --haproxy-config; additive (phase 28) and absent entirely
# with --no-proxy. `addr` is the bind's address as written (`:::22` -> # with --no-proxy. `addr` is the bind's address as written (`:::22` ->
# `::`), `port` null when a target names none (never 0), and the # `::`), `port` null when a target names none (never 0), and the
# frontend->backend routing is the names themselves: the dashboard # frontend->backend routing is the names themselves: the dashboard
# joins them, the agent reports only what the config says. # joins them, the agent reports only what the config says.
# `disabled` is the route switch's read side (phase 33, agent 1.4.0):
# true when the line carries the helper's `#off <name>` marker
# (ROUTE_MARKER), false otherwise. The marker is a comment in both
# configs, so a marked line is still a live line to haproxy/nginx until
# the helper rewrites the value — the agent reports the state, the
# dashboard draws it, and the kernel does not know markers.
nginx {available: true|false|null, config: str, files: [str], nginx {available: true|false|null, config: str, files: [str],
servers: [{listen: [{addr, port}], server_names: [str], proxy_passes: servers: [{listen: [{addr, port}], server_names: [str], proxy_passes:
[{location: str|null, scheme: str|null, [{location: str|null, scheme: str|null,
target: {host, port}|null, variable: str|null}]}], target: {host, port}|null, variable: str|null,
upstreams: [{name, servers: [{host, port}]}], disabled: bool}]}],
maps: [{variable, entries: [{key, target: {host, port}|null}]}]} upstreams: [{name, servers: [{host, port, disabled: bool}]}],
maps: [{variable, entries: [{key, target: {host, port}|null,
disabled: bool}]}]}
# parsed from --nginx-config plus the files its own `include` lines name # parsed from --nginx-config plus the files its own `include` lines name
# (one level, at most 8 files / 1 MB of included text; `files` is the # (one level, at most 8 files / 1 MB of included text; `files` is the
# main config then every include actually merged); additive (phase 28) # main config then every include actually merged); additive (phase 28)
@@ -158,6 +187,10 @@ SCHEMA (GET /snapshot)
# written (`[::]:8443` -> `"::"`, a bare `80` -> a null addr, never `*`), # written (`[::]:8443` -> `"::"`, a bare `80` -> a null addr, never `*`),
# `server_names` keeps the catch-all `_` verbatim, `location` is null for # `server_names` keeps the catch-all `_` verbatim, `location` is null for
# a proxy_pass written in the server block itself (the stream form), and # a proxy_pass written in the server block itself (the stream form), and
# `disabled` (phase 33, agent 1.4.0) is the line's `#off <name>` marker
# read by the tokenizer, exactly as the haproxy section's: on a
# `proxy_pass`, an `upstream` member line, or a `map` entry line. See
# the haproxy note for what the flag does and does not mean.
# a `proxy_pass $var` is served with `variable` (the `$` dropped) and # a `proxy_pass $var` is served with `variable` (the `$` dropped) and
# `target: null`: the map whose variable matches decides per connection, # `target: null`: the map whose variable matches decides per connection,
# so both facts travel unmerged and the dashboard joins them. # so both facts travel unmerged and the dashboard joins them.
@@ -213,7 +246,12 @@ other protection is `--cert-bundle`/AGENT_CERT_BUNDLE: one installed PEM (server
key, the dashboard's root CA, `# BUNDLE-REV <n>` and `# REVOKED <serial>` lines) turns the key, the dashboard's root CA, `# BUNDLE-REV <n>` and `# REVOKED <serial>` lines) turns the
server into HTTPS that answers every path but `/health` only to a client certificate signed server into HTTPS that answers every path but `/health` only to a client certificate signed
by that root, and refuses one on the denylist on every path — the certificate is the auth, so by that root, and refuses one on the denylist on every path — the certificate is the auth, so
no token is read on that path. no token is read on that path. The write path (1.4.0, the gateway) is `--allow-writes`
(default off) plus `--routectl-socket` (default /run/dashboards-routectl.sock, the helper's
socket; set the flag's value empty for the `503 no route helper on this host` arm): a
`POST /route` is then answered certificate-first, then flag, then body, then one dial to
that socket, and the helper's JSON is passed through verbatim — see the design rule above
for the refusal sentences.
""" """
from __future__ import annotations from __future__ import annotations
@@ -237,8 +275,65 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path from pathlib import Path
from typing import Any, cast from typing import Any, cast
AGENT_VERSION = "1.3.0" AGENT_VERSION = "1.4.0"
SCHEMA_VERSION = 1 SCHEMA_VERSION = 1
#: POST /route: one newline-delimited JSON request to the routectl helper, one line back.
#: The budget covers the helper's own checker run (`nginx -t` / `haproxy -c -f`) plus reload.
ROUTE_TIMEOUT_SECONDS = 30
#: The largest body the write path accepts, checked before a byte is read off the wire.
_ROUTE_MAX_BODY = 4096
#: An answer longer than this is "unreadable" — the helper's status JSON never is.
_ROUTE_MAX_ANSWER = 131072
# The route switch's marker, one constant the parser and the write path both read (phase 33).
# A comment in both haproxy and nginx, so `line, marker = split_marker(line)` on a route line
# is the whole of the read side: a marked line is still a live line to the proxy until the
# helper rewrites its value, and this is the state, not a behaviour the agent enforces.
ROUTE_MARKER = "#off"
def split_marker(line: str) -> tuple[str, str | None]:
"""Split a config line into ``(line, marker)`` for the helper's ``#off <name>`` marker.
The marker is the **whole** trailing comment (``#...`` through end of line), so the
grammar is one and the write path (task 01's ``routectl``) and the parser share it: a
line is marked when its comment *is* ``#off <name>`` — a marker with nothing after the
word, and nothing but the word, after it. Anything else in the comment is the operator's
own note, not a switch, and the line is reported ``disabled: false``. A marked line stays
in the document (the helper edits the value, never the line), which is what lets the
dashboard tell "declared, off" from "never declared" (that split is the dashboard's
``enabled`` field, phase 33 task 04).
"""
for i, ch in enumerate(line):
if ch == "#":
marker = line[i:]
if marker == ROUTE_MARKER or marker.startswith(ROUTE_MARKER + " "):
name = marker[len(ROUTE_MARKER):].strip()
if name and " " not in name:
return line[:i].rstrip(), name
return line, None
return line, None
def marker_for(name: str) -> str:
"""The full marker comment for one route name (the write path's spelling, pinned here so
the parser and task 01's helper cannot drift). One name per marker: a marker is a whole
comment and the name has no spaces, so the split above is exact."""
return f"{ROUTE_MARKER} {name}"
def config_line_index(text: str, name: str) -> int:
"""The 1-based line number of the route line carrying ``name``'s marker, or ``-1``.
Task 01's helper needs the line to edit; the marker names the route, so the search is
the marker itself, never the route's text (a target string appears in many lines and
only one of them is the route). `-1` is "not found", which the helper maps to its
`not-found` refusal.
"""
marker = marker_for(name)
for i, line in enumerate(text.splitlines()):
if split_marker(line)[1] == name and marker in line:
return i + 1
return -1
PAGE_SIZE = 0 # resolved from sysconf at runtime PAGE_SIZE = 0 # resolved from sysconf at runtime
# Filesystems that are not storage. Mirrors the spirit of node_exporter's default # Filesystems that are not storage. Mirrors the spirit of node_exporter's default
@@ -2037,6 +2132,11 @@ def _scan_haproxy_config(text: str) -> tuple[list[dict[str, Any]], list[dict[str
if backend is not None and backend not in current_fe["backends"]: if backend is not None and backend not in current_fe["backends"]:
current_fe["backends"].append(backend) current_fe["backends"].append(backend)
if current_be is not None and directive == "server" and len(words) > 2: if current_be is not None and directive == "server" and len(words) > 2:
# the route switch's read side (phase 33, agent 1.4.0): the marker is a trailing
# comment, `#off <name>`, and a marked line is still a live line to haproxy until
# the helper rewrites the value — the agent reports the state, the dashboard
# draws it (`enabled: false`), and the kernel does not know markers.
marked = split_marker(stripped)[1]
target = _haproxy_addr_port(words[2]) target = _haproxy_addr_port(words[2])
current_be["servers"].append({ current_be["servers"].append({
"name": words[1], "name": words[1],
@@ -2044,6 +2144,7 @@ def _scan_haproxy_config(text: str) -> tuple[list[dict[str, Any]], list[dict[str
# port — "absent is never 0", on the one field haproxy makes optional # port — "absent is never 0", on the one field haproxy makes optional
"target": words[2] if target is None else target[0], "target": words[2] if target is None else target[0],
"port": None if target is None else target[1], "port": None if target is None else target[1],
"disabled": marked is not None,
}) })
return frontends, backends, recognised return frontends, backends, recognised
@@ -2052,9 +2153,12 @@ def parse_haproxy_config(text: str) -> dict[str, Any]:
"""Parse a haproxy config's **text** into the `haproxy` section's data. Pure, never raises. """Parse a haproxy config's **text** into the `haproxy` section's data. Pure, never raises.
`{frontends: [{name, binds: [{addr, port}], default_backend, backends}], backends: `{frontends: [{name, binds: [{addr, port}], default_backend, backends}], backends:
[{name, servers: [{name, target, port}]}], recognised: bool}` — the frontend→backend routing [{name, servers: [{name, target, port, disabled}]}], recognised: bool}` — the frontend→
is the names themselves (`default_backend` plus every `use_backend`), so the dashboard joins backend routing is the names themselves (`default_backend` plus every `use_backend`), so the
them and this file interprets no rule. dashboard joins them and this file interprets no rule. `disabled` is the line's `#off
<name>` marker (phase 33, agent 1.4.0; `ROUTE_MARKER`, `split_marker`), `true` on a marked
line, `false` on every other line — the write path (the helper) is the only writer, and a
marker with anything but one name after it is the operator's comment, not a switch.
`recognised` answers the separate question "is this text a haproxy config at all" (does its `recognised` answers the separate question "is this text a haproxy config at all" (does its
top level name a section keyword?) and is deliberately **not** part of the served section: top level name a section keyword?) and is deliberately **not** part of the served section:
@@ -2070,7 +2174,7 @@ def parse_haproxy_config(text: str) -> dict[str, Any]:
def _nginx_tokens(text: str) -> list[tuple[str, str]]: def _nginx_tokens(text: str) -> list[tuple[str, str]]:
"""Split nginx config text into `("w", word)`, `("{", "")`, `("}", "")`, `(";", "")`. """Split nginx config text into `("w", word)`, `("{", "")`, `("}", "")`, `(";", comment)`.
nginx's grammar is words closed by `;` or opened by `{`, with `#` comments and quoted nginx's grammar is words closed by `;` or opened by `{`, with `#` comments and quoted
strings that may contain any of those three — the captured gateway config writes strings that may contain any of those three — the captured gateway config writes
@@ -2083,16 +2187,28 @@ def _nginx_tokens(text: str) -> list[tuple[str, str]]:
A `#` opens a comment only where a word would have started, which is where nginx's own A `#` opens a comment only where a word would have started, which is where nginx's own
lexer reads one: `proxy_pass http://host/#/app` keeps its fragment, and the lexer reads one: `proxy_pass http://host/#/app` keeps its fragment, and the
`# include /etc/nginx/old.conf;` line an operator commented out contributes nothing. `# include /etc/nginx/old.conf;` line an operator commented out contributes nothing.
The one value a token carries is a `;` (or `}`) token's **end-of-line comment**: a comment
on the same line, after the statement that closed it, is attached to that token, and every
other comment is dropped as before. That is the route switch's read side (phase 33):
`server s1 10.1.1.9:443 #off s1` is a live line with a state, and the statement scanner is
what hands the comment to the directive. A comment at the start of a line (a commented-out
directive) belongs to no statement and is dropped, exactly as today.
""" """
tokens: list[tuple[str, str]] = [] tokens: list[tuple[str, str]] = []
word: list[str] = [] word: list[str] = []
started = False # an empty quoted string is still a word: the `''` key of a map started = False # an empty quoted string is still a word: the `''` key of a map
line_terminated = False # a `;` or `}` has been emitted on this line
i, n = 0, len(text) i, n = 0, len(text)
while i < n: while i < n:
char = text[i] char = text[i]
if char == "#" and not started and (i == 0 or text[i - 1] in " \t\r\n"): if char == "#" and not started and (i == 0 or text[i - 1] in " \t\r\n"):
while i < n and text[i] != "\n": end = i
i += 1 while end < n and text[end] != "\n":
end += 1
if line_terminated and tokens and tokens[-1][0] in ";}":
tokens[-1] = (tokens[-1][0], text[i:end])
i = end
continue continue
if char in "\"'": if char in "\"'":
quote = char quote = char
@@ -2116,12 +2232,15 @@ def _nginx_tokens(text: str) -> list[tuple[str, str]]:
tokens.append(("w", "".join(word))) tokens.append(("w", "".join(word)))
word, started = [], False word, started = [], False
tokens.append((char, "")) tokens.append((char, ""))
line_terminated = True
i += 1 i += 1
continue continue
if char.isspace(): if char.isspace():
if started: if started:
tokens.append(("w", "".join(word))) tokens.append(("w", "".join(word)))
word, started = [], False word, started = [], False
if char == "\n":
line_terminated = False
i += 1 i += 1
continue continue
word.append(char) word.append(char)
@@ -2132,15 +2251,28 @@ def _nginx_tokens(text: str) -> list[tuple[str, str]]:
return tokens return tokens
def _comment_off(comment: str) -> bool:
"""Does this end-of-line comment carry the route switch's marker?
The grammar is `split_marker`'s — the one the write path (task 01's helper) shares —
applied to a line whose only comment is the one the tokenizer attached: `#off <name>`
with exactly one name after the word. The delegation is the point: the parser and the
helper cannot read the same marker differently.
"""
return split_marker(f"line {comment}")[1] is not None
def _nginx_statements(text: str) -> list[tuple[Any, ...]]: def _nginx_statements(text: str) -> list[tuple[Any, ...]]:
"""The brace-block scanner: tokens in, nested statements out. Raises on an unbalanced file. """The brace-block scanner: tokens in, nested statements out. Raises on an unbalanced file.
A statement is `("d", name, args)` for a directive or `("b", name, args, children)` for a A statement is `("d", name, args, comment)` for a directive — `comment` is the end-of-line
block. That is the whole difference between the two config languages this agent reads: comment the tokenizer attached (the route switch's marker lives there, phase 33) or `""` —
haproxy's structure is its indentation, nginx's is its braces, so nginx gets a scanner and `("b", name, args, children)` for a block. That is the whole difference between the two
instead of a line loop — and with it the two shapes that make the language what it is, a config languages this agent reads: haproxy's structure is its indentation, nginx's is its
`server { … location { … } … }` nested as deep as it goes and an `upstream` body whose braces, so nginx gets a scanner instead of a line loop — and with it the two shapes that
`server` lines are *targets* while an `http` body's `server {` is a *block*. make the language what it is, a `server { … location { … } … }` nested as deep as it goes
and an `upstream` body whose `server` lines are *targets* while an `http` body's `server {`
is a *block*.
`ValueError` here means "nginx itself would refuse to start on this file": a stray brace or `ValueError` here means "nginx itself would refuse to start on this file": a stray brace or
a directive that runs into the end of the file without its `;`. The caller turns that into a directive that runs into the end of the file without its `;`. The caller turns that into
@@ -2156,7 +2288,7 @@ def _nginx_statements(text: str) -> list[tuple[Any, ...]]:
continue continue
if kind == ";": if kind == ";":
if words: # a bare `;` is whitespace to nginx if words: # a bare `;` is whitespace to nginx
containers[-1].append(("d", words[0], words[1:])) containers[-1].append(("d", words[0], words[1:], value))
words = [] words = []
continue continue
if kind == "{": if kind == "{":
@@ -2271,6 +2403,10 @@ def _collect_nginx(statements: _NginxStatements, out: dict[str, Any], *, root: b
""" """
for statement in statements: for statement in statements:
kind, name, args = statement[0], statement[1], statement[2] kind, name, args = statement[0], statement[1], statement[2]
# the end-of-line comment, if the tokenizer attached one: the route switch's marker
# (phase 33) lives on the route line itself, and `_comment_off` is its read
comment = statement[3] if len(statement) > 3 and isinstance(statement[3], str) else ""
marked = _comment_off(comment)
if kind == "d": if kind == "d":
if name == "include" and args: if name == "include" and args:
if root: if root:
@@ -2284,6 +2420,7 @@ def _collect_nginx(statements: _NginxStatements, out: dict[str, Any], *, root: b
# keeps its name and a null port rather than losing the line entirely # keeps its name and a null port rather than losing the line entirely
"host": args[0] if found is None else found[0], "host": args[0] if found is None else found[0],
"port": None if found is None else found[1], "port": None if found is None else found[1],
"disabled": marked,
}) })
continue continue
if map_ctx is not None: if map_ctx is not None:
@@ -2297,6 +2434,7 @@ def _collect_nginx(statements: _NginxStatements, out: dict[str, Any], *, root: b
# `upgrade` / `close` values are header words, and calling one a host # `upgrade` / `close` values are header words, and calling one a host
# would be this section inventing a fact to fill a field # would be this section inventing a fact to fill a field
"target": None if found is None else {"host": found[0], "port": found[1]}, "target": None if found is None else {"host": found[0], "port": found[1]},
"disabled": marked,
}) })
continue continue
if server is not None: if server is not None:
@@ -2309,7 +2447,9 @@ def _collect_nginx(statements: _NginxStatements, out: dict[str, Any], *, root: b
# "any domain" is what the page says about it, not what the agent decides # "any domain" is what the page says about it, not what the agent decides
server["server_names"].extend(args) server["server_names"].extend(args)
elif name == "proxy_pass" and args: elif name == "proxy_pass" and args:
server["proxy_passes"].append(_nginx_proxy_pass(args[0], location)) entry = _nginx_proxy_pass(args[0], location)
entry["disabled"] = marked
server["proxy_passes"].append(entry)
continue continue
children = cast("_NginxStatements", statement[3]) children = cast("_NginxStatements", statement[3])
if name == "upstream" and args: if name == "upstream" and args:
@@ -2367,9 +2507,14 @@ def parse_nginx_config(main_text: str,
"""Parse an nginx main config plus the documents its includes name. Pure, never raises. """Parse an nginx main config plus the documents its includes name. Pure, never raises.
`{servers: [{listen: [{addr, port}], server_names: [str], proxy_passes: [{location, scheme, `{servers: [{listen: [{addr, port}], server_names: [str], proxy_passes: [{location, scheme,
target, variable}]}], upstreams: [{name, servers: [{host, port}]}], target, variable, disabled}]}], upstreams: [{name, servers: [{host, port, disabled}]}],
maps: [{variable, entries: [{key, target}]}], files: [str], includes: [str], unparsed: maps: [{variable, entries: [{key, target, disabled}]}], files: [str], includes: [str],
[str], recognised: bool, error: str|null, resolver: str|null}`. unparsed: [str], recognised: bool, error: str|null, resolver: str|null}`.
`disabled` on a `proxy_pass`, an `upstream` member line, or a `map` entry line is the
route switch's read side (phase 33, agent 1.4.0): `true` when that line carries the
helper's `#off <name>` marker, `false` otherwise — one grammar (`split_marker`), the same
one the haproxy section uses, and the same one the write path (task 01's helper) shares.
Four of those keys are the ones `Collector.nginx` does not serve, the way Four of those keys are the ones `Collector.nginx` does not serve, the way
`parse_haproxy_config`'s `recognised` is not: `error` is why the main text could not be `parse_haproxy_config`'s `recognised` is not: `error` is why the main text could not be
@@ -2979,6 +3124,12 @@ STATUS_PAGE = """<!doctype html>
</table> </table>
<p><a href="/snapshot">/snapshot</a> · <a href="/info">/info</a> · <p><a href="/snapshot">/snapshot</a> · <a href="/info">/info</a> ·
<a href="/health">/health</a> · <a href="/schema">/schema</a></p> <a href="/health">/health</a> · <a href="/schema">/schema</a></p>
<p style=color:#8b93a7>POST /route — the one write (switch a route's #off marker; 1.4.0).
Refusals, in order: 405 no such write path · 403 writes require a certificate (token
mode) · 403 client certificate required · 403 client certificate revoked · 403 writes
are not enabled on this agent · 422 body refusals · 503 no route helper on this
host · 502 helper unreachable / timed out / unreadable answer. The bytes are written
by the socket-activated root helper, never by this process.</p>
""" """
@@ -2987,6 +3138,8 @@ class Handler(BaseHTTPRequestHandler):
collector: Collector collector: Collector
token: str | None = None token: str | None = None
bundle: TlsBundle | None = None bundle: TlsBundle | None = None
allow_writes: bool = False
routectl_socket: str | None = None
def do_GET(self) -> None: # the BaseHTTPRequestHandler API name — keep it def do_GET(self) -> None: # the BaseHTTPRequestHandler API name — keep it
path = self.path.split("?", 1)[0].rstrip("/") or "/" path = self.path.split("?", 1)[0].rstrip("/") or "/"
@@ -3032,6 +3185,102 @@ class Handler(BaseHTTPRequestHandler):
else: else:
self._send(404, {"ok": False, "detail": f"no such path: {path}"}) self._send(404, {"ok": False, "detail": f"no such path: {path}"})
def do_POST(self) -> None: # the BaseHTTPRequestHandler API name — keep it
# The one write (1.4.0). The order is the design, and each refusal is its own
# sentence: the certificate (the handshake is the identity — the token arm is
# refused outright, the denylist is not a read-side rule), then the operator's
# flag, then the body (a refused body never reaches the peer), then the helper's
# socket. Nothing in this method writes a file; the bytes are the helper's.
path = self.path.split("?", 1)[0].rstrip("/") or "/"
if path != "/route":
self._send(405, {"ok": False, "detail": "no such write path"})
return
if self.bundle is None:
self._send(403, {"ok": False, "detail": "writes require a certificate"})
return
if not self._certificate_allows(self.bundle, path):
return
if not self.allow_writes:
self._send(403, {"ok": False, "detail": "writes are not enabled on this agent"})
return
try:
length = int(self.headers.get("content-length", "0"))
except ValueError:
length = -1
if length < 0 or length > _ROUTE_MAX_BODY:
self._send(422, {"ok": False,
"detail": "body longer than the write path accepts"})
return
raw = self.rfile.read(length)
try:
body = json.loads(raw.decode("utf-8"))
except (UnicodeDecodeError, ValueError):
self._send(422, {"ok": False, "detail": "body is not a JSON object"})
return
if not isinstance(body, dict):
self._send(422, {"ok": False, "detail": "body is not a JSON object"})
return
if not set(body) <= {"id", "action", "dry_run"}:
self._send(422, {"ok": False, "detail": "body carries a key the write path does not take"})
return
action = body.get("action")
if action not in ("disable", "enable"):
self._send(422, {"ok": False, "detail": "action must be disable or enable"})
return
rid = body.get("id")
if not isinstance(rid, str) or not rid or len(rid) > 256:
self._send(422, {"ok": False,
"detail": "id must be a string of at most 256 characters"})
return
if self.routectl_socket is None:
self._send(503, {"ok": False, "detail": "no route helper on this host"})
return
self._route_peer({"id": rid, "action": action,
"dry_run": bool(body.get("dry_run", False))})
def _route_peer(self, payload: dict[str, Any]) -> None:
"""Dial the routectl helper once, forward `payload`, pass the one answer through.
One connection, one request, one answer: a retry of a write is a second reload,
so every failure is a 502 and never a second dial. The helper's JSON travels
verbatim — a refusal it answers stays a refusal, and the agent invents no
success of its own. The log line prints the path and the status only: the
`id` is a hostname, and this process's log lines stay out of its body.
"""
buf = b""
sock = None
try:
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
sock.settimeout(ROUTE_TIMEOUT_SECONDS)
sock.connect(self.routectl_socket)
sock.sendall(json.dumps(payload).encode("utf-8") + b"\n")
while not buf.endswith(b"\n"):
chunk = sock.recv(65536)
if not chunk:
break
buf += chunk
if len(buf) > _ROUTE_MAX_ANSWER:
self._send(502, {"ok": False,
"detail": "route helper answered something unreadable"})
return
except TimeoutError:
self._send(502, {"ok": False, "detail": "route helper timed out"})
return
except OSError as exc:
word = socket.errorname.get(exc.errno, str(exc.errno))
self._send(502, {"ok": False, "detail": f"route helper: {word}"})
return
finally:
if sock is not None:
sock.close()
try:
answer = json.loads(buf.decode("utf-8").strip())
except (UnicodeDecodeError, ValueError):
self._send(502, {"ok": False,
"detail": "route helper answered something unreadable"})
return
self._send(200, answer)
def _certificate_allows(self, bundle: TlsBundle, path: str) -> bool: def _certificate_allows(self, bundle: TlsBundle, path: str) -> bool:
"""Does this connection's client certificate authorise `path`? Sends the refusal if not. """Does this connection's client certificate authorise `path`? Sends the refusal if not.
@@ -3094,17 +3343,21 @@ class Handler(BaseHTTPRequestHandler):
def serve(host: str, port: int, collector: Collector, token: str | None, def serve(host: str, port: int, collector: Collector, token: str | None,
bundle: TlsBundle | None = None) -> None: bundle: TlsBundle | None = None, *, allow_writes: bool = False,
routectl_socket: str | None = None) -> None:
"""Serve forever on host:port — plain HTTP with an optional token, or HTTPS with a bundle. """Serve forever on host:port — plain HTTP with an optional token, or HTTPS with a bundle.
All three class attributes are assigned on every call: `Handler` is process-wide state, so a All five class attributes are assigned on every call: `Handler` is process-wide state, so a
server started without a bundle must not inherit one from a server that had it (that would server started without a bundle (or without the write flag) must not inherit one from a
answer a plain-HTTP socket with 403 on every path, or worse, accept a client certificate the server that had it (that would answer a plain-HTTP socket with 403 on every path, or
operator revoked). worse, accept a client certificate the operator revoked — or answer a write to a helper
this process was never told about).
""" """
Handler.collector = collector Handler.collector = collector
Handler.token = token Handler.token = token
Handler.bundle = bundle Handler.bundle = bundle
Handler.allow_writes = allow_writes
Handler.routectl_socket = routectl_socket
httpd = ThreadingHTTPServer((host, port), Handler) httpd = ThreadingHTTPServer((host, port), Handler)
if bundle is not None: if bundle is not None:
httpd.socket = bundle.context.wrap_socket(httpd.socket, server_side=True) httpd.socket = bundle.context.wrap_socket(httpd.socket, server_side=True)
@@ -3178,6 +3431,17 @@ def main(argv: list[str] | None = None) -> int:
f"documented degradation (available=false, one error entry), " f"documented degradation (available=false, one error entry), "
f"not a failure") f"not a failure")
parser.add_argument("--once", action="store_true", help="print one snapshot as JSON and exit") parser.add_argument("--once", action="store_true", help="print one snapshot as JSON and exit")
parser.add_argument("--allow-writes", action="store_true",
help="answer POST /route on this agent (the fleet's one narrow write: "
"switch a route's #off marker). The gates are the client "
"certificate and this flag, in that order; the bytes are written "
"by the socket-activated root helper, never by this process")
parser.add_argument("--routectl-socket",
default=os.environ.get("AGENT_ROUTECTL_SOCKET",
"/run/dashboards-routectl.sock"),
help="the routectl helper's socket that POST /route dials once per "
"request; set the value empty for the "
"`503 no route helper on this host` arm")
args = parser.parse_args(argv) args = parser.parse_args(argv)
collector = Collector(args.root, top_processes=args.top, net_all=args.net_all, collector = Collector(args.root, top_processes=args.top, net_all=args.net_all,
@@ -3203,7 +3467,9 @@ def main(argv: list[str] | None = None) -> int:
"metrics are sensitive); pass --token, --token-file, --cert-bundle or set " "metrics are sensitive); pass --token, --token-file, --cert-bundle or set "
"AGENT_TOKEN / AGENT_CERT_BUNDLE", flush=True) "AGENT_TOKEN / AGENT_CERT_BUNDLE", flush=True)
return 2 return 2
serve(args.host, args.port, collector, token, bundle) serve(args.host, args.port, collector, token, bundle,
allow_writes=args.allow_writes,
routectl_socket=args.routectl_socket or None)
return 0 return 0
File diff suppressed because it is too large Load Diff
+25
View File
@@ -0,0 +1,25 @@
# dashboards-routectl.socket
#
# The socket the agent (and nothing else) may talk to for a route switch.
#
# 0600 + SocketUser=dashboards-agent is the whole access control at the file
# layer: only that uid can connect; everyone else gets EACCES before any code
# of ours runs. The helper's SO_PEERCRED check (its --allow-uid) is the
# *second* look, not the first.
#
# One connection, one instance: `Accept=yes` is what makes it so — the socket
# unit accepts the connection itself and hands the *connection* (not the
# listener) to a fresh `dashboards-routectl@<instance>` on stdin (StandardInput=socket);
# the instance answers one request and exits. There is no long-lived privileged
# process. (A `Service=` line naming the template does not load on systemd 257 —
# "missing the instance name" — `Accept=yes` is the documented template path.)
[Socket]
Accept=yes
ListenStream=/run/dashboards-routectl.sock
SocketUser=dashboards-agent
SocketGroup=dashboards-agent
SocketMode=0600
NoDelay=yes
[Install]
WantedBy=sockets.target
+42
View File
@@ -0,0 +1,42 @@
# dashboards-routectl@.service
#
# The route-switch helper, root, socket-activated, one request per instance.
#
# The privilege model, in one line: the grant is a file mode systemd enforces
# (the socket is 0600 and owned by the agent user), not a sudoers line. This
# unit is how the agent's one narrow write reaches root without the agent's
# own unit being given anything — NoNewPrivileges=true is kept *here* as well,
# because the privilege comes from being started by systemd as root, not from
# escalation.
#
# Write scope, stated out loud: the helper replaces exactly two kinds of
# config files, and os.replace needs write on the *directory*, so
# ReadWritePaths lists every directory whose file it replaces — and nothing
# else:
# /etc/haproxy the haproxy.cfg `server` line (the haproxy arm)
# /etc/nginx/conf.d the vhost `proxy_pass` line (the vhost arm)
# /etc/nginx/stream.d the `map` entry (the stream arm) and the helper's
# own sink file, 00-dashboards-sink.conf, which it
# creates and removes there
# /etc/nginx (the main config) is deliberately not listed: the helper never
# writes it.
#
# One connection, one instance: the socket's `Accept=yes` hands this template a
# fresh connected socket on stdin per connection (StandardInput=socket /
# StandardOutput=socket); the helper answers exactly one newline-delimited JSON
# request and exits, so there is never a long-lived privileged process sitting
# on a socket.
[Service]
Type=simple
ExecStart=/usr/local/sbin/dashboards-routectl.py --serve \
--allow-uid dashboards-agent --state /var/lib/dashboards-routectl/state.json
User=root
StandardInput=socket
StandardOutput=socket
StateDirectory=dashboards-routectl
ProtectSystem=read-only
ReadWritePaths=/etc/haproxy /etc/nginx/conf.d /etc/nginx/stream.d
ProtectHome=read-only
PrivateTmp=yes
NoNewPrivileges=true
SystemCallFilter=@system-service
+66
View File
@@ -0,0 +1,66 @@
# The route-switch helper (gateway hosts)
The dashboard's one narrow write is a route switch: one named connection on the
ingress goes `#off` (a marker the agent's own parser reads) or comes back. It is
not a sudoers line. The grant is a **file mode systemd enforces**: the socket the
agent talks to is `0600` and owned by the agent's own user, and the helper's
peer check (`--allow-uid`) is the second look. No account on the host — `ops`
included — can reach the write; only the agent's uid can connect, and only that
uid is accepted on the other end.
## The two units
| unit | what it is |
| ---- | ---------- |
| `dashboards-routectl.socket` | a systemd-activated stream socket at `/run/dashboards-routectl.sock`, `0600`, owned by `dashboards-agent` |
| `dashboards-routectl@.service` | the helper, **root**, one instance per connection (the connected socket on stdin), answers one request and exits |
The service unit's `ReadWritePaths` lists exactly the directories whose file the
helper replaces — `/etc/haproxy`, `/etc/nginx/conf.d`, `/etc/nginx/stream.d` —
and nothing else. There is never a long-lived privileged process: each request
spawns a fresh instance that exits after its one answer.
## See them
```
systemctl status dashboards-routectl.socket
systemctl status 'dashboards-routectl@*.service'
journalctl -u 'dashboards-routectl@*'
```
## Run the helper by hand on the box
The installed copy is `/usr/local/sbin/dashboards-routectl.py`. It takes its
agent parser by file path and refuses to run without one:
```
# what is off (the state sidecar)
sudo /usr/local/sbin/dashboards-routectl.py list
# preview one route without writing a byte (no checker, no reload, no state)
sudo /usr/local/sbin/dashboards-routectl.py diff --id nginx:443->127.0.0.1:44450 --action enable
# verify the sidecar against the files (drift is reported, never repaired)
sudo /usr/local/sbin/dashboards-routectl.py verify
```
A manual `disable --apply` runs the whole protocol: write, the proxy's own
checker (`nginx -t` / `haproxy -c -f`), restore the previous bytes on a checker
failure, `systemctl reload` (never `restart`) on success, then record the state.
## Undo it all
```
sudo ./deploy/install-agent.sh --without-routectl
```
That disables and removes the helper and its two units, **leaves the proxy
configs and the helper's state directory alone**, and prints the list of
still-off routes first — those are yours to switch back on.
## The one safety sentence
No sudoers line is involved. The agent's unit keeps every one of its security
directives (`NoNewPrivileges=true`, `ProtectSystem=strict`, …); the read-only
collector stayed read-only in its sandbox and talked to a peer outside it. The
grant is the socket's mode and ownership, enforced by systemd.
+154 -2
View File
@@ -9,8 +9,11 @@
# # registration fetches its own bundle # # registration fetches its own bundle
# sudo ./deploy/install-agent.sh --as-root --caps CAP_SYS_RAWIO,CAP_SYS_ADMIN # sudo ./deploy/install-agent.sh --as-root --caps CAP_SYS_RAWIO,CAP_SYS_ADMIN
# # a kernel-gated host (see below) # # a kernel-gated host (see below)
# sudo ./deploy/install-agent.sh --with-routectl # also install the route-switch helper
# # and its two units (the gateway only)
# ./deploy/install-agent.sh --status # unit state + /health, no root # ./deploy/install-agent.sh --status # unit state + /health, no root
# sudo ./deploy/install-agent.sh --uninstall # sudo ./deploy/install-agent.sh --uninstall
# sudo ./deploy/install-agent.sh --without-routectl # remove helper + units, keep config and state
# sudo ./deploy/install-agent.sh --force # re-apply over a unit this would change # sudo ./deploy/install-agent.sh --force # re-apply over a unit this would change
# #
# No root shell? `deploy/agent-sudoers` is the single sudoers line that lets the fleet's # No root shell? `deploy/agent-sudoers` is the single sudoers line that lets the fleet's
@@ -102,6 +105,17 @@
# sudo ./deploy/install-agent.sh --bind <addr> --port 9120 \ # sudo ./deploy/install-agent.sh --bind <addr> --port 9120 \
# --as-root --caps CAP_SYS_RAWIO,CAP_SYS_ADMIN # --as-root --caps CAP_SYS_RAWIO,CAP_SYS_ADMIN
# #
# --with-routectl (gateway hosts only) installs the one narrow write the dashboard can make:
# the root route-switch helper as a socket-activated service. The grant is a file mode systemd
# enforces — the socket is 0600 and owned by the agent's own user, and the helper's peer check
# (--allow-uid) is the second look — not a sudoers line, and the agent's unit is not changed by
# it: an install with the flag renders the agent unit byte-identically to one without it. The
# helper edits exactly one route (a #off <name> marker the agent's parser reads), validates the
# file with the proxy's own checker before it means anything, restores the previous bytes on a
# checker failure, and reloads — never restarts. --without-routectl removes the helper and its
# two units, leaves the proxy configs and the helper's state directory alone, and prints the
# list of still-off routes first, because those are the operator's to switch back on.
#
# A re-run is the zero-noise path it has always been as long as the unit this script would # A re-run is the zero-noise path it has always been as long as the unit this script would
# write is the unit already on disk. When the two differ the run stops: the diff between the # write is the unit already on disk. When the two differ the run stops: the diff between the
# installed unit and the generated one is printed and the exit code is 1, before the unit is # installed unit and the generated one is printed and the exit code is 1, before the unit is
@@ -113,6 +127,13 @@
# #
# --force overwrite an existing unit even if the generated unit differs (prints the # --force overwrite an existing unit even if the generated unit differs (prints the
# diff first) # diff first)
#
# --allow-writes (gateway hosts) adds the agent's own --allow-writes to the unit's
# ExecStart — the agent-side half of the write path; --with-routectl installs the
# helper it dials, and the two are independent (a host with the flag but without the
# helper answers a write with 503, not with a write). It is the *only* change this
# script makes to the agent's unit for the write path: a re-run diff that shows the
# security directives moving is a stop, not a --force.
set -euo pipefail set -euo pipefail
SERVICE="dashboards-agent" SERVICE="dashboards-agent"
@@ -133,6 +154,20 @@ if command -v realpath >/dev/null 2>&1; then
SCRIPT_PATH="$(realpath "$0" 2>/dev/null || printf '%s' "$SCRIPT_PATH")" SCRIPT_PATH="$(realpath "$0" 2>/dev/null || printf '%s' "$SCRIPT_PATH")"
fi fi
SUDOERS_SOURCE="$(dirname "$SCRIPT_PATH")/agent-sudoers" SUDOERS_SOURCE="$(dirname "$SCRIPT_PATH")/agent-sudoers"
# The route-switch helper (phase 33) and its two units. The helper is the only writer this
# phase adds: a root, socket-activated service the agent may talk to and nothing else. The
# grant is a file mode systemd enforces (the socket is 0600, owned by the agent's user), not
# a sudoers line, and the agent's own unit is not touched by installing it — the rendered
# unit is byte-identical with and without --with-routectl, and the re-run guard below applies
# to the helper and both units exactly as it does to the agent's.
ROUTECTL_SOURCE="$SCRIPT_DIR/dashboards-routectl.py"
ROUTECTL_SOCKET_UNIT_SRC="$SCRIPT_DIR/dashboards-routectl.socket"
ROUTECTL_SERVICE_UNIT_SRC="$SCRIPT_DIR/dashboards-routectl@.service"
ROUTECTL_TARGET="/usr/local/sbin/dashboards-routectl.py"
ROUTECTL_SOCKET_UNIT="/etc/systemd/system/dashboards-routectl.socket"
ROUTECTL_SERVICE_UNIT="/etc/systemd/system/dashboards-routectl@.service"
ROUTECTL_SOCKET_PATH="/run/dashboards-routectl.sock"
ROUTECTL_STATE_DIR="/var/lib/dashboards-routectl"
BIND="127.0.0.1" BIND="127.0.0.1"
PORT="9120" PORT="9120"
@@ -158,6 +193,14 @@ CAPS=""
# --force: the re-run guard's opt-in (phase 17 task 03). 0 means a re-run whose generated unit # --force: the re-run guard's opt-in (phase 17 task 03). 0 means a re-run whose generated unit
# differs from the installed one refuses after printing the diff. # differs from the installed one refuses after printing the diff.
FORCE=0 FORCE=0
# --with-routectl / --without-routectl (phase 33): the route-switch helper and its two units,
# installed beside the agent and never inside its unit. Both off is the default: an install
# without the flag is byte-identical to one before the flag existed.
WITH_ROUTECTL=0
# --allow-writes (phase 33): appends the agent's own --allow-writes to the unit's ExecStart.
# It is the agent-side gate for POST /route; the unit's security directives do not move
# with it, which is the phase's central safety claim in one flag.
ALLOW_WRITES=0
MODE="install" MODE="install"
usage() { usage() {
@@ -192,6 +235,9 @@ while [ $# -gt 0 ]; do
shift 2 shift 2
;; ;;
--force) FORCE=1; shift ;; --force) FORCE=1; shift ;;
--allow-writes) ALLOW_WRITES=1; shift ;;
--with-routectl) WITH_ROUTECTL=1; shift ;;
--without-routectl) MODE="routectl-off"; shift ;;
--status) MODE="status"; shift ;; --status) MODE="status"; shift ;;
--uninstall) MODE="uninstall"; shift ;; --uninstall) MODE="uninstall"; shift ;;
-h|--help) usage 0 ;; -h|--help) usage 0 ;;
@@ -233,7 +279,23 @@ fi
# The fetch belongs to an install. `--status` probes a port and `--uninstall` deletes files; a # The fetch belongs to an install. `--status` probes a port and `--uninstall` deletes files; a
# run of either would fetch a private key for nothing and leave it in a temp file. # run of either would fetch a private key for nothing and leave it in a temp file.
if [ "$FETCH_GIVEN" -eq 3 ] && [ "$MODE" != "install" ]; then if [ "$FETCH_GIVEN" -eq 3 ] && [ "$MODE" != "install" ]; then
echo "--fetch-bundle needs an install (it means nothing to --status or --uninstall)" >&2 echo "--fetch-bundle needs an install (it means nothing to --status, --uninstall or --without-routectl)" >&2
exit 1
fi
# One flag each direction, like --as-root/--caps: a run that installs the helper and removes
# it is a mistake about what to install, and it gets its own sentence before any write.
if [ "$WITH_ROUTECTL" -eq 1 ] && [ "$MODE" = "routectl-off" ]; then
echo "--with-routectl and --without-routectl are one flag each direction; run them separately" >&2
exit 1
fi
# --with-routectl installs files; --status probes a port and --uninstall deletes the agent.
if [ "$WITH_ROUTECTL" -eq 1 ] && [ "$MODE" != "install" ]; then
echo "--with-routectl needs an install (it means nothing to --status or --uninstall)" >&2
exit 1
fi
# --allow-writes rewrites the agent's unit; none of the non-install modes render a unit.
if [ "$ALLOW_WRITES" -eq 1 ] && [ "$MODE" != "install" ]; then
echo "--allow-writes needs an install (it is an argument to the agent's unit, not to --status, --uninstall or --without-routectl)" >&2
exit 1 exit 1
fi fi
@@ -335,6 +397,26 @@ uninstall() {
echo "removed $SERVICE (the $AGENT_USER user was left in place; remove it with userdel)" echo "removed $SERVICE (the $AGENT_USER user was left in place; remove it with userdel)"
} }
routectl_off() {
# The inverse of --with-routectl: the helper and its two units go; the proxy configs and
# the helper's state directory stay — the state is the memory of what was switched, and
# a route the helper left off is the operator's to switch back on, so the still-off list
# prints first and the removal ends with the reminder, not the file list.
need_root --without-routectl
if [ -f "$ROUTECTL_TARGET" ]; then
echo "routes the helper has switched off (these are still off after this removal — switch them back on):"
"$ROUTECTL_TARGET" --state "$ROUTECTL_STATE_DIR/state.json" list || true
else
echo "the helper is not installed; nothing to list"
fi
systemctl disable --now dashboards-routectl.socket 2>/dev/null || true
systemctl disable --now 'dashboards-routectl@.service' 2>/dev/null || true
rm -f "$ROUTECTL_SOCKET_UNIT" "$ROUTECTL_SERVICE_UNIT" "$ROUTECTL_TARGET"
systemctl daemon-reload
echo "removed dashboards-routectl (the proxy configs and $ROUTECTL_STATE_DIR are left in place)"
echo "reminder: a route still off in the list above is still off — switch it back on"
}
new_token() { new_token() {
# urlsafe and no padding: the token goes into HOST_TARGETS as URL userinfo # urlsafe and no padding: the token goes into HOST_TARGETS as URL userinfo
# (`name=http://TOKEN@host:9120`), where '+' and '=' are annoying to quote. # (`name=http://TOKEN@host:9120`), where '+' and '=' are annoying to quote.
@@ -590,6 +672,14 @@ RuntimeDirectory=dashboards-agent
RuntimeDirectoryMode=0700" RuntimeDirectoryMode=0700"
fi fi
# The write path's one unit change: the flag on ExecStart, and nothing else. The four
# security directives below are rendered exactly as before; a diff that shows them moving
# is a stop, not a --force (the phase's central safety claim, in one flag).
WRITES_EXEC_ARGS=""
if [ "$ALLOW_WRITES" -eq 1 ]; then
WRITES_EXEC_ARGS=" --allow-writes"
fi
# The kernel-gate variant (--as-root / --caps). Default: the dedicated user and an empty # The kernel-gate variant (--as-root / --caps). Default: the dedicated user and an empty
# CapabilityBoundingSet, i.e. the capability-less unit this script has always written. # CapabilityBoundingSet, i.e. the capability-less unit this script has always written.
UNIT_USER="$AGENT_USER" UNIT_USER="$AGENT_USER"
@@ -630,7 +720,7 @@ Wants=network-online.target
[Service] [Service]
Type=simple Type=simple
User=$UNIT_USER User=$UNIT_USER
ExecStart=/usr/bin/env python3 $AGENT_TARGET --host $BIND --port $PORT --token-file ${TOKEN_FILE}${TLS_EXEC_ARGS} ExecStart=/usr/bin/env python3 $AGENT_TARGET --host $BIND --port $PORT --token-file ${TOKEN_FILE}${TLS_EXEC_ARGS}${WRITES_EXEC_ARGS}
Restart=on-failure Restart=on-failure
RestartSec=5 RestartSec=5
Environment=PYTHONUNBUFFERED=1 Environment=PYTHONUNBUFFERED=1
@@ -677,6 +767,33 @@ SystemCallFilter=@system-service
WantedBy=multi-user.target WantedBy=multi-user.target
UNIT_FILE UNIT_FILE
# The helper and its two units land beside the agent's unit and never inside it: the
# re-run guard is the same one, per file — a differing installed helper or unit is a diff
# plus exit 1 unless --force, so a routine redeploy cannot reset a helper somebody fixed by
# hand. The *guard* for the three runs here, before the agent unit guard, and the *writes*
# run after the agent unit write: a refusal does nothing after it — no routectl file, no
# agent unit, no daemon-reload, no enable — and an apply writes both before the one reload.
ROUTECTL_REFUSED=0
if [ "$WITH_ROUTECTL" -eq 1 ]; then
for _pair in "$ROUTECTL_SOURCE $ROUTECTL_TARGET" \
"$ROUTECTL_SOCKET_UNIT_SRC $ROUTECTL_SOCKET_UNIT" \
"$ROUTECTL_SERVICE_UNIT_SRC $ROUTECTL_SERVICE_UNIT"; do
_src="${_pair%% *}"
_dst="${_pair##* }"
[ -f "$_src" ] || { echo "no $_src beside this script" >&2; exit 1; }
if [ -f "$_dst" ] && ! cmp -s "$_dst" "$_src"; then
if [ "$FORCE" -eq 1 ]; then
diff -u "$_dst" "$_src" || true
else
echo "refusing to overwrite $_dst (installed copy differs); run with --force to apply:"
diff -u "$_dst" "$_src" || true
ROUTECTL_REFUSED=1
break
fi
fi
done
fi
# No silent clobber (phase 17 task 03). $UNIT is the operator's file as much as this # No silent clobber (phase 17 task 03). $UNIT is the operator's file as much as this
# template's output: it gets edited on the host to fix something the template did not know # template's output: it gets edited on the host to fix something the template did not know
# (driveripper: User=root plus the two capabilities the kernel-7.2 passthrough gate needs), # (driveripper: User=root plus the two capabilities the kernel-7.2 passthrough gate needs),
@@ -699,6 +816,22 @@ UNIT_FILE
exit 1 exit 1
fi fi
fi fi
# Both guards have passed now: the writes are one step. The routectl refusal flag exists
# only so this write can be skipped when a routectl file refused above — but the agent
# unit guard below exits the run, so a routectl refusal alone must stop the run too:
# the refusal is the answer, and a half-apply (agent unit written, routectl not, and no
# reload) is the failure it exists to prevent.
if [ "$ROUTECTL_REFUSED" -eq 1 ]; then
rm -f "$TMP_UNIT"
exit 1
fi
if [ "$WITH_ROUTECTL" -eq 1 ]; then
install -d -m 0755 "$(dirname "$ROUTECTL_TARGET")"
install -m 0755 "$ROUTECTL_SOURCE" "$ROUTECTL_TARGET"
install -m 0644 "$ROUTECTL_SOCKET_UNIT_SRC" "$ROUTECTL_SOCKET_UNIT"
install -m 0644 "$ROUTECTL_SERVICE_UNIT_SRC" "$ROUTECTL_SERVICE_UNIT"
echo " installed the route-switch helper in $ROUTECTL_TARGET and its two units"
fi
cat "$TMP_UNIT" > "$UNIT" cat "$TMP_UNIT" > "$UNIT"
rm -f "$TMP_UNIT" rm -f "$TMP_UNIT"
@@ -712,6 +845,24 @@ UNIT_FILE
journalctl -u "$SERVICE" -n 20 --no-pager >&2 journalctl -u "$SERVICE" -n 20 --no-pager >&2
exit 1 exit 1
fi fi
if [ "$WITH_ROUTECTL" -eq 1 ]; then
systemctl enable --now dashboards-routectl.socket
if ! systemctl is-active --quiet dashboards-routectl.socket; then
echo " dashboards-routectl.socket failed to start — last lines:" >&2
journalctl -u dashboards-routectl.socket -n 20 --no-pager >&2
exit 1
fi
# The reachability check the install owes: a socket the agent's own uid cannot connect
# to is a feature that fails at 03:00 during an incident, so the check is part of the
# install and its output is printed. $UNIT_USER is the service user (the agent runs as
# root under --as-root, and root may always connect — the check then passes trivially).
if sudo -u "$UNIT_USER" python3 -c 'import socket, sys; s = socket.socket(socket.AF_UNIX); s.connect(sys.argv[1])' "$ROUTECTL_SOCKET_PATH"; then
echo " $UNIT_USER can connect to $ROUTECTL_SOCKET_PATH (the whole grant at the file layer)"
else
echo " $UNIT_USER cannot connect to $ROUTECTL_SOCKET_PATH — the helper is installed but the agent cannot reach it" >&2
exit 1
fi
fi
if [ -n "$TLS_EXEC_ARGS" ]; then if [ -n "$TLS_EXEC_ARGS" ]; then
cat <<NEXT cat <<NEXT
@@ -747,4 +898,5 @@ case "$MODE" in
install) do_install ;; install) do_install ;;
status) status ;; status) status ;;
uninstall) uninstall ;; uninstall) uninstall ;;
routectl-off) routectl_off ;;
esac esac
+4
View File
@@ -47,6 +47,10 @@ packages = ["src/dashboards"]
[tool.pytest.ini_options] [tool.pytest.ini_options]
testpaths = ["tests"] testpaths = ["tests"]
addopts = "-q --strict-markers" addopts = "-q --strict-markers"
# pytest-asyncio 1.x (pytest 9.2) errors at configure if the default fixture loop scope is
# unset; "function" is the scope the deprecation points at and matches how every async
# fixture here is cached (nothing shares a loop across fixtures).
asyncio_default_fixture_loop_scope = "function"
markers = [ markers = [
# `-m live --live` runs them; without --live they SKIP, so `uv run pytest` stays # `-m live --live` runs them; without --live they SKIP, so `uv run pytest` stays
# network-free. See tests/test_live_fleet.py for the env vars each one needs. # network-free. See tests/test_live_fleet.py for the env vars each one needs.
+311 -14
View File
@@ -1,4 +1,6 @@
"""HTTP API. GET everywhere except one endpoint: `POST /api/ups/command`. """HTTP API. GET everywhere except two endpoints: `POST /api/ups/command` and
`POST /api/gateway/route` — the app's two upstream writes, each gated by its own
sentence at its own gates (invariant 1).
Everything a browser can see is derived from the in-memory payloads (fast, no DB on the Everything a browser can see is derived from the in-memory payloads (fast, no DB on the
hot path) plus SQLite for anything older than a few minutes. Two rules from llm_monitor that hot path) plus SQLite for anything older than a few minutes. Two rules from llm_monitor that
@@ -22,6 +24,7 @@ from dataclasses import dataclass, field, replace
from typing import Any from typing import Any
from urllib.parse import urlsplit from urllib.parse import urlsplit
import httpx
from fastapi import APIRouter, Body, Depends, HTTPException, Query, Request, Response from fastapi import APIRouter, Body, Depends, HTTPException, Query, Request, Response
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
@@ -29,7 +32,7 @@ from . import alerts as alert_rules
from . import auth, certs from . import auth, certs
from .auth import ADMIN_USER, LOGIN_PATH, AuthState, LoginThrottle, Session from .auth import ADMIN_USER, LOGIN_PATH, AuthState, LoginThrottle, Session
from .certs import CertMaterial from .certs import CertMaterial
from .collectors import NutMonitor from .collectors import NutMonitor, UpstreamError, agent_tls_map, post_route
from .config import Settings, strip_userinfo from .config import Settings, strip_userinfo
from .poller import Hub, SourceState from .poller import Hub, SourceState
from .severity import classify from .severity import classify
@@ -93,6 +96,11 @@ class AppContext:
*is* "one throttle per process" — and `None` would be a live branch in the login route *is* "one throttle per process" — and `None` would be a live branch in the login route
for a state nothing can reach. A throttle created per request would be a throttle that for a state nothing can reach. A throttle created per request would be a throttle that
never trips, which is the failure this object must not have. never trips, which is the failure this object must not have.
`client` is the one shared `httpx.AsyncClient` the jobs dial with (`app.py`): the write
route reuses it for the plain-token path the same way the reads do, so a `POST` to a
host and a `GET` from the same host are one pool. `None` only on a context assembled
outside the lifespan, like `auth` and `certs`.
""" """
settings: Settings settings: Settings
@@ -103,6 +111,7 @@ class AppContext:
auth: AuthState | None = None auth: AuthState | None = None
certs: CertMaterial | None = None certs: CertMaterial | None = None
login_throttle: LoginThrottle = field(default_factory=LoginThrottle) login_throttle: LoginThrottle = field(default_factory=LoginThrottle)
client: httpx.AsyncClient | None = None
def get_context(request: Request) -> AppContext: def get_context(request: Request) -> AppContext:
@@ -255,8 +264,9 @@ def _endpoint_values(connections: Any) -> list[Any]:
def _gateway_summary(payload: Mapping[str, Any] | None, settings: Settings) -> dict[str, Any]: def _gateway_summary(payload: Mapping[str, Any] | None, settings: Settings) -> dict[str, Any]:
"""`/api/summary`'s gateway block: the ingress's totals, or a stub of unknowns. """`/api/summary`'s gateway block: the ingress's totals, or a stub of unknowns.
Additive in phase 29, reshaped by phase 30 task 02 into eleven fields, with `warnings` joined Additive in phase 29, reshaped by phase 30 task 02 into eleven fields, `warnings` joined by
by task 03 as the twelfth. The page charts task 03 as the twelfth, and `disabled` — the switch-off tally — by phase 33 task 04 as the
thirteenth. The page charts
from `/api/gateway/snapshot`, which has the rows themselves; this block is the **tally** a from `/api/gateway/snapshot`, which has the rows themselves; this block is the **tally** a
programmatic consumer (and the overview's one line) reads without walking them. Three arms, and programmatic consumer (and the overview's one line) reads without walking them. Three arms, and
they are three different sentences: they are three different sentences:
@@ -304,6 +314,11 @@ def _gateway_summary(payload: Mapping[str, Any] | None, settings: Settings) -> d
"map_targets": None, "map_targets": None,
# the config-integrity tally; `null` until the agent answers, exactly like the row counts # the config-integrity tally; `null` until the agent answers, exactly like the row counts
"warnings": None, "warnings": None,
# the switch-off tally (phase 33 task 04): the number of rows reading `enabled: false`
# — routes the operator's own marker (or the helper's sink) switched off. `null` in the
# unsupported arm, exactly like the other tallies: an agent that never read the configs
# has no route in an off state.
"disabled": None,
} }
if payload is None: if payload is None:
return block return block
@@ -322,6 +337,12 @@ def _gateway_summary(payload: Mapping[str, Any] | None, settings: Settings) -> d
# malformed counts as zero rather than raising — this module does not own the projection, and a # malformed counts as zero rather than raising — this module does not own the projection, and a
# drift inside one row must not 500 the overview (see `_mappings`). # drift inside one row must not 500 the overview (see `_mappings`).
block["warnings"] = len(_mappings(payload.get("warnings"))) block["warnings"] = len(_mappings(payload.get("warnings")))
# The tally over the rows' own `enabled` state, not a second reading of the configs: a row
# says `enabled: false` when the agent read the helper's marker (or the sink address) off
# that route's line. `0` is the truth ("nothing switched off"), `null` is the unsupported arm
# above, and a row from an agent older than the marker (no `enabled` key) is counted as on —
# absence of the flag is "not switched off", never "broken".
block["disabled"] = sum(1 for row in connections if row.get("enabled") is False)
# Row counts come from the config and are therefore known whenever the agent answered at all — # Row counts come from the config and are therefore known whenever the agent answered at all —
# `0` here is the truth ("this host declares no listener"), not a missing measurement, so it is # `0` here is the truth ("this host declares no listener"), not a missing measurement, so it is
# the *live* pair below that carries the nulls. # the *live* pair below that carries the nulls.
@@ -498,9 +519,9 @@ def auth_router() -> APIRouter:
every cookie write — a plain `<a href>` link that signs the operator out is a CSRF, and one every cookie write — a plain `<a href>` link that signs the operator out is a CSRF, and one
planted on a page they read during an outage is the version that actually hurts. planted on a page they read during an outage is the version that actually hurts.
These are the app's only writes besides `POST /api/ups/command`, and they are writes to These are the app's only writes besides `POST /api/ups/command` and `POST /api/gateway/route`, and they are writes to
**this process** and its SQLite `meta` table, never to an upstream: invariant 1 is about the **this process** and its SQLite `meta` table, never to an upstream: invariant 1 is about the
fleet, and `tests/test_api.py` pins the two lists separately rather than widening the first. fleet, and `tests/test_api.py` pins the lists separately rather than widening the first.
""" """
router = APIRouter(prefix="/api/auth", tags=["auth"]) router = APIRouter(prefix="/api/auth", tags=["auth"])
@@ -712,6 +733,11 @@ def common_router() -> APIRouter:
"url": _gateway_url(settings), "url": _gateway_url(settings),
"interval_seconds": settings.gateway_interval, "interval_seconds": settings.gateway_interval,
"timeout_seconds": settings.gateway_timeout, "timeout_seconds": settings.gateway_timeout,
# `GATEWAY_ALLOW_WRITES`, the write's own flag (invariant 1's second
# exception): the page renders the switch's honest arm from it instead of
# assuming (rule 2) — `read_only` stays in the `app` block, where both
# write paths read it.
"writes_enabled": settings.gateway_allow_writes,
}, },
"ups": { "ups": {
"enabled": settings.nut_enabled, "enabled": settings.nut_enabled,
@@ -994,9 +1020,10 @@ def _disk_visible(host: Mapping[str, Any]) -> bool:
def disks_router() -> APIRouter: def disks_router() -> APIRouter:
"""`/api/disks/*` — GETs only, like every other dashboard's surface. """`/api/disks/*` — GETs only, like every other dashboard's surface.
There is deliberately no POST here: the app's one write stays `POST /api/ups/command` There is deliberately no POST here: the app's two upstream writes stay `POST /api/ups/command`
(invariant 1). Nothing about reading a drive's SMART attributes justifies a mutation, and and `POST /api/gateway/route` (invariant 1). Nothing about reading a drive's SMART attributes
`smartctl -t` (a self-test) would put real load on a disk something is plugged into. justifies a mutation, and `smartctl -t` (a self-test) would put real load on a disk something
is plugged into.
""" """
router = APIRouter(prefix="/api/disks", tags=["disks"]) router = APIRouter(prefix="/api/disks", tags=["disks"])
@@ -1071,11 +1098,15 @@ def disks_router() -> APIRouter:
# Gateway dashboard (the ingress's pipes) # Gateway dashboard (the ingress's pipes)
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
def gateway_router() -> APIRouter: def gateway_router() -> APIRouter:
"""`/api/gateway/*` — GETs only, like every other dashboard's surface. """`/api/gateway/*` — the two read routes, and the app's second upstream write.
A fifth data page grows no write (invariant 1's shape): reading two config files and a kernel `GET /snapshot` and `GET /history` are like every other dashboard's surface: read-only, and
socket table justifies no mutation, and the app's only route that dials an upstream stays an unconfigured `GATEWAY_HOST` answers `404 {"configured": false}` before anything that
`POST /api/ups/command`. `tests/test_api.py` enumerates the served verbs to keep that true. could open a socket (invariant 3). `POST /route` is the second exception to invariant 1
(the first is `POST /api/ups/command`), and its ten gates are the design of the page it
serves: nine refusals before a socket opens, then one dial through
`collectors.post_route`. `tests/test_api.py` enumerates the served verbs to keep both
write lists true, and `tests/test_auth_api.py` pins the gate's public surface.
""" """
router = APIRouter(prefix="/api/gateway", tags=[GATEWAY_DASHBOARD]) router = APIRouter(prefix="/api/gateway", tags=[GATEWAY_DASHBOARD])
@@ -1152,9 +1183,275 @@ def gateway_router() -> APIRouter:
"series": _history_batch(ctx.store, gateway_source.SOURCE, wanted_hosts, "series": _history_batch(ctx.store, gateway_source.SOURCE, wanted_hosts,
wanted_metrics, span, points)} wanted_metrics, span, points)}
@router.post("/route")
async def switch_route(request: Request,
ctx: AppContext = Depends(get_context)) -> Any:
"""The app's second and last upstream write: turn one ingress route off or back on.
The gate order is the design, and it is written as numbered comments because a
reordering is the bug. Gates 1-3 run in the session-gate middleware (`app.py`) before
this body sees anything — the session (a `303` to `/login` for a page, the one JSON
refusal for the rest), the `Origin`/`Sec-Fetch-Site` check on a write, and
`X-CSRF-Token` for this session on a cookie write — and `tests/test_auth_api.py`
pins all three on the wire for this route, the way it does for the UPS one. Gates
4-10 run here; every one refuses **before the single dial** (`collectors.post_route`
is what the first nine keep off), and each refusal names its gate in its 4xx/429
sentence and in the one event this call writes, and says nothing about which check
the caller came closest to passing.
The response is the helper's own answer object, verbatim (a `applied: false` stays
`false` — no success is invented, rule 2), plus `host`, `id`, `action`, `dry_run` and
the `hint` sentences this module owns.
"""
settings = ctx.settings
address = request.client.host if request.client else "unknown"
throttle = ctx.login_throttle
def event(kind: str, severity: str, message: str, data: dict[str, Any]) -> None:
# One event per call (invariant: the feed is a window, not a printout), under the
# dashboard name, for the host the route lives on. No diff, no file path, no config
# text — the message is a fixed shape and the data carries ids and flags only.
ctx.store.log_event(Event(time.time(), GATEWAY_DASHBOARD, settings.gateway_host,
kind, severity, message, data))
def row_text(row: Mapping[str, Any]) -> tuple[str, str]:
out = row.get("out") or {}
selectors = out.get("selectors") or []
subject = (out.get("name") or (selectors[0] if selectors else None)
or out.get("host") or "?")
port = out.get("port")
target = str(out.get("host") or "?") + (f":{port}" if port else "")
return str(subject), target
def denied_row(row: Mapping[str, Any], gate: int, name: str, action: str) -> None:
subject, target = row_text(row)
event("route-denied", "error",
f"{action} {subject} → {target}: denied at gate {gate} — {name} (admin)",
{"gate": gate, "id": row.get("id")})
# Gate 4 — `DASH_READ_ONLY` beats everything, including the flag below (invariant 1):
# the read-only refusal is read before `GATEWAY_ALLOW_WRITES` is, and the sentence
# names the variable so `grep DASH_READ_ONLY` finds both write paths.
if settings.read_only:
event("route-denied", "error",
"route write denied at gate 4 — read-only (admin)", {"gate": 4})
raise HTTPException(403, "this dashboard is read-only (DASH_READ_ONLY)")
# Gate 5 — `GATEWAY_ALLOW_WRITES`, its own flag: a deployment that may test a UPS
# battery is not one that may cut the fleet's only ingress, so the two writes are
# two independent decisions (invariant 1's two exceptions, two sentences).
if not settings.gateway_allow_writes:
event("route-denied", "error",
"route write denied at gate 5 — writes disabled (admin)", {"gate": 5})
raise HTTPException(403, "route switching is disabled — set GATEWAY_ALLOW_WRITES=1 "
"to enable it")
# Gate 6 — the write's own throttle key (`route:<address>`, the third shape on the
# per-process `login_throttle`; the per-process caveat stays a documented gap). The
# answer is `refusal()`'s, like the password change's, because it is the one 4xx
# that carries `Retry-After`, and `retry_after` is read-only: a refused request does
# not extend its own block by asking.
key = f"route:{address}"
blocked = throttle.retry_after(key)
if blocked > 0:
seconds = math.ceil(blocked)
event("route-denied", "error",
"route write denied at gate 6 — throttled (admin)", {"gate": 6})
return refusal(429, f"too many route writes — try again in {seconds}s",
headers={"Retry-After": str(seconds)})
# Gate 7 — the body, parsed by hand for the same reason the login body is: the 422
# must be *ours* — one fixed sentence naming the contract — not a framework's
# per-field schema errors a caller has to diff to fix the request.
try:
body = await request.json()
except Exception as exc: # not JSON at all — the same refusal as a missing key
event("route-denied", "error",
"route write denied at gate 7 — bad body (admin)", {"gate": 7})
raise HTTPException(422, "send a JSON body with `id`, `action` and `danger`") from exc
if not isinstance(body, dict):
event("route-denied", "error",
"route write denied at gate 7 — bad body (admin)", {"gate": 7})
raise HTTPException(422, "send a JSON body with `id`, `action` and `danger`")
route_id = body.get("id")
action = body.get("action")
dry_run = body.get("dry_run", False)
extra = sorted(set(body) - {"id", "action", "dry_run", "danger"})
if extra:
event("route-denied", "error",
"route write denied at gate 7 — bad body (admin)", {"gate": 7})
raise HTTPException(422,
f"unknown key {extra[0]!r} — send only `id`, `action`, "
f"`dry_run` and `danger`")
if not isinstance(route_id, str) or not route_id:
event("route-denied", "error",
"route write denied at gate 7 — bad body (admin)", {"gate": 7})
raise HTTPException(422, "`id` must be a non-empty string")
if len(route_id) > 256:
event("route-denied", "error",
"route write denied at gate 7 — bad body (admin)", {"gate": 7})
raise HTTPException(422, "`id` is longer than 256 characters")
if action not in ("disable", "enable"):
event("route-denied", "error",
"route write denied at gate 7 — bad body (admin)", {"gate": 7})
raise HTTPException(422, "`action` must be `disable` or `enable`")
if not isinstance(dry_run, bool):
event("route-denied", "error",
"route write denied at gate 7 — bad body (admin)", {"gate": 7})
raise HTTPException(422, "`dry_run` must be a boolean")
# Gate 8 — the id's state in the *current* payload. A route the payload has not
# published is not a route this app may flip: an unknown id is a 409, an id already
# in the requested state is a 409 `{changed: false}`, and a gateway that has never
# answered is a 409 too — never a guess about a route it cannot see. Both spellings
# are accepted: with or without the `#off` suffix; the helper's wire id is the base.
job = ctx.hub.jobs.get(gateway_source.SOURCE)
if job is None or job.state.payload is None:
event("route-denied", "error",
"route write denied at gate 8 — never answered (admin)",
{"gate": 8, "id": route_id})
raise HTTPException(409, "the gateway has not answered yet — no route is visible")
payload = job.state.payload
base = route_id[:-4] if route_id.endswith("#off") else route_id
rows = _mappings(payload.get("connections"))
row_on = next((row for row in rows
if row.get("id") == base and row.get("enabled") is True), None)
row_off = next((row for row in rows
if row.get("id") == base + "#off" and row.get("enabled") is False),
None)
row, already = (row_on, row_off) if action == "disable" else (row_off, row_on)
if row is None:
if already is not None:
denied_row(already, 8,
f"already {'off' if action == 'disable' else 'on'}", action)
raise HTTPException(409, detail={"changed": False})
event("route-denied", "error",
"route write denied at gate 8 — unknown id (admin)",
{"gate": 8, "id": route_id})
raise HTTPException(409, f"unknown id {route_id!r} — not in the current payload")
# Gate 9 — freshness: "do not rewrite a config you cannot currently read end to
# end". All six facts are in the job state and the payload; the sentence and the
# event name the one that refused.
now = time.time()
host_job = ctx.hub.jobs.get(f"host:{settings.gateway_host}")
fresh_name: str | None = None
if not job.state.ok:
fresh_name = "the gateway job is not ok"
elif job.state.last_ok is None:
fresh_name = "the gateway has not answered"
elif now - job.state.last_ok > 3 * job.interval:
fresh_name = "the gateway is stale"
elif host_job is None or not host_job.state.ok:
fresh_name = "the host job is not ok"
else:
raw_sections = payload.get("sections")
sections = raw_sections if isinstance(raw_sections, Mapping) else {}
if sections.get("haproxy") is not True:
fresh_name = "the haproxy section is not read"
elif sections.get("nginx") is not True:
fresh_name = "the nginx section is not read"
elif not _version_at_least(payload.get("agent_version"), (1, 4, 0)):
fresh_name = "the agent predates the marker read"
if fresh_name is not None:
denied_row(row, 9, fresh_name, action)
raise HTTPException(409, f"{fresh_name} — do not rewrite a config you cannot "
f"currently read end to end")
# Gate 10 — the typed `danger: "DANGER"`, for **both** verbs: switching a route off
# and switching one back on are both writes to the fleet's only ingress, and the
# word is the confirmation (the UPS `confirm="DANGER"` sentence's sibling). A dry
# run is the one body that does not carry it: the helper validates and answers with
# a diff without writing a byte, so there is nothing for the word to confirm — the
# preview the page asks for before the operator types it (phase 33 task 06).
if not dry_run and body.get("danger") != "DANGER":
denied_row(row, 10, "danger confirmation missing", action)
raise HTTPException(422, "switching an ingress route can cut the fleet's only "
'ingress: send danger="DANGER" to prove you read the '
"warning")
# The one dial — what gates 1-10 exist to keep off until they pass. `post_route`
# dials the same web server the reads do: the per-dial `AgentTls` client, the
# revocation check before any socket opens, no `Authorization` on the certificate
# path, the write's own 35 s budget (`GATEWAY_WRITE_TIMEOUT_SECONDS`, longer than
# the agent's 30 s peer timeout on purpose), and the redacted URL in every error.
client = ctx.client
if client is None:
# A context assembled outside the lifespan has no dialer (the `auth` `None`
# branch's sibling): it answers the 503, not a 500 with a traceback.
raise HTTPException(503, "the server's dialer is not ready")
target = next((item for item in settings.host_targets
if item.name == settings.gateway_host), None)
if target is None:
# A context assembled without the source it claims to write for: answer the 503,
# not a 500 with a traceback. (The job's existence makes this unreachable in a
# live app — the source is configured exactly when its host is in `host_targets`
# — but a hand-built context is what the 503 is for.)
raise HTTPException(503, "the server's gateway is not configured")
tls = (agent_tls_map(ctx.certs, ctx.store, (target,)).get(target.name)
if ctx.certs is not None else None)
subject, target_text = row_text(row)
try:
answer = await post_route(client, target, {"id": base, "action": action,
"dry_run": dry_run},
timeout=settings.gateway_write_timeout, tls=tls)
except UpstreamError as exc:
event("route-denied", "error",
f"{action} {subject} → {target_text}: refused at the dial — {exc} (admin)",
{"id": base, "action": action, "dry_run": dry_run})
throttle.failure(key)
raise
applied = answer.get("applied") is True
# One event per call: a change is `warn` under its own kind, a refusal (the helper's
# `applied: false`, or the dial's own error above) is `error` under `route-denied`.
# A dry run is neither — it changed nothing and refused nothing, and a feed line
# reading `applied=false` for a preview would say the helper refused a write it
# was never asked to make.
triple = (f"applied={str(applied).lower()} "
f"validated={str(answer.get('validated') is True).lower()} "
f"reloaded={str(answer.get('reloaded') is True).lower()}")
if not dry_run:
event("route-disabled" if (applied and action == "disable")
else ("route-enabled" if applied else "route-denied"),
"warn" if applied else "error",
f"{action} {subject} → {target_text}: {triple} (admin)",
{"id": base, "action": action, "dry_run": dry_run,
"applied": applied,
"validated": answer.get("validated"),
"reloaded": answer.get("reloaded")})
if dry_run:
hint = "a dry run — nothing was written; the diff above is what the change would do"
elif applied:
# The attempt completed: the backoff resets. A helper-side `applied: false`
# records neither a success nor a failure — the caller did nothing wrong and
# the helper said so; a failed dial charged one above.
throttle.success(key)
hint = (f"the helper reloaded the proxy — the switch shows on the next gateway "
f"poll (≤ {settings.gateway_interval:.0f}s)")
else:
hint = ("no byte of config was written by this call — the helper's word above "
"says why")
return {**answer, "host": settings.gateway_host, "id": route_id,
"action": action, "dry_run": dry_run, "hint": hint}
return router return router
def _version_at_least(version: Any, minimum: tuple[int, ...]) -> bool:
"""`"1.4.1" >= (1, 4, 0)`. A version this cannot read is *below* the floor, never above:
a payload without an `agent_version` cannot prove it reads the marker, and a write aimed
at a config the dashboard cannot read is gate 9's whole point."""
if not isinstance(version, str):
return False
chunks = version.strip().split(".")
if not chunks or any(not chunk.isdigit() for chunk in chunks):
return False
got = tuple(int(chunk) for chunk in chunks)
got = got + (0,) * (len(minimum) - len(got))
return got[:len(minimum)] >= minimum
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# LLM dashboard # LLM dashboard
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@@ -1245,7 +1542,7 @@ def ups_router() -> APIRouter:
@router.post("/command") @router.post("/command")
async def send_command(ctx: AppContext = Depends(get_context), async def send_command(ctx: AppContext = Depends(get_context),
payload: dict[str, Any] = Body(...)) -> dict[str, Any]: payload: dict[str, Any] = Body(...)) -> dict[str, Any]:
"""The one write endpoint in the application. Deliberately loud about it. """One of the application's two upstream write endpoints. Deliberately loud about it.
Gated on: NUT_ALLOW_ACTIONS, not DASH_READ_ONLY, credentials that carry `actions` Gated on: NUT_ALLOW_ACTIONS, not DASH_READ_ONLY, credentials that carry `actions`
in upsd.users, the command being advertised by the driver for that UPS, and — for in upsd.users, the command being advertised by the driver for that UPS, and — for
+2 -2
View File
@@ -196,7 +196,7 @@ def build_context(settings: Settings, client: httpx.AsyncClient) -> tuple[
hub, nut_monitor = build_hub(settings, store, client, hub, nut_monitor = build_hub(settings, store, client,
tls=agent_tls_map(material, store, settings.host_targets)) tls=agent_tls_map(material, store, settings.host_targets))
ctx = AppContext(settings=settings, store=store, hub=hub, nut_monitor=nut_monitor, ctx = AppContext(settings=settings, store=store, hub=hub, nut_monitor=nut_monitor,
started_at=time.time(), certs=material) started_at=time.time(), certs=material, client=client)
return ctx, store, hub.start return ctx, store, hub.start
@@ -340,7 +340,7 @@ def create_app(settings: Settings | None = None, store: SampleStore | None = Non
settings.host_targets)) settings.host_targets))
ctx = AppContext(settings=settings, store=store, hub=hub, ctx = AppContext(settings=settings, store=store, hub=hub,
nut_monitor=nut_monitor, started_at=time.time(), nut_monitor=nut_monitor, started_at=time.time(),
certs=material) certs=material, client=client)
start = hub.start start = hub.start
else: else:
ctx, owned_store, start = build_context(settings, client) ctx, owned_store, start = build_context(settings, client)
+60 -2
View File
@@ -1,8 +1,9 @@
"""Collectors: the fetch half of each poll. Parse + store live in the Job wrapper. """Collectors: the fetch half of each poll. Parse + store live in the Job wrapper.
Every collector returns a `Result`; every failure is an exception the Job turns into a Every collector returns a `Result`; every failure is an exception the Job turns into a
red dot plus an event. Nothing here writes to any upstream — with one deliberate exception, red dot plus an event. Nothing here writes to any upstream — with **two** deliberate
`run_ups_command`, which is the app's only mutating path and is gated in three places. exceptions, and both are gated: `run_ups_command` (the UPS battery tests) and
`post_route` (turning one ingress route off or back on, phase 33 task 05).
""" """
from __future__ import annotations from __future__ import annotations
@@ -235,6 +236,63 @@ async def fetch_snapshot(client: httpx.AsyncClient, target: Target,
return body return body
async def post_route(client: httpx.AsyncClient, target: Target, body: Mapping[str, Any],
*, timeout: float | None = None,
tls: AgentTls | None = None) -> dict[str, Any]:
"""One `POST /route` to the agent — the app's second and last upstream write.
The dial is the snapshot dial, because it is the same web server: the per-dial
:class:`AgentTls` client, the revocation check **before any socket opens** (a host the
operator struck out is not a host a write may touch, and the refusal costs no network
round trip), and the **redacted** URL in every message, because an `UpstreamError` string
lands in the event table and the browser reads it (invariant 6). There is one deliberate
difference from `fetch_snapshot`, and it is the point: **`Authorization` is never sent on
the certificate path** — the bundle is the credential there, and a token alongside it is a
second opinion the agent's write handler refuses; the token is sent only on the plain
token path, where a 401 from it is about the token. The response is the helper's own
object, **verbatim** — `applied: false` comes back `false`, no success is invented (rule
2), and a non-2xx answer is an :class:`UpstreamError` carrying the agent's own word.
"""
if tls is not None and tls.revoked:
raise UpstreamError(f"{target.name}: agent certificate revoked")
request_timeout: Any = httpx.USE_CLIENT_DEFAULT if timeout is None else timeout
url = f"{target.url}/route"
shown = f"{target.display_url}/route"
secure = tls if (tls is not None and target.url.startswith(HTTPS_SCHEME)) else None
# The certificate path sends no `Authorization`: a token next to a certificate is the
# second opinion the write refuses. The token path sends the header exactly as the read
# does — and the agent's answer to a token-mode write is its own, carried by `refusal_detail`.
headers: dict[str, str] = {} if secure is not None else dict(target.headers)
try:
if secure is not None:
async with secure.client(shared=client) as scoped:
response = await scoped.post(url, json=dict(body), headers=headers,
timeout=request_timeout)
else:
response = await client.post(url, json=dict(body), headers=headers,
timeout=request_timeout)
except httpx.TimeoutException as exc:
raise UpstreamError(f"{target.name}: {shown} timed out", status=504) from exc
except (httpx.HTTPError, OSError) as exc:
raise UpstreamError(f"{target.name}: {shown} unreachable ({type(exc).__name__})") from exc
if response.status_code == 401:
raise UpstreamError(f"{target.name}: agent rejected the token (401) — check the "
f"userinfo token in HOST_TARGETS")
if response.status_code >= 400:
# a plain 403 here is the agent's own write refusal ("writes require a certificate",
# "writes are not enabled on this agent"): its word is the answer, carried verbatim
raise UpstreamError(f"{target.name}: {shown} -> HTTP {response.status_code} "
f"({refusal_detail(response)})")
try:
answer = response.json()
except ValueError as exc:
raise UpstreamError(f"{target.name}: route helper answered something unreadable "
f"({response.text[:80]!r})") from exc
if not isinstance(answer, dict):
raise UpstreamError(f"{target.name}: route helper answered a non-object")
return answer
async def fetch_host_snapshot(client: httpx.AsyncClient, target: Target, async def fetch_host_snapshot(client: httpx.AsyncClient, target: Target,
host_collector: HostCollector, host_collector: HostCollector,
*, tls: AgentTls | None = None) -> Result: *, tls: AgentTls | None = None) -> Result:
+18 -4
View File
@@ -203,8 +203,21 @@ class Settings:
# plain agent fetch's (the snapshot handler does no work for these sections beyond reading # plain agent fetch's (the snapshot handler does no work for these sections beyond reading
# two configs and two /proc tables), with headroom because the same handler may be mid- # two configs and two /proc tables), with headroom because the same handler may be mid-
# smartctl pass for a different page. # smartctl pass for a different page.
gateway_interval: float = _num("GATEWAY_INTERVAL_SECONDS", 30) gateway_interval: float = _num("GATEWAY_INTERVAL_SECONDS", 30.0)
gateway_timeout: float = _num("GATEWAY_TIMEOUT_SECONDS", 10) gateway_timeout: float = _num("GATEWAY_TIMEOUT_SECONDS", 10.0)
# the second and last upstream write (phase 33 task 05): turning one ingress route
# off or back on. Off by default, and its **own** flag rather than reusing
# `NUT_ALLOW_ACTIONS`: a deployment that may test a UPS battery is not one that may
# cut the fleet's only ingress, so the two writes are two independent decisions, each
# gated by its own sentence at its own endpoint (invariant 1). Same truthy rule as
# `NUT_ALLOW_ACTIONS`.
gateway_allow_writes: bool = _flag("GATEWAY_ALLOW_WRITES")
# the write's budget, on purpose a different number from `gateway_timeout`: the dial
# has to fit the agent's own 30 s peer timeout **inside** a helper that re-reads the
# file, runs the proxy's own checker and reloads one unit, so 35 s makes the
# dashboard wait for the helper's own answer instead of timing out first and leaving
# the operator wondering whether the reload happened
gateway_write_timeout: float = _num("GATEWAY_WRITE_TIMEOUT_SECONDS", 35.0)
# --- llm dashboard ------------------------------------------------------------ # --- llm dashboard ------------------------------------------------------------
gufo_url: str = _base_url("GUFO_BASE_URL") gufo_url: str = _base_url("GUFO_BASE_URL")
@@ -228,8 +241,9 @@ class Settings:
nut_password: str = _env("NUT_PASSWORD") nut_password: str = _env("NUT_PASSWORD")
nut_interval: float = _num("NUT_INTERVAL_SECONDS", 5) nut_interval: float = _num("NUT_INTERVAL_SECONDS", 5)
nut_timeout: float = _num("NUT_TIMEOUT_SECONDS", 4) nut_timeout: float = _num("NUT_TIMEOUT_SECONDS", 4)
# Instant commands (battery tests, beeper, calibration) are the only write path in # Instant commands (battery tests, beeper, calibration) are the only write path to a
# the app. Off unless BOTH the env flag is set and DASH_READ_ONLY is not. # **NUT** — the app's second upstream write is the gateway route switch, a different
# flag, a different dial. Off unless BOTH the env flag is set and DASH_READ_ONLY is not.
nut_allow_actions: bool = _flag("NUT_ALLOW_ACTIONS") nut_allow_actions: bool = _flag("NUT_ALLOW_ACTIONS")
# --- history ------------------------------------------------------------------ # --- history ------------------------------------------------------------------
+144 -26
View File
@@ -77,6 +77,20 @@ dialled). That is the same traffic seen from both sides, which is exactly what t
for — and it is why the loop `continue`s the moment a socket is counted: one row may never be for — and it is why the loop `continue`s the moment a socket is counted: one row may never be
drawn down two pipes. drawn down two pipes.
A line's `enabled` field (phase 33 task 04) is the route's **config state**: `true` when the
config declares the route and it is on, `false` when the operator's own helper switched it off —
the `#off <name>` marker on the route line (haproxy `server`, nginx `proxy_pass`, an `upstream`
member, a `map` entry), read by the agent and published as `disabled`, or the helper's sink
address in a `map` entry's value, which is the off state the helper left before the agent knew
markers. Three facts, never conflated: a route **never declared** is absent from the payload, a
route **declared and on** is an `enabled: true` line, a route **declared and off** is an
`enabled: false` line that is still drawn and still counted — the kernel does not know markers, so
a switched-off route with traffic in it is drawn as an off line carrying a number. An arm that
never read the config (the `stub`, the `predates` arm) publishes **no lines at all**, which is what
keeps `false` out of a payload nobody answered: a `false` is always a claim the config made. No
count is ever *derived* from `enabled` here: the join and the gauges read sockets, and the field
is the state the page prints and the write path targets.
The connection graph The connection graph
-------------------- --------------------
@@ -100,10 +114,16 @@ files express a route:
one connection per entry with a target, selector the entry's own key (the SNI domain, or the one connection per entry with a target, selector the entry's own key (the SNI domain, or the
literal `default`, which is the config's word for "everything nobody mapped", not a guess). literal `default`, which is the config's word for "everything nobody mapped", not a guess).
**Merge rule.** Identical `(source, in_port, host, port)` is **one** connection whose `selectors` **Merge rule.** Identical `(source, in_port, host, port, enabled)` is **one** connection whose
are the union in first-seen order — one vhost's two names arriving at one pool is one line, not `selectors` are the union in first-seen order — one vhost's two names arriving at one pool is one
two. Never merge across `in_port`: `:4443 → keycloak:8080` and `:8443 → keycloak:8080` are two line, not two. `enabled` is in the key on purpose: a claim to one endpoint that is on in one
pipes on two socket spaces, which is the `minecraft` 25565/25566 lesson told from the other side. config and off in another is **two lines** — one of these selectors is off is two facts, not one
row, and a silent union would print the switched-off route as live and the live one as off, at
once. The off line's id carries a `#off` suffix (on the line's `id`, where the page and the write
path both key on it), which is what makes the two halves of such a split distinct rows; the suffix
is the state, and the state is the only reason an id ever carries one. Never merge across
`in_port`: `:4443 → keycloak:8080` and `:8443 → keycloak:8080` are two pipes on two socket
spaces, which is the `minecraft` 25565/25566 lesson told from the other side.
**Two refusals**, both rule 2, both silent (a warning needs a payload that can tell broken from **Two refusals**, both rule 2, both silent (a warning needs a payload that can tell broken from
merely unreadable, and neither of these can): merely unreadable, and neither of these can):
@@ -131,17 +151,21 @@ What this module warns about
With everything outside the two config files ignored, the only warning left worth an operator's With everything outside the two config files ignored, the only warning left worth an operator's
attention is a config that **does not connect to itself**: a route naming a backend nobody attention is a config that **does not connect to itself**: a route naming a backend nobody
declares, a backend nothing routes to, a `proxy_pass $var` with no `map` behind it. `warnings()` declares, a backend nothing routes to, a `proxy_pass $var` with no `map` behind it. `warnings()`
answers the seven such kinds, each derived from config text alone and each actionable: answers the seven such kinds, each derived from config text alone and each actionable — and since
phase 33 task 04 two of them are **marker-aware**: a shape the operator's own switch caused (a
line carrying the helper's marker) is a state, not a fault, while the same shape with the line
hand-edited is still a fault, and the checks are written so that stays true without a second code
path:
| kind | condition | | kind | condition |
|------|-----------| |------|-----------|
| `haproxy_route_dangling` | a frontend's `default_backend`/`use_backend` names no `backend` block | | `haproxy_route_dangling` | a frontend's `default_backend`/`use_backend` names no `backend` block |
| `haproxy_backend_orphan` | a backend with `server` lines that no frontend routes to | | `haproxy_backend_orphan` | a backend with `server` lines that no frontend routes to |
| `haproxy_backend_empty` | a `backend` block with no `server` line at all | | `haproxy_backend_empty` | a `backend` block with no `server` line **in the document** at all — a line the helper switched off stays in the config, `disabled: true`, so it is not empty and gets no warning (its state is the line's `enabled: false`); this kind is the hand-deleted line |
| `nginx_server_unlistened` | a `server` block with names and/or `proxy_pass` and no `listen` | | `nginx_server_unlistened` | a `server` block with names and/or `proxy_pass` and no `listen` |
| `nginx_pool_undeclared` | `proxy_pass http://<name>` with no port and no `upstream` of that name | | `nginx_pool_undeclared` | `proxy_pass http://<name>` with no port and no `upstream` of that name |
| `nginx_variable_unmapped` | `proxy_pass $var` with no `map` block defining `$var` | | `nginx_variable_unmapped` | `proxy_pass $var` with no `map` block defining `$var` |
| `nginx_map_route_missing` | an entry of a **referenced** `map` whose target is null | | `nginx_map_route_missing` | a `map` entry's value is neither an address nor a variable — **except** a line the helper switched off (marked, or pointing at the helper's sink block), which is a state, not this fault; the hand-edited shape still reaches it |
**Three things it refuses to warn about**, each because the payload cannot tell broken from merely **Three things it refuses to warn about**, each because the payload cannot tell broken from merely
unreadable (and a warning is a claim, not a hunch): unreadable (and a warning is a claim, not a hunch):
@@ -444,12 +468,17 @@ def haproxy_frontends(section: Mapping[str, Any] | None) -> list[dict[str, Any]]
def haproxy_targets(section: Mapping[str, Any] | None) -> list[dict[str, Any]]: def haproxy_targets(section: Mapping[str, Any] | None) -> list[dict[str, Any]]:
"""Every `server` line's `{name, host, port, backend}` — the outbound half of a haproxy config. """Every `server` line's `{name, host, port, backend, disabled}` — the outbound half of a
haproxy config.
`backend` is the name of the `backend` block the `server` was declared in, and it is the only `backend` is the name of the `backend` block the `server` was declared in, and it is the only
reason the join in `config_connections()` is possible at all: the frontend side speaks backend reason the join in `config_connections()` is possible at all: the frontend side speaks backend
names (`default_backend` / `use_backend`) and the endpoint side speaks hosts, so without the names (`default_backend` / `use_backend`) and the endpoint side speaks hosts, so without the
declaring block's name a `server` line is an endpoint nobody routes to. declaring block's name a `server` line is an endpoint nobody routes to. `disabled` is the
agent's read of the helper's marker on the line (phase 33): `true` when the operator
switched the route off and the line stayed in the document, `false` otherwise, `True`
**never** for a line the agent did not report the flag on (an older agent's rows stay on —
absence of the flag is "not switched off", not "broken").
""" """
rows: list[dict[str, Any]] = [] rows: list[dict[str, Any]] = []
for backend in _mappings((section or {}).get("backends")): for backend in _mappings((section or {}).get("backends")):
@@ -459,7 +488,8 @@ def haproxy_targets(section: Mapping[str, Any] | None) -> list[dict[str, Any]]:
if not host: if not host:
continue continue
rows.append({"name": _text(server.get("name")), "host": host, rows.append({"name": _text(server.get("name")), "host": host,
"port": _port(server.get("port")), "backend": declared_in}) "port": _port(server.get("port")), "backend": declared_in,
"disabled": server.get("disabled") is True})
return rows return rows
@@ -560,6 +590,59 @@ def _maps_by_variable(section: Mapping[str, Any] | None) -> dict[str, list[Mappi
return found return found
def _loopback(value: str) -> bool:
"""Is this listen address the machine's own loopback (`127.0.0.0/8` or `::1`)?
`ipaddress` parses text and never dials, the `_is_ip_literal` rule; a name is not a
loopback, and a non-loopback literal is not one either.
"""
try:
return ipaddress.ip_address(value).is_loopback
except ValueError:
return False
def sink_endpoint(nginx: Mapping[str, Any] | None) -> tuple[str, int] | None:
"""The helper's catch-all stream server, recognised from this payload's own text.
The root helper's `off` state for a route it switched is "the entry's value is the sink
address" — a `server` block that listens on loopback, carries **no** `server_names` and
proxies **nowhere** (its whole job is to answer the switched-off route with a 502). The
block, not a port constant, is what this looks for: the port is the helper's to choose, and
a vhost that legitimately `proxy_pass`es to `127.0.0.1:<something>` is not a switch. `None`
when the config has no such block, which is every config the helper has not run on.
"""
for server in _mappings((nginx or {}).get("servers")):
if _names(server.get("server_names")):
continue
if _mappings(server.get("proxy_passes")):
continue
for listen in _mappings(server.get("listen")):
addr = _text(listen.get("addr"))
if addr is not None and _loopback(addr):
port = _port(listen.get("port"))
if port is not None:
return addr, port
return None
def _map_entry_off(entry: Mapping[str, Any], sink: tuple[str, int] | None) -> bool:
"""Did this map entry's own line say the route is off?
The agent's `disabled` flag (a line carrying the helper's marker, read by the agent's own
`split_marker`), or a target that **is** the helper's sink block — the state the helper left
before the agent knew markers. An address alone, without the block, is not off (the
`sink_endpoint` refusal, in one call): a map that legitimately lands on loopback is routing,
not a switch.
"""
if entry.get("disabled") is True:
return True
if sink is None:
return False
target = _target_of(entry.get("target"))
return target is not None and target == sink
def _pools(section: Mapping[str, Any] | None) -> dict[str, list[Mapping[str, Any]]]: def _pools(section: Mapping[str, Any] | None) -> dict[str, list[Mapping[str, Any]]]:
"""The `upstream` blocks by name, each mapped to its `server` rows, in config order.""" """The `upstream` blocks by name, each mapped to its `server` rows, in config order."""
found: dict[str, list[Mapping[str, Any]]] = {} found: dict[str, list[Mapping[str, Any]]] = {}
@@ -572,7 +655,7 @@ def _pools(section: Mapping[str, Any] | None) -> dict[str, list[Mapping[str, Any
def _connection(source: str, in_port: int, host: str, out_port: int | None, name: str | None, def _connection(source: str, in_port: int, host: str, out_port: int | None, name: str | None,
kind: str, selectors: Sequence[str]) -> dict[str, Any]: kind: str, selectors: Sequence[str], *, enabled: bool) -> dict[str, Any]:
"""One line of the graph: an inbound port, an outbound endpoint, and the config's word for it. """One line of the graph: an inbound port, an outbound endpoint, and the config's word for it.
Three of these fields are set later, and each says so by being `None` here: Three of these fields are set later, and each says so by being `None` here:
@@ -582,6 +665,16 @@ def _connection(source: str, in_port: int, host: str, out_port: int | None, name
number, so a reader never mistakes two lines for twice the traffic. number, so a reader never mistakes two lines for twice the traffic.
* `metric` — the gauge this line publishes. * `metric` — the gauge this line publishes.
`enabled` is the line's **config state** (phase 33 task 04): `true` = the config declares the
route and it is on, `false` = the config declares it and the operator switched it off (the
helper's marker on the line, or the helper's sink address in its value). The line is drawn
either way — "declared, off" is a different fact from "never declared" (rule 2) — and an arm
that never read the config publishes **no lines at all** (`stub` / the predates arm carry
`connections: []`), which is what keeps `false` out of an arm nobody answered: a `false` is
always a claim the config made. A merge that would mix states splits on this flag instead
(the merge rule in the module docstring), and the `#off` suffix on the id is what keeps the
two halves of such a split distinct rows when the page and the write path both key on it.
`apply_resolution` then widens all three on one question: can the kernel name this line's far `apply_resolution` then widens all three on one question: can the kernel name this line's far
end? An address the config wrote can, and so can a name the gateway resolved on the box that end? An address the config wrote can, and so can a name the gateway resolved on the box that
dials it. A name nobody resolved never can, and keeps `connections: null`, `metric: null` and dials it. A name nobody resolved never can, and keeps `connections: null`, `metric: null` and
@@ -593,8 +686,9 @@ def _connection(source: str, in_port: int, host: str, out_port: int | None, name
address = _is_ip_literal(host) address = _is_ip_literal(host)
joinable = address and isinstance(out_port, int) and not isinstance(out_port, bool) joinable = address and isinstance(out_port, int) and not isinstance(out_port, bool)
return { return {
"id": f"{source}:{in_port}->{suffix}", "id": f"{source}:{in_port}->{suffix}" + ("" if enabled else "#off"),
"source": source, "source": source,
"enabled": enabled,
"in": {"port": in_port}, "in": {"port": in_port},
"out": {"host": host, "port": out_port, "name": name, "kind": kind, "out": {"host": host, "port": out_port, "name": name, "kind": kind,
"selectors": list(selectors), "address": address}, "selectors": list(selectors), "address": address},
@@ -609,19 +703,27 @@ def config_connections(haproxy: Mapping[str, Any] | None,
"""Every connection the two configs declare, in config order. Pure, clock-free, resolver-free. """Every connection the two configs declare, in config order. Pure, clock-free, resolver-free.
The four derivations, the merge rule and the two refusals are stated in the module docstring The four derivations, the merge rule and the two refusals are stated in the module docstring
("The connection graph"); this is that text made executable. What it deliberately does **not** ("The connection graph"); this is that text made executable, with the phase-33 `enabled`
do is join a socket: a connection says the config routes here, which is a fact readable from state on every line (the flag read of the helper's marker, per arm: the `server` line's own
a file, and never that a connection is open now, which is not. `disabled` for haproxy and the pool members, the `proxy_pass` line's own for a vhost, and
`_map_entry_off` — the flag **or** the sink block — for a map entry). What it deliberately
does **not** do is join a socket: a connection says the config routes here, which is a fact
readable from a file, and never that a connection is open now, which is not.
""" """
claims: dict[tuple[str, int, str, int | None], dict[str, Any]] = {} claims: dict[tuple[str, int, str, int | None, bool], dict[str, Any]] = {}
def claim(source: str, in_port: int, host: str, out_port: int | None, *, name: str | None, def claim(source: str, in_port: int, host: str, out_port: int | None, *, name: str | None,
kind: str, selectors: Sequence[str]) -> None: kind: str, selectors: Sequence[str], enabled: bool) -> None:
"""One line per `(source, in_port, host, out_port)`; selectors union, first-seen order.""" """One line per `(source, in_port, host, out_port, enabled)`; selectors union, first-seen
key = (source, in_port, host, out_port) order. `enabled` is in the key on purpose: two claims to one endpoint, one on and one off,
are two facts (the module docstring's merge rule), so the merge splits into one line per
state rather than unioning a `false` into an `on` line — a silent union would print the
switched-off route as live and the live one as off, at once."""
key = (source, in_port, host, out_port, enabled)
seen = claims.get(key) seen = claims.get(key)
if seen is None: if seen is None:
claims[key] = _connection(source, in_port, host, out_port, name, kind, selectors) claims[key] = _connection(source, in_port, host, out_port, name, kind, selectors,
enabled=enabled)
return return
merged = seen["out"]["selectors"] merged = seen["out"]["selectors"]
for selector in selectors: for selector in selectors:
@@ -641,8 +743,10 @@ def config_connections(haproxy: Mapping[str, Any] | None,
for route in frontend["routes"]: for route in frontend["routes"]:
for target in servers_by_backend.get(route, ()): for target in servers_by_backend.get(route, ()):
claim("haproxy", int(frontend["port"]), target["host"], target["port"], claim("haproxy", int(frontend["port"]), target["host"], target["port"],
name=target["name"], kind=KIND_BACKEND, selectors=(route,)) name=target["name"], kind=KIND_BACKEND, selectors=(route,),
enabled=not target["disabled"])
sink = sink_endpoint(nginx)
maps = _maps_by_variable(nginx) maps = _maps_by_variable(nginx)
pools = _pools(nginx) pools = _pools(nginx)
for block in nginx_server_blocks(nginx): for block in nginx_server_blocks(nginx):
@@ -658,16 +762,19 @@ def config_connections(haproxy: Mapping[str, Any] | None,
continue continue
for port in block["ports"]: for port in block["ports"]:
claim("nginx", port, target[0], target[1], name=None, kind=KIND_MAP, claim("nginx", port, target[0], target[1], name=None, kind=KIND_MAP,
selectors=(key,)) selectors=(key,), enabled=not _map_entry_off(entry, sink))
continue continue
target = _target_of(proxy.get("target")) target = _target_of(proxy.get("target"))
if target is None: if target is None:
continue # refusal: neither a target nor a variable names an endpoint continue # refusal: neither a target nor a variable names an endpoint
# the `proxy_pass` line's own flag: a marked line stays in the document (the agent
# reports it `disabled: true`), so this is the arm's state, not the block's
proxy_enabled = proxy.get("disabled") is not True
if target[1] is not None: if target[1] is not None:
# rule 2: a concrete `host:port`, one line per listen port of this block # rule 2: a concrete `host:port`, one line per listen port of this block
for port in block["ports"]: for port in block["ports"]:
claim("nginx", port, target[0], target[1], name=None, claim("nginx", port, target[0], target[1], name=None,
kind=KIND_SERVER_NAME, selectors=block["names"]) kind=KIND_SERVER_NAME, selectors=block["names"], enabled=proxy_enabled)
continue continue
# rule 3: a name with no port is an `upstream` block, and no block of that name is # rule 3: a name with no port is an `upstream` block, and no block of that name is
# no endpoint — the payload cannot say where the traffic lands, so it claims nothing # no endpoint — the payload cannot say where the traffic lands, so it claims nothing
@@ -678,7 +785,8 @@ def config_connections(haproxy: Mapping[str, Any] | None,
continue continue
for port in block["ports"]: for port in block["ports"]:
claim("nginx", port, host, _port(member.get("port")), name=None, claim("nginx", port, host, _port(member.get("port")), name=None,
kind=KIND_POOL, selectors=(target[0],)) kind=KIND_POOL, selectors=(target[0],),
enabled=proxy_enabled and member.get("disabled") is not True)
return list(claims.values()) return list(claims.values())
@@ -757,8 +865,12 @@ def haproxy_warnings(haproxy: Mapping[str, Any] | None) -> list[dict[str, str]]:
continue continue
servers = _mappings(backend.get("servers")) servers = _mappings(backend.get("servers"))
if not servers: if not servers:
# an empty backend routes nowhere whichever frontend names it, so this is the only # `servers` is the lines **in the document**, and that is the whole of the marker
# thing said about it: `haproxy_backend_orphan` needs a server to have anywhere to go # decision (phase 33 task 04): the helper's marker leaves a switched-off line in the
# config and the agent reports it `disabled: true`, so a backend whose only server
# the operator turned off is *not* empty here and gets no warning — its state is the
# line's `enabled: false`. This arm is the **hand-deleted** line: the config no
# longer says anything, and that is the fault.
found.append(_warning( found.append(_warning(
WARN_BACKEND_EMPTY, name, WARN_BACKEND_EMPTY, name,
f'backend "{name}" declares no server — it routes nowhere')) f'backend "{name}" declares no server — it routes nowhere'))
@@ -844,6 +956,12 @@ def nginx_warnings(nginx: Mapping[str, Any] | None) -> list[dict[str, str]]:
continue # refusal: an unreferenced map is not a routing table continue # refusal: an unreferenced map is not a routing table
bare = variable.lstrip("$") bare = variable.lstrip("$")
for entry in _mappings(block.get("entries")): for entry in _mappings(block.get("entries")):
# a line carrying the helper's marker is the state the operator wrote, not a fault
# (phase 33 task 04): its value is the sink address or nothing at all, and both are
# the switch. The same shape with the line hand-edited (value deleted, no marker)
# falls through to the check below and still warns.
if entry.get("disabled") is True:
continue
if isinstance(entry.get("target"), Mapping): if isinstance(entry.get("target"), Mapping):
continue continue
key = entry.get("key") key = entry.get("key")
+2 -1
View File
@@ -737,7 +737,8 @@ def _duration(seconds: float) -> str:
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# Instant commands (the app's only write path) # Instant commands (the app's only write to a NUT; the gateway route switch is the other
# upstream write, on its own flag)
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
RISK_SAFE = "safe" RISK_SAFE = "safe"
RISK_CAUTION = "caution" RISK_CAUTION = "caution"
+29 -2
View File
@@ -198,8 +198,9 @@
here is coloured by a verdict: a busy ingress is the job.</p> here is coloured by a verdict: a busy ingress is the job.</p>
<p class="faint">a dash means the gateway could not resolve that name in this reading — hover a <p class="faint">a dash means the gateway could not resolve that name in this reading — hover a
counted line for the address it matched and when that answer was taken.</p> counted line for the address it matched and when that answer was taken.</p>
<p class="faint">the switch shows what the config says; toggling a route needs a config write <p class="faint">the switch shows what the config says; with `GATEWAY_ALLOW_WRITES=1` set on
path and is not wired up yet.</p> the dashboard, activating it switches that route in its config file — otherwise it is a
reading that does nothing, and its title says so.</p>
<p class="faint">the filter box hides the routes whose own labels do not contain what you typed — <p class="faint">the filter box hides the routes whose own labels do not contain what you typed —
domain, <code>server_name</code>, <code>map</code> key, endpoint or port. It asks for nothing: domain, <code>server_name</code>, <code>map</code> key, endpoint or port. It asks for nothing:
the tallies, the warnings and the charts above it are still the whole ingress, not the filtered the tallies, the warnings and the charts above it are still the whole ingress, not the filtered
@@ -207,6 +208,32 @@
</footer> </footer>
</main> </main>
<!-- The switch's confirm-and-apply flow (phase 33 task 06). Every slot the script paints carries
`data-k`; the id the script reads is the dialog's own. The one `<pre>` is the route's diff,
the one labelled input asks for the word `DANGER` (both verbs — either one can cut a route
through the fleet's only ingress), and the alert line is the server's word, printed verbatim
and never re-derived. -->
<dialog id="gate-switch-dialog" aria-labelledby="gate-switch-title">
<header>
<span class="chip">danger</span>
<h2 id="gate-switch-title">Switch this route?</h2>
</header>
<div class="body">
<p class="mono" style="font-size:12px" data-k="route"></p>
<p class="faint" data-k="selectors"></p>
<p class="faint" data-k="hint"></p>
<pre class="mono" data-k="diff"></pre>
<p class="faint" data-k="confirm-wrap">Type <strong>DANGER</strong> to apply:</p>
<p data-k="input-wrap"><input class="confirm" data-k="input" type="text"
autocomplete="off" spellcheck="false" placeholder="DANGER"></p>
<p class="faint" data-k="alert" hidden></p>
</div>
<footer>
<button class="ghost" type="button" data-k="cancel">Cancel</button>
<button class="action" type="button" data-k="send">Apply</button>
</footer>
</dialog>
<script src="/static/charts.js"></script> <script src="/static/charts.js"></script>
<script src="/static/app.js"></script> <script src="/static/app.js"></script>
<script src="/static/page-gateway.js"></script> <script src="/static/page-gateway.js"></script>
+30 -9
View File
@@ -1403,19 +1403,16 @@ table.data td.san { white-space: normal; }
.gate-link.on .gate-elbow { border-top-color: var(--gate-on); } .gate-link.on .gate-elbow { border-top-color: var(--gate-on); }
.gate-link.off .gate-elbow { border-top-color: var(--gate-off); } .gate-link.off .gate-elbow { border-top-color: var(--gate-off); }
.gate-switch-slot { display: inline-flex; align-items: center; flex: none; } .gate-switch-slot { display: inline-flex; align-items: center; flex: none; }
/* The switch is a **reading** shaped like a control, and it is the shape the operator sketched: a /* The switch is a pill with a knob, green when the route is in the config — and in two arms
* pill with a knob, green when the route is in the config. It is keyed on `[aria-checked]` and * (phase 33 task 06). It is keyed on `[aria-checked]` and **never** on `:enabled`, because a
* **never** on `:enabled`, because a switch this app cannot wire (invariant 1: nothing here writes * switch that is on must still look green while it is on in both arms — styling `:enabled`
* to an upstream) must still look green while it is on — styling `:enabled` would paint every one * would paint every one of them red by accident. `aria-disabled="true"` rather than `disabled`,
* of them red by accident. `cursor: not-allowed` says what a hover would otherwise promise, and * which would drop a real `<button>` out of the a11y tree and make a reading invisible to the
* there is no `:hover` rule and no listener anywhere in `page-gateway.js`. `aria-disabled="true"` * people who most need the sentence. */
* rather than `disabled`, which would drop a real `<button>` out of the a11y tree and make a
* reading invisible to the people who most need the sentence. */
.gate-switch { .gate-switch {
width: 34px; height: 18px; padding: 2px; border-radius: 999px; flex: none; width: 34px; height: 18px; padding: 2px; border-radius: 999px; flex: none;
border: 1px solid var(--gate-on); display: inline-flex; align-items: center; border: 1px solid var(--gate-on); display: inline-flex; align-items: center;
background: color-mix(in srgb, var(--gate-on) 14%, transparent); background: color-mix(in srgb, var(--gate-on) 14%, transparent);
cursor: not-allowed;
} }
.gate-switch-thumb { .gate-switch-thumb {
width: 12px; height: 12px; border-radius: 50%; background: var(--gate-on); width: 12px; height: 12px; border-radius: 50%; background: var(--gate-on);
@@ -1424,6 +1421,18 @@ table.data td.san { white-space: normal; }
.gate-switch[aria-checked="false"] { border-color: var(--gate-off); .gate-switch[aria-checked="false"] { border-color: var(--gate-off);
background: color-mix(in srgb, var(--gate-off) 14%, transparent); } background: color-mix(in srgb, var(--gate-off) 14%, transparent); }
.gate-switch[aria-checked="false"] .gate-switch-thumb { transform: translateX(0); } .gate-switch[aria-checked="false"] .gate-switch-thumb { transform: translateX(0); }
/* The two arms, on purpose: the writes-off arm is a reading shaped like a control — the pointer
* says `not-allowed` because a hover promises nothing and a click does nothing. The writes-on arm
* is a real control: focusable, and its Enter/Space press fires the one `click` listener. Neither
* arm keys on `:enabled`/`:disabled`, and neither arm grows a `:hover` rule (phase 33 task 06). */
.gate-switch[aria-disabled="true"] { cursor: not-allowed; }
.gate-switch:not([aria-disabled]) { cursor: pointer; }
/* The map's one hover (phase 33 task 06): the writes-on arm's, and under the
* `:not([aria-disabled])` selector only — the off arm's hover would promise a click it
* never honours, and the connection lines stay hoverless. An instant tint: the knob's
* `.16s` slide is the switch's only motion, and the reduced-motion block already stills
* it by name. */
button.gate-switch:not([aria-disabled="true"]):hover { background: var(--bg-sunken); }
.gate-target { display: grid; min-width: 0; padding-inline-start: .35rem; } .gate-target { display: grid; min-width: 0; padding-inline-start: .35rem; }
/* The trunk's domain count (`16 names`, with the whole list on `title`) — the page's smallest line, /* The trunk's domain count (`16 names`, with the whole list on `title`) — the page's smallest line,
* and the one the leaf no longer uses: a leaf's qualifier became its primary line (phase 31 task 02). * and the one the leaf no longer uses: a leaf's qualifier became its primary line (phase 31 task 02).
@@ -1558,6 +1567,18 @@ dialog h2 { margin: 0; font-size: 1rem; }
dialog > header { padding: .8rem .9rem; border-bottom: 1px solid var(--border); display: flex; gap: .5rem; align-items: center; } dialog > header { padding: .8rem .9rem; border-bottom: 1px solid var(--border); display: flex; gap: .5rem; align-items: center; }
dialog .body { padding: .9rem; font-size: 13px; color: var(--text-muted); } dialog .body { padding: .9rem; font-size: 13px; color: var(--text-muted); }
dialog footer { padding: .7rem .9rem; border-top: 1px solid var(--border); display: flex; gap: .5rem; justify-content: flex-end; } dialog footer { padding: .7rem .9rem; border-top: 1px solid var(--border); display: flex; gap: .5rem; justify-content: flex-end; }
/* The route switch's confirm dialog (phase 33 task 06): the diff is the one block that
* scrolls in its own box — `overflow: auto`, `max-height` bounded, so a long diff never
* scrolls the document (the phase-27 rule) — and its two tints derive from the existence
* pair only: a `+` line reads as the same green an in-config line does, a `-` line as its
* red, and no verdict colour enters the map's vocabulary. */
#gate-switch-dialog pre {
margin: .6rem 0 0; padding: .5rem .6rem; overflow: auto; max-height: 9rem;
font-family: var(--mono); font-size: 12px; white-space: pre;
background: var(--bg-sunken); border: 1px solid var(--border); border-radius: var(--radius);
}
#gate-switch-dialog pre .diff-del { color: var(--gate-off); }
#gate-switch-dialog pre .diff-add { color: var(--gate-on); }
/* ---- misc --------------------------------------------------------------- */ /* ---- misc --------------------------------------------------------------- */
.muted { color: var(--text-muted); } .muted { color: var(--text-muted); }
+215 -23
View File
@@ -40,9 +40,12 @@
the `n/a` a trunk prints when the socket table itself went quiet. the `n/a` a trunk prints when the socket table itself went quiet.
The switch on each line says what the config says (in the config = on, and every line the payload The switch on each line says what the config says (in the config = on, and every line the payload
sends is on). It is a real `role="switch"` button and it is not wired: toggling a route needs a sends is on). It is a real `role="switch"` button in the two arms `gateway.writes_enabled`
config write path this app does not have, by invariant 1, so it carries `aria-disabled="true"` and a selects (see `buildSwitch`): with `GATEWAY_ALLOW_WRITES` off it is a reading shaped like a
`title` that says so, and no listener is bound to it anywhere in this file. control — `aria-disabled`, the flag's own sentence, no listener — and with the flag on it is
the one write this app may make (dry run, typed `DANGER`, one dial, the answer's paint).
A click never paints a verdict of its own: the row's colour is the payload's word from the
next gateway poll.
Charts follow the `/system` discipline: one collapsed `<details>` per card, painted only while Charts follow the `/system` discipline: one collapsed `<details>` per card, painted only while
open, fed by **one** `/api/gateway/history` request per card per refresh naming every gauge that open, fed by **one** `/api/gateway/history` request per card per refresh naming every gauge that
@@ -73,6 +76,10 @@ const state = {
history: new Map(), // card key -> { range, response } from the last snapshot pass history: new Map(), // card key -> { range, response } from the last snapshot pass
snapshot: null, // last payload, so a toggle paints without fetching snapshot: null, // last payload, so a toggle paints without fetching
row: null, // the `gateway` job's own health row, same reason row: null, // the `gateway` job's own health row, same reason
writesEnabled: false, // `gateway.writes_enabled` from /api/config — never guessed here
optimistic: null, // the one state a click may create: { id, checked } while the
// payload catches up after an `applied: true` (never a row class)
switching: null, // the in-flight dialog flow, if any: { id, action }
filter: '', // the filter field, verbatim and trimmed; '' means "no filter" filter: '', // the filter field, verbatim and trimmed; '' means "no filter"
poller: null, poller: null,
/* Half the job's interval: an age stamp cannot sit at "updated 29s" for a whole cycle, and /* Half the job's interval: an age stamp cannot sit at "updated 29s" for a whole cycle, and
@@ -80,6 +87,12 @@ const state = {
pollMs: 15000, pollMs: 15000,
}; };
// A leaf's `<li>` → the payload connection last painted on it. `buildSwitch()` is bound once
// (the row is patched in place by `renderLeaf`), so the one `click` listener reaches the
// *current* connection through the row: a row's id is stable, and its `conn` is the one its
// switch is switching. Weak, because a pruned row is pruned from the map too.
const rowConns = new WeakMap();
// ---- the panels ------------------------------------------------------------- // ---- the panels -------------------------------------------------------------
// //
// One panel per config file, keyed on the payload's own `source` word. That keying is not a layout // One panel per config file, keyed on the payload's own `source` word. That keying is not a layout
@@ -369,36 +382,199 @@ function buildPipe(trunk = false) {
* config (invariant 1) it cannot honestly say `off` about a route it has just read from a file. The * config (invariant 1) it cannot honestly say `off` about a route it has just read from a file. The
* `off` styling exists so that day is a one-line change and not a redesign. * `off` styling exists so that day is a one-line change and not a redesign.
*/ */
function routeInConfig() { function routeInConfig(conn) {
return true; // Phase 33 task 04: the `enabled` key is the config's own word, carried verbatim (the `#off`
// marker on the route's line). `false` is a **reachable** state — the route is declared in
// the config and the operator switched it off — so the off arm is a real reading, never an
// error. `undefined` (a pre-1.4.0 agent, or the stub arm) is still "on", as before: the marker
// simply is not there, and an old agent never sends `enabled: false`, so a page reading one
// cannot go red.
return conn.enabled !== false;
}
/** The writes-off title: `api.py`'s own `GATEWAY_ALLOW_WRITES` refusal, copied verbatim so the
* switch and the route it would call say the same sentence (`tests/test_api.py` reads both
* files and compares; a drift is an operator following one sentence into a refusal written
* by the other). */
const WRITES_OFF_TITLE = 'route switching is disabled — set GATEWAY_ALLOW_WRITES=1 to enable it';
/**
* The one state a click may create: after an `applied: true`, the switch's **own**
* `aria-checked` flips immediately and holds until the payload catches up. The row's on/off
* word, class and spine stay the payload's ("no optimistic colour, ever"), and the flip is
* confirmed the moment the payload's own word agrees with it. The id is matched on both
* spellings because a disabled row gains the `#off` suffix (phase 33 task 04), and the
* confirm-and-clear is what stops a stale flip from surviving a payload that never changed.
*/
function optimisticFor(conn) {
const optimistic = state.optimistic;
if (!optimistic) return null;
const same = conn.id === optimistic.id || conn.id === `${optimistic.id}#off`
|| conn.id === optimistic.id.replace(/#off$/, '');
if (!same) return null;
if (optimistic.checked === routeInConfig(conn)) state.optimistic = null; // it caught up
return optimistic.checked;
} }
/** /**
* The switch: the pill from the operator's sketch, and nothing it cannot do. * The leaf's switch, in the two arms `gateway.writes_enabled` selects (phase 33 task 06).
* The flag is read from `/api/config` at boot — never inferred, because the off arm is the
* honest one and a guess would style a decision the server made. `aria-checked` and the
* accessible name are written by `renderLeaf` (the optimistic flip lives there too), so the
* switch says what its row says rather than what a static attribute guessed.
* *
* A real `<button role="switch">` with a real accessible name, because "the config has this route" * **Writes off** — the shape this page shipped with: a reading shaped like a control.
* is a reading the page is allowed to make and a toggle-shaped `<span>` would hide it. It is * `aria-disabled="true"` (not the bare `disabled` attribute, which would drop the reading out
* **drawn, not wired**: this app writes to no upstream (invariant 1), so a route is turned off by * of the a11y tree) keeps the title reachable, and `cursor: not-allowed` is the stylesheet's,
* editing `haproxy.cfg` or an nginx `server` block and by nothing in a browser. Hence * on the same selector. No listener is attached: a hover promises nothing, a click does
* `aria-disabled="true"` — which keeps it in the a11y tree, where the sentence lives, unlike * nothing.
* `disabled`, which would take that reading away from the people who need it most — a `title` that *
* names the missing write path instead of promising a click, and **no listener anywhere in this * **Writes on** — a real control. No `aria-disabled`, no `not-allowed`: a `<button>` is
* file** (`test_the_switch_is_built_and_explicitly_unwired` fails the day that changes). * focusable and its Enter/Space press fires the one `click` listener, whose flow is the
* `aria-checked` is written by `renderLeaf`, so the switch says what its row says rather than what a * server's (dry run, typed `DANGER`, one dial, the answer's paint). Nothing here decides
* static attribute guessed. * what the write is: the route's ten gates do.
*/ */
function buildSwitch() { function buildSwitch() {
const button = document.createElement('button'); const button = document.createElement('button');
button.className = 'gate-switch'; button.className = 'gate-switch';
button.type = 'button'; button.type = 'button';
button.setAttribute('role', 'switch'); button.setAttribute('role', 'switch');
button.setAttribute('aria-disabled', 'true');
button.title = 'Toggling a route is not wired up yet — it needs a config write path. '
+ 'This switch shows what the config says.';
const thumb = document.createElement('span'); const thumb = document.createElement('span');
thumb.className = 'gate-switch-thumb'; thumb.className = 'gate-switch-thumb';
button.appendChild(thumb); button.appendChild(thumb);
if (!state.writesEnabled) {
button.setAttribute('aria-disabled', 'true');
button.title = WRITES_OFF_TITLE;
return button; return button;
}
button.title = 'Activation edits this route in its config file, validates the file with the'
+ " proxy's own checker and reloads that proxy — nothing is restarted.";
button.addEventListener('click', () => {
const li = button.closest('li');
if (li) startSwitch(rowConns.get(li));
});
return button;
}
// ---- the switch's flow (phase 33 task 06) ----------------------------------
//
// One async handler per arm, and the ordering is the safety: (1) a dry run — the server's
// own word for what the change will do, before the operator is asked for the word;
// (2) the real `<dialog>`, carrying the row's inbound word and endpoint, the config's own
// selectors, a labelled `diff` in a `<pre>`, the route's `hint`, and the typed `DANGER`
// ask; (3) the send fires only when the typed word is exactly `DANGER`; (4) `aria-busy`
// for the one in-flight send; (5) the paint is the answer's — `applied: true` flips the
// switch's own `aria-checked` and says the page is re-reading, `applied: false` flips
// nothing and prints the helper's word verbatim; (6) one `refresh()`, so the row's own
// word is the payload's, not the click's.
function switchDialog() { return $('#gate-switch-dialog'); }
function slot(name) { return switchDialog().querySelector(`[data-k="${name}"]`); }
function printAlert(words) {
const alert = slot('alert');
alert.textContent = words.filter(Boolean).join(' ');
alert.hidden = alert.textContent === '';
}
async function startSwitch(conn) {
if (!conn || !state.writesEnabled || state.switching) return; // one flow at a time
const on = routeInConfig(conn);
const action = on ? 'disable' : 'enable';
let dry = null;
let refusal = null;
try {
dry = await fetchJSON('/api/gateway/route', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ id: conn.id, action, dry_run: true }),
});
} catch (err) {
refusal = err; // a gate speaking before the ask: print it, the flow stops at the dialog
}
openSwitchDialog(conn, action, dry, refusal);
}
function openSwitchDialog(conn, action, dry, refusal) {
const out = conn.out || {};
const dialog = switchDialog();
// The row's inbound word and endpoint, spelled the way the row spells them (line 1 → line 2).
slot('route').textContent = `${inboundText(out) || endpointText(out)} → ${endpointText(out)}`;
// The config's own selectors, verbatim and in full — the names the file wrote for this route.
slot('selectors').textContent = (out.selectors || []).join(', ') || '—';
// The diff, one span per line, each `textContent` — no byte of it is parsed — the `+`/`-`
// lines take the existence pair's tints (`.diff-add`/`.diff-del`, `--gate-on`/`--gate-off`)
// so the diff reads as the same red/green the rows do.
const diffEl = slot('diff');
diffEl.textContent = '';
if (dry && dry.diff) {
for (const line of String(dry.diff).split('\n')) {
const span = document.createElement('span');
span.textContent = line + '\n';
if (line.startsWith('+')) span.className = 'diff-add';
else if (line.startsWith('-')) span.className = 'diff-del';
diffEl.appendChild(span);
}
} else {
diffEl.textContent = '—';
}
slot('hint').textContent = dry && dry.hint ? dry.hint : '';
slot('input').value = '';
printAlert([]);
slot('send').disabled = false;
state.switching = { id: conn.id, action };
dialog.showModal();
slot('input').focus(); // the word is the operator's to type, and the field is where it goes
if (refusal) {
printAlert([refusal.message,
refusal.body && refusal.body.hint ? refusal.body.hint : '']);
}
}
async function sendSwitch() {
const pending = state.switching;
if (!pending) return;
const dialog = switchDialog();
const input = slot('input');
if (input.value.trim() !== 'DANGER') return; // the wrong word sends nothing
if (slot('send').disabled) return; // one in-flight send, never two
slot('send').disabled = true;
dialog.setAttribute('aria-busy', 'true');
try {
const answer = await fetchJSON('/api/gateway/route', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ id: pending.id, action: pending.action,
danger: 'DANGER', dry_run: false }),
});
if (answer.applied === true) {
// The only optimistic thing a click may create: the switch's own `aria-checked`, held
// in `state.optimistic` so a repaint before the payload catches up keeps it. `renderLeaf`
// applies it; the row's word, class and spine stay the payload's until then.
state.optimistic = { id: pending.id, checked: pending.action === 'enable' };
printAlert(['the config is applied — the page is re-reading']);
} else {
// `applied: false`: nothing flips. The helper's own word, printed verbatim
// (`textContent`, so no byte of it is parsed), with the route's sentence after it.
printAlert([typeof answer.refused === 'string' ? answer.refused : '',
typeof answer.error === 'string' ? answer.error : '',
answer.hint || '']);
input.focus();
}
} catch (err) {
// 429/409/403/422: the server's detail and the route's hint, in the alert; the dialog
// stays open and the focus returns to the confirm field.
printAlert([err.message, err.body && err.body.hint ? err.body.hint : '']);
input.focus();
}
dialog.removeAttribute('aria-busy');
state.switching = null;
await refresh(); // the row's word is the payload's from here on
}
function closeSwitchDialog() {
state.switching = null;
switchDialog().close();
} }
/** /**
@@ -521,12 +697,21 @@ function renderLeaf(li, conn) {
if (svg.getAttribute('aria-label') !== aria) svg.setAttribute('aria-label', aria); if (svg.getAttribute('aria-label') !== aria) svg.setAttribute('aria-label', aria);
// The switch is patched with the same `on` the line is coloured from, so a row can never show a // The switch is patched with the same `on` the line is coloured from, so a row can never show a
// green pipe beside an off switch. `aria-disabled` is static markup from `buildSwitch()` and is // green pipe beside an off switch — except the one early paint a click may make: after an
// never written here: it says "this control cannot be used", which no poll can change. // `applied: true`, `state.optimistic` holds the switch's own `aria-checked` until the payload
// catches up. The row's word, class and spine stay the payload's ("no optimistic colour, ever"),
// and `aria-disabled` is static markup from `buildSwitch()`, which no poll writes: in the
// writes-off arm it says "this control cannot be used", and in the writes-on arm it is absent.
rowConns.set(li, conn);
const sw = li.querySelector('.gate-switch'); const sw = li.querySelector('.gate-switch');
const checked = on ? 'true' : 'false'; const optimistic = state.writesEnabled ? optimisticFor(conn) : null;
const switchShown = optimistic !== null ? optimistic : on;
const checked = switchShown ? 'true' : 'false';
if (sw.getAttribute('aria-checked') !== checked) sw.setAttribute('aria-checked', checked); if (sw.getAttribute('aria-checked') !== checked) sw.setAttribute('aria-checked', checked);
const swAria = switchAria(conn, on); // The writes-on name is the same fact with the verb: what an activation does.
const swAria = state.writesEnabled
? `${switchAria(conn, on)}, ${on ? 'activate to switch it off' : 'activate to switch it on'}`
: switchAria(conn, on);
if (sw.getAttribute('aria-label') !== swAria) sw.setAttribute('aria-label', swAria); if (sw.getAttribute('aria-label') !== swAria) sw.setAttribute('aria-label', swAria);
} }
@@ -1188,12 +1373,19 @@ function pruneCards(visible) {
document.addEventListener('DOMContentLoaded', async () => { document.addEventListener('DOMContentLoaded', async () => {
state.cards = cardElements(); state.cards = cardElements();
initFilter(); initFilter();
const dialog = $('#gate-switch-dialog');
dialog.addEventListener('cancel', () => { state.switching = null; }); // Esc: the native close
dialog.querySelector('[data-k="cancel"]').addEventListener('click', closeSwitchDialog);
dialog.querySelector('[data-k="send"]').addEventListener('click', sendSwitch);
try { try {
const config = await fetchJSON('/api/config'); const config = await fetchJSON('/api/config');
const gateway = config.gateway || {}; const gateway = config.gateway || {};
if (Number.isFinite(gateway.interval_seconds) && gateway.interval_seconds > 0) { if (Number.isFinite(gateway.interval_seconds) && gateway.interval_seconds > 0) {
state.every = gateway.interval_seconds; state.every = gateway.interval_seconds;
} }
// The flag the switch's two arms read. Never inferred: a fetch failure leaves it false,
// which is the safe side of a wrong guess (the honest arm, not a control that dials).
state.writesEnabled = Boolean(gateway.writes_enabled);
if (gateway.enabled === false) { if (gateway.enabled === false) {
// The unconfigured arm before the first request: the setup block, no panels, and no poll // The unconfigured arm before the first request: the setup block, no panels, and no poll
// loop dialling a route that answers 404 forever (invariant 3). // loop dialling a route that answers 404 forever (invariant 3).
+14 -1
View File
@@ -116,6 +116,14 @@ const GATEWAY_FIELDS = [
* the unknown arm prints nothing rather than a number nobody measured. */ * the unknown arm prints nothing rather than a number nobody measured. */
const GATEWAY_WARNING_FIELD = { key: 'warnings', word: 'to address' }; const GATEWAY_WARNING_FIELD = { key: 'warnings', word: 'to address' };
/* The sixth figure keeps the fifth figure's exact rule, restated for the switch (phase 33):
* it prints only when non-zero, because a clean ingress has no switched-off route and a
* permanent ` · 0 switched off` is the same ink the empty-state rules avoid. The value is the
* block's `disabled` tally — the rows the config itself wrote off (the helper's marker, or the
* helper's sink in a map entry's value) — and a `null` (an ingress that never answered) is not
* `> 0`, so the unknown arm prints nothing rather than a number nobody measured. */
const GATEWAY_DISABLED_FIELD = { key: 'disabled', word: 'switched off' };
/** /**
* The ingress's one line under the fleet table: the ingress's own tallies, linked to the page * The ingress's one line under the fleet table: the ingress's own tallies, linked to the page
* that draws the map behind them. * that draws the map behind them.
@@ -147,9 +155,14 @@ function renderGateway(gateway) {
const warn = Number.isInteger(block[GATEWAY_WARNING_FIELD.key]) const warn = Number.isInteger(block[GATEWAY_WARNING_FIELD.key])
&& block[GATEWAY_WARNING_FIELD.key] > 0 && block[GATEWAY_WARNING_FIELD.key] > 0
? ` · ${block[GATEWAY_WARNING_FIELD.key]} ${GATEWAY_WARNING_FIELD.word}` : ''; ? ` · ${block[GATEWAY_WARNING_FIELD.key]} ${GATEWAY_WARNING_FIELD.word}` : '';
// the off-row tally, the same guard: `0` (nothing switched off) and `null` (nobody answered)
// both print nothing, so a clean ingress keeps the line at four figures
const off = Number.isInteger(block[GATEWAY_DISABLED_FIELD.key])
&& block[GATEWAY_DISABLED_FIELD.key] > 0
? ` · ${block[GATEWAY_DISABLED_FIELD.key]} ${GATEWAY_DISABLED_FIELD.word}` : '';
row.innerHTML = `<a href="${DashCharts.escapeHtml(String(block.href || ''))}">` row.innerHTML = `<a href="${DashCharts.escapeHtml(String(block.href || ''))}">`
+ `${DashCharts.escapeHtml(String(block.host || ''))}</a> ` + `${DashCharts.escapeHtml(String(block.host || ''))}</a> `
+ `${DashCharts.escapeHtml(counts + warn)}`; + `${DashCharts.escapeHtml(counts + warn + off)}`;
if (block.unreachable === true) row.title = `${block.host} is not answering`; if (block.unreachable === true) row.title = `${block.host} is not answering`;
} }
+2 -2
View File
@@ -42,7 +42,7 @@ def sign_in(client: TestClient, password: str = ADMIN_PASSWORD,
response = client.post("/api/auth/login", json={"password": password, **payload}) response = client.post("/api/auth/login", json={"password": password, **payload})
assert response.status_code == 200, f"sign-in failed: {response.status_code} {response.text}" assert response.status_code == 200, f"sign-in failed: {response.status_code} {response.text}"
body: dict[str, Any] = response.json() body: dict[str, Any] = response.json()
# On the client, not per request: the ~200 existing HTTP assertions in test_api.py post to # On the client, not per request: the existing HTTP assertions in test_api.py post to the
# the one write endpoint, and a token the helper did not attach would 403 every one of them. # upstream write endpoints, and a token the helper did not attach would 403 every one of them.
client.headers[auth.CSRF_HEADER] = str(body.get("csrf", "")) client.headers[auth.CSRF_HEADER] = str(body.get("csrf", ""))
return body return body
+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 " 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)", "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: 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", "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 @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 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 * `sockets_silent` — neither `/proc` table readable: `available: null`, so every live count
must come back `None` and never `0` 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]: def build(mode: str = "full") -> dict[str, Any]:
if mode not in GATEWAY_MODES: 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": if mode == "sockets_silent":
(root / "proc/net/tcp").unlink() (root / "proc/net/tcp").unlink()
(root / "proc/net/tcp6").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] = [] errors: list[str] = []
collector = host_agent_module.Collector(root) collector = host_agent_module.Collector(root)
+593 -41
View File
@@ -27,6 +27,7 @@ import threading
import time import time
import urllib.error import urllib.error
import urllib.request import urllib.request
from contextlib import suppress
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
from http.server import ThreadingHTTPServer from http.server import ThreadingHTTPServer
from pathlib import Path from pathlib import Path
@@ -838,8 +839,10 @@ def test_the_captured_haproxy_shape_parses_into_frontends_and_backends() -> None
[{"addr": "::", "port": port}] for port in (22, 2000, 5201, 8080, 25565, 25566)] [{"addr": "::", "port": port}] for port in (22, 2000, 5201, 8080, 25565, 25566)]
assert [frontend["default_backend"] for frontend in parsed["frontends"]] == [ assert [frontend["default_backend"] for frontend in parsed["frontends"]] == [
"svc-2222", "svc-2000", "svc-5201", "svc-8080", "svc-25565", "svc-25566"] "svc-2222", "svc-2000", "svc-5201", "svc-8080", "svc-25565", "svc-25566"]
# no line carries the helper's marker, so none is off (the flag is present, not inferred)
assert [backend["servers"] for backend in parsed["backends"]] == [ assert [backend["servers"] for backend in parsed["backends"]] == [
[{"name": f"svc{port}", "target": f"svc{port}.example.com", "port": port}] [{"name": f"svc{port}", "target": f"svc{port}.example.com", "port": port,
"disabled": False}]
for port in (2222, 2000, 5201, 8080, 25565, 25566)] for port in (2222, 2000, 5201, 8080, 25565, 25566)]
# routing is the names themselves, and the config joins: every frontend's default_backend is # routing is the names themselves, and the config joins: every frontend's default_backend is
# one of the backends listed beside it (phase 29 draws its pipes from exactly this) # one of the backends listed beside it (phase 29 draws its pipes from exactly this)
@@ -865,9 +868,10 @@ def test_the_recorded_gateway_haproxy_config_parses_too() -> None:
"testcraft-25565", "nimcraft-25566"] "testcraft-25565", "nimcraft-25566"]
assert [(backend["name"], backend["servers"]) for backend in parsed["backends"]][:2] == [ assert [(backend["name"], backend["servers"]) for backend in parsed["backends"]][:2] == [
("gitea-2222", [{"name": "gitea", "target": "reeseapps-gitea.reeselink.com", ("gitea-2222", [{"name": "gitea", "target": "reeseapps-gitea.reeselink.com",
"port": 2222}]), "port": 2222, "disabled": False}]),
("sec-gitea-2000", [{"name": "sec-gitea", "target": "stackexpected-gitea.reeselink.com", ("sec-gitea-2000", [{"name": "sec-gitea",
"port": 2222}]), "target": "stackexpected-gitea.reeselink.com",
"port": 2222, "disabled": False}]),
] ]
# every frontend's target exists beside it — the join phase 29 draws its pipes from # every frontend's target exists beside it — the join phase 29 draws its pipes from
names = {backend["name"] for backend in parsed["backends"]} names = {backend["name"] for backend in parsed["backends"]}
@@ -912,9 +916,10 @@ def test_a_server_that_names_no_port_keeps_null_and_unknown_lines_are_ignored()
" http-request deny deny_status 403\n" " http-request deny deny_status 403\n"
) )
assert parsed["backends"][0]["servers"] == [ assert parsed["backends"][0]["servers"] == [
{"name": "withport", "target": "10.1.0.9", "port": 8080}, {"name": "withport", "target": "10.1.0.9", "port": 8080, "disabled": False},
{"name": "noport", "target": "10.1.0.10", "port": None}, {"name": "noport", "target": "10.1.0.10", "port": None, "disabled": False},
{"name": "servicename", "target": "host.example.com:http", "port": None}, {"name": "servicename", "target": "host.example.com:http", "port": None,
"disabled": False},
] ]
# a `backend` body never grows frontend fields, however many directives it holds # a `backend` body never grows frontend fields, however many directives it holds
assert "binds" not in parsed["backends"][0] assert "binds" not in parsed["backends"][0]
@@ -933,7 +938,7 @@ def test_a_listen_section_is_reported_as_the_pair_haproxy_says_it_is() -> None:
"default_backend": "stats", "backends": ["stats"]}] "default_backend": "stats", "backends": ["stats"]}]
assert parsed["backends"] == [{"name": "stats", assert parsed["backends"] == [{"name": "stats",
"servers": [{"name": "own", "target": "127.0.0.1", "servers": [{"name": "own", "target": "127.0.0.1",
"port": 8405}]}] "port": 8405, "disabled": False}]}]
def test_a_backend_computed_per_request_contributes_no_name() -> None: def test_a_backend_computed_per_request_contributes_no_name() -> None:
@@ -1249,27 +1254,30 @@ def test_the_captured_nginx_shape_parses_into_servers_maps_and_targets() -> None
assert [server["proxy_passes"] for server in parsed["servers"]] == [ assert [server["proxy_passes"] for server in parsed["servers"]] == [
[], [],
# the stream form: no `location` exists, and the target is a variable, not an address # the stream form: no `location` exists, and the target is a variable, not an address
[{"location": None, "scheme": None, "target": None, "variable": "targetBackend"}], [{"location": None, "scheme": None, "target": None, "variable": "targetBackend",
"disabled": False}],
[{"location": "/", "scheme": "http", "variable": None, [{"location": "/", "scheme": "http", "variable": None,
"target": {"host": "nextcloud.internal.test", "port": 11000}}], "target": {"host": "nextcloud.internal.test", "port": 11000}, "disabled": False}],
[{"location": "/", "scheme": "http", "variable": None, [{"location": "/", "scheme": "http", "variable": None,
"target": {"host": "127.0.0.1", "port": 8081}}], "target": {"host": "127.0.0.1", "port": 8081}, "disabled": False}],
] ]
maps = by_variable(parsed["maps"]) maps = by_variable(parsed["maps"])
assert set(maps) == {"connection_upgrade", "targetBackend"} assert set(maps) == {"connection_upgrade", "targetBackend"}
# the variable name is spelled one way on both sides of the join, and the `$` is nginx's # the variable name is spelled one way on both sides of the join, and the `$` is nginx's
# syntax rather than part of the name — so it is dropped here once, not in the page # syntax rather than part of the name — so it is dropped here once, not in the page
assert maps["targetBackend"]["entries"] == [ assert maps["targetBackend"]["entries"] == [
{"key": "default", "target": {"host": "127.0.0.1", "port": 4443}}, {"key": "default", "target": {"host": "127.0.0.1", "port": 4443},
"disabled": False},
{"key": "brain.example.com", "target": {"host": "brain-of-reese.internal.test", {"key": "brain.example.com", "target": {"host": "brain-of-reese.internal.test",
"port": 443}}, "port": 443}, "disabled": False},
] ]
# …and the `proxy_pass $targetBackend` above still carries `target: null`: the map decides # …and the `proxy_pass $targetBackend` above still carries `target: null`: the map decides
# per connection, so merging it here would be a number the wire never published # per connection, so merging it here would be a number the wire never published
assert parsed["servers"][1]["proxy_passes"][0]["target"] is None assert parsed["servers"][1]["proxy_passes"][0]["target"] is None
# `$connection_upgrade`'s values are header words, not hosts: keys reported, targets null # `$connection_upgrade`'s values are header words, not hosts: keys reported, targets null
assert maps["connection_upgrade"]["entries"] == [ assert maps["connection_upgrade"]["entries"] == [
{"key": "default", "target": None}, {"key": "", "target": None}] {"key": "default", "target": None, "disabled": False},
{"key": "", "target": None, "disabled": False}]
# the include lines, as written, in document order — including the one file this tree has no # the include lines, as written, in document order — including the one file this tree has no
# opinion about because it is not a config (`mime.types`) # opinion about because it is not a config (`mime.types`)
assert parsed["includes"] == ["/etc/nginx/stream.d/*.conf", "/etc/nginx/mime.types", assert parsed["includes"] == ["/etc/nginx/stream.d/*.conf", "/etc/nginx/mime.types",
@@ -1325,15 +1333,18 @@ def test_a_proxy_pass_reports_the_address_the_variable_or_neither() -> None:
assert parsed["servers"][0]["proxy_passes"] == [ assert parsed["servers"][0]["proxy_passes"] == [
# the trailing path is dropped the way haproxy's `check inter 5s` is: it is not a place # the trailing path is dropped the way haproxy's `check inter 5s` is: it is not a place
{"location": "/a/", "scheme": "http", "target": {"host": "h1.test", "port": 8080}, {"location": "/a/", "scheme": "http", "target": {"host": "h1.test", "port": 8080},
"variable": None}, "variable": None, "disabled": False},
# an `upstream` name is a host with no port — the name is the fact, the port is not # an `upstream` name is a host with no port — the name is the fact, the port is not
{"location": "/b/", "scheme": "http", "target": {"host": "pool_name", "port": None}, {"location": "/b/", "scheme": "http", "target": {"host": "pool_name", "port": None},
"variable": None}, "variable": None, "disabled": False},
{"location": "/c/", "scheme": None, "target": None, "variable": "backend"}, {"location": "/c/", "scheme": None, "target": None, "variable": "backend",
"disabled": False},
# a unix socket is neither a host nor a variable: the line is reported with nulls # a unix socket is neither a host nor a variable: the line is reported with nulls
{"location": "/d/", "scheme": None, "target": None, "variable": None}, {"location": "/d/", "scheme": None, "target": None, "variable": None,
"disabled": False},
# a colon that is not a port either: the scheme is a fact, the host would be a guess # a colon that is not a port either: the scheme is a fact, the host would be a guess
{"location": "/e/", "scheme": "http", "target": None, "variable": None}, {"location": "/e/", "scheme": "http", "target": None, "variable": None,
"disabled": False},
] ]
@@ -1371,9 +1382,9 @@ def test_an_upstream_block_keeps_its_server_lines_as_targets() -> None:
"http { server { listen 80; } }" "http { server { listen 80; } }"
) )
assert parsed["upstreams"] == [{"name": "pool_name", "servers": [ assert parsed["upstreams"] == [{"name": "pool_name", "servers": [
{"host": "10.1.0.9", "port": 8080}, {"host": "10.1.0.9", "port": 8080, "disabled": False},
{"host": "backend.test", "port": None}, {"host": "backend.test", "port": None, "disabled": False},
{"host": "::1", "port": 8080}, {"host": "::1", "port": 8080, "disabled": False},
]}] ]}]
# an upstream's `server` lines are not server blocks, however many of them there are # an upstream's `server` lines are not server blocks, however many of them there are
assert len(parsed["servers"]) == 1 assert len(parsed["servers"]) == 1
@@ -2179,8 +2190,11 @@ def agent_server():
host_agent.Handler.collector = host_agent.Collector("/") host_agent.Handler.collector = host_agent.Collector("/")
host_agent.Handler.token = token host_agent.Handler.token = token
# Cleared on purpose: `Handler` is process-wide state, so a token-mode server started # Cleared on purpose: `Handler` is process-wide state, so a token-mode server started
# after a TLS test in this file must not inherit that test's bundle. # after a TLS test in this file must not inherit that test's bundle — or the write
# path's gate, which the bundle check is the first line of.
host_agent.Handler.bundle = None host_agent.Handler.bundle = None
host_agent.Handler.allow_writes = False
host_agent.Handler.routectl_socket = None
httpd = ThreadingHTTPServer(("127.0.0.1", 0), host_agent.Handler) httpd = ThreadingHTTPServer(("127.0.0.1", 0), host_agent.Handler)
servers.append(httpd) servers.append(httpd)
threading.Thread(target=httpd.serve_forever, daemon=True).start() threading.Thread(target=httpd.serve_forever, daemon=True).start()
@@ -2253,15 +2267,6 @@ def test_token_is_required_when_configured_but_health_stays_open(agent_server) -
assert _get(base + "/health")[0] == 200 assert _get(base + "/health")[0] == 200
def test_agent_has_no_write_path(agent_server) -> None:
"""No do_POST/do_PUT exists: read-only is structural, not a policy toggle."""
base, _port = agent_server()
request = urllib.request.Request(base + "/snapshot", data=b"{}", method="POST")
with pytest.raises(urllib.error.HTTPError) as excinfo:
urllib.request.urlopen(request, timeout=5)
assert excinfo.value.code in (405, 501)
def test_refuses_to_bind_off_loopback_without_a_token(monkeypatch) -> None: def test_refuses_to_bind_off_loopback_without_a_token(monkeypatch) -> None:
"""0.0.0.0 with no token would hand host inventory to the whole LAN.""" """0.0.0.0 with no token would hand host inventory to the whole LAN."""
monkeypatch.delenv("AGENT_TOKEN", raising=False) monkeypatch.delenv("AGENT_TOKEN", raising=False)
@@ -2452,6 +2457,10 @@ def tls_agent_server():
host_agent.Handler.collector = host_agent.Collector("/", smart=False) host_agent.Handler.collector = host_agent.Collector("/", smart=False)
host_agent.Handler.token = token host_agent.Handler.token = token
host_agent.Handler.bundle = bundle host_agent.Handler.bundle = bundle
# Cleared on purpose, like `bundle` above: a writes-off server started after a
# `POST /route` test must not inherit that test's write gate or helper socket.
host_agent.Handler.allow_writes = False
host_agent.Handler.routectl_socket = None
httpd = ThreadingHTTPServer(("127.0.0.1", 0), host_agent.Handler) httpd = ThreadingHTTPServer(("127.0.0.1", 0), host_agent.Handler)
httpd.socket = bundle.context.wrap_socket(httpd.socket, server_side=True) httpd.socket = bundle.context.wrap_socket(httpd.socket, server_side=True)
servers.append(httpd) servers.append(httpd)
@@ -2463,6 +2472,8 @@ def tls_agent_server():
httpd.shutdown() httpd.shutdown()
httpd.server_close() httpd.server_close()
host_agent.Handler.bundle = None host_agent.Handler.bundle = None
host_agent.Handler.allow_writes = False
host_agent.Handler.routectl_socket = None
def _json_or_raw(body: bytes) -> dict: def _json_or_raw(body: bytes) -> dict:
@@ -2757,14 +2768,19 @@ def test_a_bundle_allows_the_off_loopback_bind_without_a_token(
tls_fleet: SimpleNamespace, tmp_path: Path, monkeypatch) -> None: tls_fleet: SimpleNamespace, tmp_path: Path, monkeypatch) -> None:
"""The client certificate *is* the protection, so the exit-2 refusal does not apply.""" """The client certificate *is* the protection, so the exit-2 refusal does not apply."""
served: list[tuple] = [] served: list[tuple] = []
seen_kwargs: list[dict] = []
monkeypatch.setattr(host_agent, "serve", monkeypatch.setattr(host_agent, "serve",
lambda *args: served.append(args)) lambda *args, **kwargs: served.append(args) or seen_kwargs.append(kwargs))
path = write_bundle(tmp_path, tls_fleet.bundle) path = write_bundle(tmp_path, tls_fleet.bundle)
assert host_agent.main(["--host", "0.0.0.0", "--port", "0", "--cert-bundle", str(path)]) == 0 assert host_agent.main(["--host", "0.0.0.0", "--port", "0", "--cert-bundle", str(path)]) == 0
assert served, "a bundle-configured agent must start" assert served, "a bundle-configured agent must start"
host_arg, _port, _collector, token_arg, bundle_arg = served[0] host_arg, _port, _collector, token_arg, bundle_arg = served[0]
assert (host_arg, token_arg) == ("0.0.0.0", None) assert (host_arg, token_arg) == ("0.0.0.0", None)
assert isinstance(bundle_arg, host_agent.TlsBundle) assert isinstance(bundle_arg, host_agent.TlsBundle)
# 1.4.0's defaults: the write path is off, and the helper's home is the standard socket
# path unless the flag names another one (an empty value is the 503 arm: no helper here).
assert seen_kwargs[0]["allow_writes"] is False
assert seen_kwargs[0]["routectl_socket"] == "/run/dashboards-routectl.sock"
@pytest.mark.parametrize("extra", [[], ["--cert-bundle", ""]]) @pytest.mark.parametrize("extra", [[], ["--cert-bundle", ""]])
@@ -2819,22 +2835,157 @@ def test_the_schema_names_the_three_proxy_sections() -> None:
assert "--no-proxy" in doc and "--haproxy-config" in doc and "--nginx-config" in doc assert "--no-proxy" in doc and "--haproxy-config" in doc and "--nginx-config" in doc
def test_the_agent_version_is_1_3_0_and_the_schema_names_the_resolver_section() -> None: def test_the_agent_version_is_1_4_0_and_the_schema_documents_the_route_marker() -> None:
"""1.3.0 is the version that resolves the names the configs declare. """1.4.0 is the version that reads the route switch's marker off the route line.
The bump is how the fleet tells an agent that publishes `resolver` from one that does not — The bump is how the fleet tells an agent that publishes `disabled` from one that does not —
`--status`, `/health` and this test all read the same constant, and `sources/gateway.py`'s `--status`, `/health` and this test all read the same constant. The SCHEMA block is
"the agent is too old" arm keys on the sections the version belongs to. The SCHEMA block is `GET /schema`'s body, so it documents the flag on both config sections and the marker's
`GET /schema`'s body, so it names the section, its three-way `available`, and `--no-resolve`, spelling, and `--no-resolve` stays named: the flag that removes the resolver section in
the flag that removes it in silence. silence.
""" """
assert host_agent.AGENT_VERSION == "1.3.0" assert host_agent.AGENT_VERSION == "1.4.0"
doc = host_agent.__doc__ or "" doc = host_agent.__doc__ or ""
assert schema_documents("resolver"), "resolver is not documented in the SCHEMA block" assert schema_documents("resolver"), "resolver is not documented in the SCHEMA block"
block = doc.split(" resolver ", 1)[-1] block = doc.split(" resolver ", 1)[-1]
assert "available" in block, "the resolver block does not document its three-way answer" assert "available" in block, "the resolver block does not document its three-way answer"
assert "--no-resolve" in doc, "the flag that removes the section is undocumented" assert "--no-resolve" in doc, "the flag that removes the section is undocumented"
assert "libc" in block, "how the addresses were obtained is not published in the schema" assert "libc" in block, "how the addresses were obtained is not published in the schema"
assert "disabled" in doc, "the marker read is not documented in the SCHEMA block"
assert "#off" in doc, "the marker's spelling is not documented in the schema"
def test_the_marker_grammar_is_one_constant_shared_by_writer_and_reader() -> None:
"""`split_marker` is the read side of the switch and `marker_for` the write side; both
spell the same grammar, which is the whole of the read side (phase 33 task 04).
A marked line keeps its text and gains a name; anything that is not exactly `#off <name>`
— an extra word, a bare `#off`, or the operator's own comment — is not a switch, and a line
without a comment is a line.
"""
assert host_agent.ROUTE_MARKER == "#off"
line, name = host_agent.split_marker("server s1 10.1.1.9:443 #off s1")
assert line == "server s1 10.1.1.9:443" and name == "s1"
# trailing whitespace after the name is the operator's formatting, not part of the name
assert host_agent.split_marker("server s1 10.1.1.9:443 #off s1 ") == (
"server s1 10.1.1.9:443", "s1")
line, name = host_agent.split_marker("server s1 10.1.1.9:443 #off")
assert name is None, "a bare #off with no name is a comment, not a switch"
line, name = host_agent.split_marker("server s1 10.1.1.9:443 #off s1 extra")
assert name is None, "two words after the marker is a comment, not a switch"
line, name = host_agent.split_marker("server s1 10.1.1.9:443 # off s1")
assert name is None, "a comment that merely mentions the word is not a switch"
line, name = host_agent.split_marker("server s1 10.1.1.9:443")
assert name is None
# the write side spells what the read side parses: the round trip is exact
for name_ in ("s1", "pool:8080", "web.example.com"):
marked, marker = host_agent.split_marker(f"line {host_agent.marker_for(name_)}")
assert marker == name_ and marked == "line"
def test_config_line_index_finds_the_line_the_marker_names() -> None:
"""The helper edits by line number, and the line is found by the marker, never the route's
text (a target string appears in many lines; the marker names exactly one of them)."""
text = "backend be\n server s1 10.1.1.9:443\n server s2 10.1.1.10:443 #off s1\n"
assert host_agent.config_line_index(text, "s1") == 3
assert host_agent.config_line_index(text, "s2") == -1
# a commented-out line is not a route: its marker is part of the comment, not a switch
text = "# server s3 10.1.1.11:443 #off s3\n"
assert host_agent.config_line_index(text, "s3") == -1
def test_the_haproxy_parser_reports_a_marked_server_line_disabled() -> None:
"""The read side of the switch: a `server` line carrying the helper's marker is
`disabled: true` and every other line `disabled: false` — and the marked line is still a
line: its target is parsed and its text kept, because the helper rewrites the value, not
the line, and a parser that dropped it would lose the route the switch is about."""
parsed = host_agent.parse_haproxy_config(
"backend be\n"
" server on 10.1.1.9:443\n"
" server off 10.1.1.10:443 #off off\n"
" server note 10.1.1.11:443 # a comment that is not the switch\n"
)
assert parsed["backends"][0]["servers"] == [
{"name": "on", "target": "10.1.1.9", "port": 443, "disabled": False},
{"name": "off", "target": "10.1.1.10", "port": 443, "disabled": True},
{"name": "note", "target": "10.1.1.11", "port": 443, "disabled": False},
]
def test_the_nginx_parser_reports_a_marked_line_disabled_in_all_three_shapes() -> None:
"""One marker, three route lines: a `proxy_pass`, an `upstream` member, a `map` entry.
The stream form is the fleet's own: a `proxy_pass $var` with no location, a `map` whose
entries are the SNI fan-out, and an `upstream` the http vhosts name. Each line's marker is
its own line's `disabled`, and an unmarked line beside a marked one is still on."""
parsed = host_agent.parse_nginx_config(
"stream {\n"
" server { listen 443; proxy_pass $targetBackend; }\n"
" server { listen 127.0.0.1:44450; return 502; }\n"
" map $ssl_preread_server_name $targetBackend {\n"
" default 10.1.0.1:8008;\n"
" on.example.com 10.1.0.1:8008;\n"
" off.example.com 127.0.0.1:44450; #off off.example.com\n"
" }\n"
"}"
"http {\n"
" upstream pool {\n"
" server 10.1.0.9:8080; #off 10.1.0.9:8080\n"
" server 10.1.0.10:8080;\n"
" }\n"
" server { listen 80; location / { proxy_pass http://pool; } }\n"
"}"
)
assert parsed["servers"][0]["proxy_passes"] == [
{"location": None, "scheme": None, "target": None, "variable": "targetBackend",
"disabled": False}]
assert parsed["servers"][1]["proxy_passes"] == [], "a `return` block proxies nowhere"
entries = parsed["maps"][0]["entries"]
assert [entry["key"] for entry in entries] == ["default", "on.example.com", "off.example.com"]
assert [entry["disabled"] for entry in entries] == [False, False, True]
assert entries[2]["target"] == {"host": "127.0.0.1", "port": 44450}, \
"the marked entry keeps its value: the state is the flag, not a rewritten line"
assert parsed["upstreams"] == [{"name": "pool", "servers": [
{"host": "10.1.0.9", "port": 8080, "disabled": True},
{"host": "10.1.0.10", "port": 8080, "disabled": False},
]}]
def test_the_nginx_section_exposes_the_marker_flag_on_the_wire(tmp_path: Path) -> None:
"""The section the dashboard reads is where the flag must land: a marked line in the file
is a `disabled: true` row in the `haproxy` and `nginx` sections, and an unmarked config
reports `false`, never a missing key (the dashboard's `enabled: false` is the flag's
absence of absence, and a row that could not answer the question would read as on)."""
write_file(tmp_path / "root", "opt/haproxy/etc/haproxy.cfg",
"backend be\n server on 10.1.1.9:443\n"
" server off 10.1.1.10:443 #off off\n")
write_file(tmp_path / "root", "opt/nginx/conf/nginx.conf",
"stream {\n"
" server {\n"
" listen 443;\n"
" proxy_pass $v; #off v\n"
" }\n"
" map $x $v {\n"
" default 10.1.0.1:8008;\n"
" a.test 10.1.0.1:8008;\n"
" }\n"
"}\n"
"http {\n"
" upstream p {\n"
" server 10.1.0.9:8080; #off 10.1.0.9:8080\n"
" }\n"
"}\n")
errors: list[str] = []
collector = host_agent.Collector(
str(tmp_path / "root"), haproxy_config="/opt/haproxy/etc/haproxy.cfg",
nginx_config="/opt/nginx/conf/nginx.conf")
haproxy = collector.haproxy(errors)
assert [row["disabled"] for row in haproxy["backends"][0]["servers"]] == [False, True]
nginx = collector.nginx(errors)
assert nginx["servers"][0]["proxy_passes"][0]["disabled"] is True
assert [row["disabled"] for row in nginx["upstreams"][0]["servers"]] == [True]
assert [row["disabled"] for row in nginx["maps"][0]["entries"]] == [False, False]
assert errors == []
# ---- the recorded gateway: the captures themselves (phase 28 task 04) -------------------- # # ---- the recorded gateway: the captures themselves (phase 28 task 04) -------------------- #
@@ -3732,3 +3883,404 @@ def test_the_target_list_is_the_two_sections_and_survives_a_section_that_is_not_
("vhost.test", 443), ("map.test", 443)] ("vhost.test", 443), ("map.test", 443)]
assert host_agent.target_names(None, None) == [] assert host_agent.target_names(None, None) == []
assert host_agent.target_names({"backends": "nonsense"}, {"maps": [7]}) == [] assert host_agent.target_names({"backends": "nonsense"}, {"maps": [7]}) == []
# ---- POST /route (the one write, agent 1.4.0) -------------------------------- #
class _Peer:
"""A routectl stub: an AF_UNIX socket that counts dials, reads one request line and
answers one line. `hang` holds the connection open without answering, which is what
makes the agent's 30 s budget (patched down in the test) fire. A dial it never sees
is a refusal that happened before the socket, so the counters are the pins."""
def __init__(self, tmp_path: Path, reply: str | None = None, *, hang: bool = False) -> None:
self.path = str(tmp_path / "peer.sock")
self.reply = reply
self.hang = hang
self.requests: list[dict[str, Any]] = []
self.connections = 0
self._server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
self._server.bind(self.path)
self._server.listen(8)
def serve(self, window: float) -> None:
deadline = time.monotonic() + window
self._server.settimeout(0.05)
while time.monotonic() < deadline:
try:
conn, _addr = self._server.accept()
except TimeoutError:
continue
except OSError:
return # stop() closed the server; the window is over
self.connections += 1
data = b""
try:
while b"\n" not in data:
chunk = conn.recv(65536)
if not chunk:
break
data += chunk
except OSError:
pass
if data:
self.requests.append(json.loads(data.decode()))
if self.hang:
continue # never answer; the agent's own timeout fires
if self.reply is not None:
with suppress(OSError):
conn.sendall(self.reply.encode())
conn.close()
def stop(self) -> None:
self._server.close()
def _start_peer(tmp_path: Path, **kwargs: Any) -> _Peer:
peer = _Peer(tmp_path, **kwargs)
threading.Thread(target=peer.serve, args=(6.0,), daemon=True).start()
return peer
def _https_post(url: str, context: ssl.SSLContext, body: bytes) -> tuple[int, dict]:
request = urllib.request.Request(url, data=body, method="POST")
try:
with urllib.request.urlopen(request, timeout=15, context=context) as response:
return response.status, _json_or_raw(response.read())
except urllib.error.HTTPError as error:
return error.code, _json_or_raw(error.read())
def _post(url: str, body: bytes, headers: dict[str, str] | None = None) -> tuple[int, dict]:
request = urllib.request.Request(url, data=body, headers=headers or {}, method="POST")
try:
with urllib.request.urlopen(request, timeout=5) as response:
return response.status, json.loads(response.read())
except urllib.error.HTTPError as error:
return error.code, json.loads(error.read())
def _route_body() -> bytes:
return json.dumps({"id": "nginx:443->brain.test:443", "action": "disable",
"dry_run": True}).encode()
def test_the_agent_is_1_4_0_and_the_write_path_is_a_documented_flag() -> None:
"""The version moves with the write; the two flags are on the command line, not in
config, and the status page and the docstring say the write exists and is gated."""
assert host_agent.AGENT_VERSION == "1.4.0"
assert host_agent.ROUTE_TIMEOUT_SECONDS == 30
help_text = subprocess.run([sys.executable, str(AGENT_PATH), "--help"],
capture_output=True, text=True, timeout=30).stdout
for flag in ("--allow-writes", "--routectl-socket", "--once"):
assert flag in help_text, flag
assert "POST /route" in (host_agent.__doc__ or "")
def test_a_post_without_a_client_certificate_is_refused(
tls_fleet: SimpleNamespace, tls_agent_server, tmp_path: Path) -> None:
"""The token is not a write credential: a probe that holds only the CA (the same
identity `install-agent.sh --status` uses) gets the certificate refusal, on a POST too."""
base = tls_agent_server(load_bundle(tmp_path, tls_fleet.bundle))
status, body = _https_post(base + "/route", client_context(tls_fleet.root_cert), _route_body())
assert status == 403
assert body["detail"] == "client certificate required"
def test_a_revoked_serial_is_refused_on_the_write_path_too(
tls_fleet: SimpleNamespace, tls_agent_server, tmp_path: Path) -> None:
"""A serial on the denylist is refused on every path, including the write — the
revoke in `/certs` bites a `POST /route` no later than it bites a `GET`."""
text = tls_fleet.bundle + f"{host_agent.REVOKED_LINE}{tls_fleet.stolen.serial}\n"
base = tls_agent_server(load_bundle(tmp_path, text))
status, body = _https_post(base + "/route",
client_context(tls_fleet.root_cert, str(tls_fleet.stolen.cert),
str(tls_fleet.stolen.key)),
_route_body())
assert status == 403
assert body["detail"] == "client certificate revoked"
def test_token_mode_cannot_write_and_token_is_not_a_write_credential(
agent_server, tmp_path: Path) -> None:
"""A token-mode agent (no bundle) answers the write 403 even when the bearer token is
right: `Authorization` is a read credential, and the write's gate is the certificate.
The 405 arm is the same handler: a POST to any other path is refused before anything
else is checked."""
base, _port = agent_server("k8s-ops-token")
bearer = {"Authorization": "Bearer k8s-ops-token"}
status, body = _post(base + "/route", _route_body(), bearer)
assert status == 403
assert body["detail"] == "writes require a certificate"
for path in ("/health", "/snapshot", "/info", "/schema", "/"):
status, body = _post(base + path, b"{}", bearer)
assert status == 405, path
assert body["detail"] == "no such write path", path
@pytest.mark.parametrize("body, detail", [
('{"id": "x", "action": "disable", "dry_run": true, "extra": 1}',
"body carries a key the write path does not take"),
('{"id": "x", "action": "restart", "dry_run": true}',
"action must be disable or enable"),
(json.dumps({"id": "y" * 257, "action": "disable"}),
"id must be a string of at most 256 characters"),
("[1, 2]", "body is not a JSON object"),
("not json at all", "body is not a JSON object"),
])
def test_a_body_outside_the_vocabulary_is_refused_before_the_peer_is_touched(
body: str, detail: str, tls_fleet: SimpleNamespace, tls_agent_server,
tmp_path: Path) -> None:
"""The helper decides what a route id means; the agent decides what a body is. A body
outside `{id, action, dry_run}` is refused before any socket opens, so a bad body can
never reach a proxy config through this door."""
peer = _start_peer(tmp_path, reply='{"ok": true, "applied": true}')
base = tls_agent_server(load_bundle(tmp_path, tls_fleet.bundle))
host_agent.Handler.allow_writes = True
host_agent.Handler.routectl_socket = peer.path
status, answer = _https_post(base + "/route",
client_context(tls_fleet.root_cert, tls_fleet.client_cert,
tls_fleet.client_key),
body.encode())
assert status == 422
assert answer["detail"] == detail
peer.stop()
assert peer.connections == 0
def test_an_oversized_body_is_refused_before_the_peer_is_touched(
tls_fleet: SimpleNamespace, tls_agent_server, tmp_path: Path) -> None:
peer = _start_peer(tmp_path, reply='{"ok": true, "applied": true}')
base = tls_agent_server(load_bundle(tmp_path, tls_fleet.bundle))
host_agent.Handler.allow_writes = True
host_agent.Handler.routectl_socket = peer.path
oversized = json.dumps({"id": "x" * 5000, "action": "disable"})
assert len(oversized) > host_agent._ROUTE_MAX_BODY
status, answer = _https_post(base + "/route",
client_context(tls_fleet.root_cert, tls_fleet.client_cert,
tls_fleet.client_key),
oversized.encode())
assert status == 422
assert answer["detail"] == "body longer than the write path accepts"
peer.stop()
assert peer.connections == 0
def test_writes_off_refuses_before_the_peer_is_touched(
tls_fleet: SimpleNamespace, tls_agent_server, tmp_path: Path) -> None:
"""The certificate verifies and the helper is listening, but the agent was started
without `--allow-writes`: the refusal is the agent's own, and the dial never happens —
a host that is not armed must not be reachable by an armed dashboard."""
peer = _start_peer(tmp_path, reply='{"ok": true, "applied": true}')
base = tls_agent_server(load_bundle(tmp_path, tls_fleet.bundle))
# allow_writes stays False (the fixture's default)
host_agent.Handler.routectl_socket = peer.path
status, answer = _https_post(base + "/route",
client_context(tls_fleet.root_cert, tls_fleet.client_cert,
tls_fleet.client_key),
_route_body())
assert status == 403
assert answer["detail"] == "writes are not enabled on this agent"
peer.stop()
assert peer.connections == 0
def test_a_host_without_the_helper_answers_503_not_a_write(
tls_fleet: SimpleNamespace, tls_agent_server, tmp_path: Path) -> None:
"""`--routectl-socket` empty (the flag's documented arm): armed host, no helper unit.
The answer is 503, not a write and not a 200 — a dashboard can tell "not installed"
from "refused" without guessing."""
base = tls_agent_server(load_bundle(tmp_path, tls_fleet.bundle))
host_agent.Handler.allow_writes = True
# routectl_socket stays None (the fixture's default)
status, answer = _https_post(base + "/route",
client_context(tls_fleet.root_cert, tls_fleet.client_cert,
tls_fleet.client_key),
_route_body())
assert status == 503
assert answer["detail"] == "no route helper on this host"
@pytest.mark.parametrize("action, dry_run", [("disable", True), ("enable", False)])
def test_a_good_request_reaches_the_peer_once_and_the_answer_is_passed_through(
action: str, dry_run: bool, tls_fleet: SimpleNamespace, tls_agent_server,
tmp_path: Path) -> None:
"""The agent is a forward: the body that reaches the helper is exactly `{id, action,
dry_run}` — no header, no token, no invented fields — and the helper's answer (a
refusal here, `applied` stays false) is the response, verbatim. One dial, one request:
a retry would be a second reload."""
peer = _start_peer(tmp_path, reply='{"ok": false, "detail": "ambiguous: whatever"}')
base = tls_agent_server(load_bundle(tmp_path, tls_fleet.bundle))
host_agent.Handler.allow_writes = True
host_agent.Handler.routectl_socket = peer.path
payload = {"id": "nginx:443->brain.test:443", "action": action, "dry_run": dry_run}
status, answer = _https_post(base + "/route",
client_context(tls_fleet.root_cert, tls_fleet.client_cert,
tls_fleet.client_key),
json.dumps(payload).encode())
assert status == 200
assert answer == {"ok": False, "detail": "ambiguous: whatever"}
assert peer.connections == 1
assert peer.requests == [payload]
peer.stop()
def test_the_peer_is_called_once_even_when_it_times_out(
tls_fleet: SimpleNamespace, tls_agent_server, tmp_path: Path,
monkeypatch: pytest.MonkeyPatch) -> None:
"""The helper hangs: the agent's own 30 s budget (patched to 0.3 s here) fires and the
answer is 502 with the reason, never a hang and never a second dial — a retry would be
a second reload of the fleet's only ingress."""
peer = _start_peer(tmp_path, hang=True)
monkeypatch.setattr(host_agent, "ROUTE_TIMEOUT_SECONDS", 0.3)
base = tls_agent_server(load_bundle(tmp_path, tls_fleet.bundle))
host_agent.Handler.allow_writes = True
host_agent.Handler.routectl_socket = peer.path
status, answer = _https_post(base + "/route",
client_context(tls_fleet.root_cert, tls_fleet.client_cert,
tls_fleet.client_key),
_route_body())
assert status == 502
assert answer["detail"] == "route helper timed out"
assert peer.connections == 1
assert len(peer.requests) == 1
peer.stop()
def test_an_unreadable_peer_answer_is_502_and_the_peer_is_not_retried(
tls_fleet: SimpleNamespace, tls_agent_server, tmp_path: Path) -> None:
"""A helper that answers bytes that are not a JSON object is a broken helper: 502 with
the reason, one dial. The agent invents no success and no detail of its own."""
peer = _start_peer(tmp_path, reply="nonsense is not json")
base = tls_agent_server(load_bundle(tmp_path, tls_fleet.bundle))
host_agent.Handler.allow_writes = True
host_agent.Handler.routectl_socket = peer.path
status, answer = _https_post(base + "/route",
client_context(tls_fleet.root_cert, tls_fleet.client_cert,
tls_fleet.client_key),
_route_body())
assert status == 502
assert answer["detail"] == "route helper answered something unreadable"
assert peer.connections == 1
peer.stop()
def test_every_other_path_is_405_on_the_write_path_and_a_query_string_is_not_a_second_path(
tls_fleet: SimpleNamespace, tls_agent_server, tmp_path: Path) -> None:
"""The write path is `/route` and nothing else, on a TLS host with writes on: every
other path is 405 before the certificate, the flag or the body are even looked at. A
query string on `/route` is the same path — the helper decides what the id means."""
peer = _start_peer(tmp_path, reply='{"ok": true, "applied": true}')
base = tls_agent_server(load_bundle(tmp_path, tls_fleet.bundle))
host_agent.Handler.allow_writes = True
host_agent.Handler.routectl_socket = peer.path
context = client_context(tls_fleet.root_cert, tls_fleet.client_cert, tls_fleet.client_key)
for path in ("/health", "/snapshot", "/info", "/schema", "/", "/nope"):
status, answer = _https_post(base + path, context, b"{}")
assert status == 405, path
assert answer["detail"] == "no such write path", path
status, answer = _https_post(base + "/route?from=drill", context, _route_body())
assert status == 200
assert peer.connections == 1
peer.stop()
# ---- the `disabled` field (the read side of the switch) ---------------------- #
def _haproxy_server(snapshot: dict[str, Any], name: str) -> dict[str, Any] | None:
for backend in snapshot["haproxy"]["backends"]:
for server in backend["servers"]:
if server["name"] == name:
return server
return None
def _nginx_proxy_pass(snapshot: dict[str, Any], host: str, port: int) -> dict[str, Any] | None:
for server in snapshot["nginx"]["servers"]:
for entry in server["proxy_passes"]:
target = entry.get("target")
if target is not None and target["host"] == host and target["port"] == port:
return entry
return None
def _nginx_map_entry(snapshot: dict[str, Any], key: str) -> dict[str, Any] | None:
for mapped in snapshot["nginx"]["maps"]:
for entry in mapped["entries"]:
if entry["key"] == key:
return entry
return None
def test_a_marked_haproxy_server_is_disabled_and_stays_in_the_document(gateway_snapshot) -> None:
"""`#off <name>` is a trailing comment, not a deletion: the marked line stays in the
config (the enable half needs it) and the agent says `disabled: true` on the node.
The unmarked neighbours keep `disabled: false` — the field is the marker, nothing else."""
snap = gateway_snapshot("marked")
gitea = _haproxy_server(snap, "gitea")
assert gitea is not None, "the marked line must stay in the document"
assert gitea["disabled"] is True
assert gitea["target"] == "reeseapps-gitea.reeselink.com" and gitea["port"] == 2222
others = [server for backend in snap["haproxy"]["backends"]
for server in backend["servers"] if server["name"] != "gitea"]
assert others, "the fixture's other servers must still be parsed"
assert all(server["disabled"] is False for server in others)
def test_an_unmarked_config_has_disabled_false_everywhere(gateway_snapshot) -> None:
snap = gateway_snapshot("full")
for backend in snap["haproxy"]["backends"]:
for server in backend["servers"]:
assert server["disabled"] is False, server["name"]
for server in snap["nginx"]["servers"]:
for entry in server["proxy_passes"]:
assert entry["disabled"] is False
for pool in snap["nginx"]["upstreams"]:
for member in pool["servers"]:
assert member["disabled"] is False
for mapped in snap["nginx"]["maps"]:
for entry in mapped["entries"]:
assert entry["disabled"] is False
def test_a_marked_vhost_proxy_pass_is_disabled(gateway_snapshot) -> None:
snap = gateway_snapshot("marked")
entry = _nginx_proxy_pass(snap, "nextcloud.reeselink.com", 11000)
assert entry is not None, "the marked proxy_pass must stay in the document"
assert entry["disabled"] is True
plain = gateway_snapshot("full")
plain_entry = _nginx_proxy_pass(plain, "nextcloud.reeselink.com", 11000)
assert plain_entry is not None
assert plain_entry["disabled"] is False
def test_a_marked_map_entry_is_disabled_and_its_value_is_the_sink(gateway_snapshot) -> None:
"""The helper's third arm: the map entry's value is rewritten to the sink address and
the line marked. `disabled` is the marker (true even though the target is a loopback),
and the unmarked capture keeps the real target with `disabled: false`."""
snap = gateway_snapshot("marked")
entry = _nginx_map_entry(snap, "brain.reeseapps.com")
assert entry is not None
assert entry["disabled"] is True
assert entry["target"] == {"host": "127.0.0.1", "port": 44450}
plain = gateway_snapshot("full")
plain_entry = _nginx_map_entry(plain, "brain.reeseapps.com")
assert plain_entry is not None
target = plain_entry["target"]
assert target is not None and target["host"] == "reeseapps-brain-of-reese.reeselink.com"
assert target["port"] == 443
def test_a_hand_edit_is_an_absence_not_a_marker(gateway_snapshot) -> None:
"""`hand_edited` is the operator's other half of the switch: the haproxy line is gone
(the route is off, nothing to mark), the vhost line is plain (on, `disabled: false`),
and the map value is deleted (the entry is there, its target is null, not marked)."""
snap = gateway_snapshot("hand_edited")
assert _haproxy_server(snap, "gitea") is None, "the deleted line is gone"
entry = _nginx_proxy_pass(snap, "nextcloud.reeselink.com", 11000)
assert entry is not None
assert entry["disabled"] is False
mapped = _nginx_map_entry(snap, "brain.reeseapps.com")
assert mapped is not None
assert mapped["target"] is None
assert mapped["disabled"] is False
+1011 -105
View File
File diff suppressed because it is too large Load Diff
+9 -6
View File
@@ -59,8 +59,10 @@ PROTECTED_API = (
"/api/disks/history", "/api/disks/history",
# Phase 29: the ingress's pipes. `…/snapshot` carries the gateway's whole routing map — every # 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 # 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. # bundle download is, whatever the payload happens to hold today. Phase 33 task 05 adds the
"/api/gateway/snapshot", "/api/gateway/history", # 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/llm/snapshot", "/api/llm/history", "/api/ups/snapshot",
"/api/ups/history", "/api/ups/commands", "/api/auth/status", "/api/ups/history", "/api/ups/commands", "/api/auth/status",
# Phase 15: the certificate authority's own surface. `/api/certs/<name>/bundle` is every # 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") "/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). #: request that reaches the route answers 404 — that is the point).
WRITE = {"ups": "apc", "command": "beeper.disable", "confirm": "yes"} 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. """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 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 `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. 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) 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)] 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 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} path for path, methods in served if "POST" in methods}
for path in gated: for path in gated:
page = anon.get(path, headers=BROWSER_HEADERS) page = anon.get(path, headers=BROWSER_HEADERS)
+33
View File
@@ -124,6 +124,39 @@ def test_the_local_testing_password_is_prose_and_never_an_assignment(template: P
) )
#: The write gate phase 33 added. The pin is the **declaration** — a comment that names
#: the variable (with its default and what enabling it authorises) — never an assignment:
#: a value in a tracked template is a decision made for everyone who copies the file, and
#: the configuration table refuses that trap (the two writes are two decisions, and one
#: flag does not carry the other).
WRITE_GATES = ("GATEWAY_ALLOW_WRITES",)
@pytest.mark.parametrize("template", TEMPLATES, ids=lambda p: p.name)
def test_every_write_gate_is_declared_and_never_assigned_in_the_template(template: Path) -> None:
"""A write gate no template names is a gate nobody finds.
The declaration is a comment (a commented-out line, or prose naming the variable with
its default and what enabling it authorises). An *active* assignment in a tracked
template is the gate armed by default for everyone who copies the file —
`GATEWAY_ALLOW_WRITES=1` in a template is a route switch waiting in a fresh
deployment.
"""
text = template.read_text()
for name in WRITE_GATES:
commented = [ln for ln in text.splitlines()
if name in ln and ln.lstrip().startswith("#")]
assert commented, (
f"{template.name} never names {name} in a comment — a write gate nobody finds "
"is a gate whose default nobody can see"
)
active = re.search(rf"^[ \t]*(?:export[ \t]+)?{name}[ \t]*=(?!=)", text, re.MULTILINE)
assert active is None, (
f"{template.name} actively assigns {name} — a value in a tracked template is a "
"decision for everyone who copies it; the pin is the declaration, never a value"
)
def test_the_dev_template_points_at_where_the_local_password_lives() -> None: def test_the_dev_template_points_at_where_the_local_password_lives() -> None:
"""A dev copying the template needs to know what to type at the first login prompt.""" """A dev copying the template needs to know what to type at the first login prompt."""
text = ENV_DEV_EXAMPLE.read_text().lower() text = ENV_DEV_EXAMPLE.read_text().lower()
+201
View File
@@ -33,6 +33,7 @@ from dashboards.collectors import (
fetch_snapshot, fetch_snapshot,
gateway_job, gateway_job,
host_jobs, host_jobs,
post_route,
) )
from dashboards.config import Settings, Target from dashboards.config import Settings, Target
from dashboards.poller import Result from dashboards.poller import Result
@@ -891,6 +892,206 @@ async def test_a_token_mode_snapshot_gains_no_tls_fields() -> None:
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# post_route (the app's second upstream write, phase 33 task 05)
# --------------------------------------------------------------------------- #
def recorded_post(handler: Any) -> tuple[httpx.AsyncClient, list[httpx.Request]]:
"""The `post_route` shape of `client_for`: a scripted answer, every request kept."""
seen: list[httpx.Request] = []
def recording(request: httpx.Request) -> httpx.Response:
seen.append(request)
return handler(request)
return httpx.AsyncClient(transport=httpx.MockTransport(recording)), seen
async def test_post_route_posts_the_wire_body_and_returns_the_helpers_object_verbatim() -> None:
"""The wire is task 01's `--serve` contract: `id`, `action`, `dry_run`, and nothing else.
`danger` is the dashboard's typed confirmation, not a wire key — the agent's writer takes a
body with no `danger`, and a header or field smuggled in is a contract break the agent would
answer with its own 422. The answer comes back untouched: an `applied: false` must not
become a success on the way over (rule 2), so the object is handed over, not rebuilt.
"""
client, seen = recorded_post(lambda request: httpx.Response(
200, json={"applied": True, "changed": True, "reloaded": True}))
async with client:
answer = await post_route(client, Target("gateway", "http://agent.test"),
{"id": "brain.reeseapps.com", "action": "disable",
"dry_run": False},
timeout=35.0)
assert answer == {"applied": True, "changed": True, "reloaded": True}
assert len(seen) == 1
request = seen[0]
assert request.method == "POST" and request.url.path == "/route"
assert json.loads(request.content) == {"id": "brain.reeseapps.com",
"action": "disable", "dry_run": False}
assert "authorization" not in {key.lower() for key in request.headers}, (
"the plain path carries only the target's own headers")
async def test_post_route_sends_the_token_on_the_token_path() -> None:
"""A host nobody has bundled dials the compatibility way: the header, exactly as the read sends it.
`parse_targets` is what moves the userinfo token into the header; a directly built target
hands `post_route` the same shape, so the pin is the header arriving on the wire.
"""
client, seen = recorded_post(lambda request: httpx.Response(
200, json={"applied": False, "changed": False}))
target = Target("gateway", "http://agent.test",
headers={"Authorization": "Bearer SEKRIT-AGENT-TOKEN"})
async with client:
await post_route(client, target, {"id": "r", "action": "enable", "dry_run": True},
timeout=35.0)
assert seen[0].headers["Authorization"] == "Bearer SEKRIT-AGENT-TOKEN"
async def test_post_route_sends_no_authorization_on_the_certificate_path() -> None:
"""The bundle is the credential on a `https://` target, and the write is stricter than the read.
`parse_targets` has already moved any token into the header, so a bundled host's target has a
clean URL and no header — and the write must add nothing of its own: a header the read would
have sent (a compatibility host's token) is a second opinion the write refuses, so the
certificate path sends the body and the certificate and nothing else.
"""
seen: list[list[tuple[bytes, bytes]]] = []
def handler(request: httpx.Request) -> httpx.Response:
seen.append(request.headers.raw)
return httpx.Response(200, json={"applied": False, "dry_run": True, "changed": True})
tls = AgentTls(verify="/unused/root.pem", cert=("/unused/cert.pem", "/unused/key.pem"),
status=lambda: certs.ACTIVE, transport=httpx.MockTransport(handler))
async with client_for(lambda request: httpx.Response(200, json={})) as shared:
answer = await post_route(shared, Target("gateway", "https://agent.test"),
{"id": "r", "action": "disable", "dry_run": True},
timeout=35.0, tls=tls)
assert answer["dry_run"] is True
assert len(seen) == 1
assert not any(key == b"authorization" for key, _ in seen[0]), (
"a token next to a certificate is the second opinion the write refuses")
class PostTimeoutRecorder(TimeoutRecordingClient):
"""The same seam as `TimeoutRecordingClient`, for the write's own budget argument."""
async def post(self, url: Any, **kwargs: Any) -> httpx.Response: # type: ignore[override]
self.timeouts.append(kwargs.get("timeout", "<unset>"))
return await super().post(url, **kwargs)
async def test_post_route_carries_the_write_budget_and_never_a_bare_none() -> None:
"""`GATEWAY_WRITE_TIMEOUT_SECONDS` is the write's own 35 s, passed per request.
A `None` timeout is *no timeout at all* to httpx: a helper stuck on a wedged `systemctl
reload` would hold a request open for good. The sentinel keeps the client default, a number
keeps the write's own budget.
"""
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, json={"applied": False, "changed": False})
client = PostTimeoutRecorder(transport=httpx.MockTransport(handler),
timeout=10.0, base_url="http://agent.test")
async with client:
await post_route(client, Target("gw", "http://agent.test"),
{"id": "r", "action": "disable", "dry_run": False}, timeout=35.0)
await post_route(client, Target("gw", "http://agent.test"),
{"id": "r", "action": "disable", "dry_run": False})
assert client.timeouts[-2:] == [35.0, httpx.USE_CLIENT_DEFAULT]
async def test_post_route_timeout_is_a_504_that_names_the_redacted_url() -> None:
"""The message lands in the event table and the browser reads it (invariant 6): the URL is
the redacted one and the status is the 504 a timeout is, so a slow helper is not a 502.
"""
def handler(request: httpx.Request) -> httpx.Response:
raise httpx.ReadTimeout("read timed out")
target = Target("gw", "http://SEKRIT-AGENT-TOKEN@agent.test")
async with client_for(handler) as client:
with pytest.raises(UpstreamError) as caught:
await post_route(client, target, {"id": "r", "action": "disable", "dry_run": False})
assert caught.value.status == 504
assert "SEKRIT-AGENT-TOKEN" not in caught.value.message
assert "agent.test/route" in caught.value.message
assert "timed out" in caught.value.message
async def test_post_route_unreachable_is_a_502_with_the_redacted_url() -> None:
def handler(request: httpx.Request) -> httpx.Response:
raise httpx.ConnectError("no route to host")
target = Target("gw", "https://SEKRIT-AGENT-TOKEN@agent.test")
tls = AgentTls(verify="/unused/root.pem", cert=("/unused/cert.pem", "/unused/key.pem"),
status=lambda: certs.ACTIVE, transport=httpx.MockTransport(handler))
async with client_for(lambda request: httpx.Response(200, json={})) as client:
with pytest.raises(UpstreamError) as caught:
await post_route(client, target, {"id": "r", "action": "enable", "dry_run": False},
tls=tls)
assert caught.value.status == 502
assert "SEKRIT-AGENT-TOKEN" not in caught.value.message
async def test_post_route_401_on_the_token_path_is_about_the_token() -> None:
"""A 401 the token path gets is the rotated-token case: the sentence says so, and the URL
is still the redacted one."""
async with client_for(lambda request: httpx.Response(401, json={"detail": "bad token"})) as client:
with pytest.raises(UpstreamError) as caught:
await post_route(client, Target("gw", "http://SEKRIT-AGENT-TOKEN@agent.test"),
{"id": "r", "action": "disable", "dry_run": False})
assert "rejected the token (401)" in caught.value.message
assert "SEKRIT-AGENT-TOKEN" not in caught.value.message
async def test_post_route_refusal_carries_the_helpers_own_word() -> None:
"""A `>= 400` answer is the agent's own refusal — a `403` here is the write gate's own word
(`writes are not enabled on this agent`), and paraphrasing it would be the dashboard
claiming to know more than the peer said."""
async with client_for(lambda request: httpx.Response(
409, json={"detail": "no such id: brain.reeseapps.com"})) as client:
with pytest.raises(UpstreamError) as caught:
await post_route(client, Target("gateway", "http://agent.test"),
{"id": "brain.reeseapps.com", "action": "disable", "dry_run": False})
assert caught.value.status == 502
assert "HTTP 409" in caught.value.message
assert "no such id: brain.reeseapps.com" in caught.value.message
async def test_post_route_an_unreadable_answer_is_an_error_with_a_bounded_slice() -> None:
async with client_for(lambda request: httpx.Response(
200, text="not json at all " + "x" * 200)) as client:
with pytest.raises(UpstreamError, match="unreadable") as caught:
await post_route(client, Target("gateway", "http://agent.test"),
{"id": "r", "action": "disable", "dry_run": False})
assert "x" * 200 not in caught.value.message, "the slice is bounded, not the whole body"
async with client_for(lambda request: httpx.Response(200, json=[1, 2])) as client:
with pytest.raises(UpstreamError, match="non-object"):
await post_route(client, Target("gateway", "http://agent.test"),
{"id": "r", "action": "disable", "dry_run": False})
async def test_post_route_refuses_a_revoked_agent_before_any_dial() -> None:
"""The revocation gate is in front of the network, for the write as for the read.
The scripted transport records what it receives, and a `status` callable that answers
`REVOKED` is what `certs.agent_status` would answer after the operator's click: the
refusal must come before the transport sees a request, not after.
"""
seen: list[httpx.Request] = []
def handler(request: httpx.Request) -> httpx.Response:
seen.append(request)
return httpx.Response(200, json={"applied": True})
tls = AgentTls(verify="/unused/root.pem", cert=("/unused/cert.pem", "/unused/key.pem"),
status=lambda: certs.REVOKED, transport=httpx.MockTransport(handler))
async with client_for(lambda request: httpx.Response(200, json={})) as client:
with pytest.raises(UpstreamError, match="agent certificate revoked") as caught:
await post_route(client, Target("gateway", "https://agent.test"),
{"id": "r", "action": "disable", "dry_run": False}, tls=tls)
assert seen == [], "a revoked host must not be written"
assert "revoked" in caught.value.message
# LLM engines # LLM engines
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
GUFO = (FIXTURES / "metrics_gufo.txt").read_text() GUFO = (FIXTURES / "metrics_gufo.txt").read_text()
+39
View File
@@ -135,6 +135,45 @@ def test_the_gateway_settings_come_from_the_environment(monkeypatch: pytest.Monk
importlib.reload(config) importlib.reload(config)
def test_the_gateway_write_is_off_by_default_and_budgets_are_their_own() -> None:
"""Phase 33 task 05: the flag is a *decision*, and its default is the safe one.
`GATEWAY_ALLOW_WRITES` is `NUT_ALLOW_ACTIONS`'s sibling: the same truthy rule, the same
default, and its own budget. The read's 10 s is the read's alone; the write's 35 s default
is the write's own, and the two never share a number.
"""
settings = Settings()
assert settings.gateway_allow_writes is False
assert settings.gateway_write_timeout == 35.0
assert settings.gateway_timeout == 10.0
def test_the_gateway_write_settings_come_from_the_environment(
monkeypatch: pytest.MonkeyPatch) -> None:
"""`GATEWAY_ALLOW_WRITES=1` is the only way the flag turns on, the same rule as the NUT one."""
monkeypatch.setenv("GATEWAY_ALLOW_WRITES", "1")
monkeypatch.setenv("GATEWAY_WRITE_TIMEOUT_SECONDS", "12.5")
reloaded = importlib.reload(config)
try:
assert reloaded.SETTINGS.gateway_allow_writes is True
assert reloaded.SETTINGS.gateway_write_timeout == 12.5
finally:
for name in ("GATEWAY_ALLOW_WRITES", "GATEWAY_WRITE_TIMEOUT_SECONDS"):
monkeypatch.delenv(name)
importlib.reload(config)
def test_the_gateway_flag_uses_the_same_truthy_rule_as_every_other_flag(
monkeypatch: pytest.MonkeyPatch) -> None:
"""The rule is `_flag`'s: the two writes are two decisions, the same shape (task 05)."""
for truthy in ("1", "true", "TRUE", "yes", "on"):
monkeypatch.setenv("GATEWAY_ALLOW_WRITES", truthy)
assert config._flag("GATEWAY_ALLOW_WRITES") is True, truthy
for falsey in ("0", "false", "", "no"):
monkeypatch.setenv("GATEWAY_ALLOW_WRITES", falsey)
assert config._flag("GATEWAY_ALLOW_WRITES") is False, falsey
def test_the_disk_source_defaults_to_a_five_minute_cadence() -> None: def test_the_disk_source_defaults_to_a_five_minute_cadence() -> None:
"""SMART is not a per-second signal (phase 09), and the fetch waits for the agent's """SMART is not a per-second signal (phase 09), and the fetch waits for the agent's
own 20 s smartctl budget. Both are env-only, like every other interval.""" own 20 s smartctl budget. Both are env-only, like every other interval."""
+383 -1
View File
@@ -30,8 +30,10 @@ import importlib.util
import ipaddress import ipaddress
import json import json
import os import os
import pwd
import re import re
import shutil import shutil
import socket
import stat import stat
import subprocess import subprocess
import sys import sys
@@ -367,7 +369,7 @@ def test_no_shell_function_is_named_after_a_coreutil_it_could_be_asked_to_run(sc
#: The commands of `install()` that would touch the machine running pytest. Each one is a #: The commands of `install()` that would touch the machine running pytest. Each one is a
#: PATH stub that records its arguments and succeeds — the same trick `ScriptedNutClient` is #: PATH stub that records its arguments and succeeds — the same trick `ScriptedNutClient` is
#: for the UPS wire: fake the privileged boundary, run the real logic. #: for the UPS wire: fake the privileged boundary, run the real logic.
ROOT_BOUNDARY_STUBS = ("id", "getent", "useradd", "usermod", "chown", "systemctl") ROOT_BOUNDARY_STUBS = ("id", "getent", "useradd", "usermod", "chown", "systemctl", "sudo")
#: The three absolute destinations the installer writes to, rewritten into a temp directory #: The three absolute destinations the installer writes to, rewritten into a temp directory
#: so the test exercises the body instead of the operator's `/usr` and `/etc`. #: so the test exercises the body instead of the operator's `/usr` and `/etc`.
@@ -376,6 +378,16 @@ SANDBOX_REWRITES = {
'TOKEN_FILE="/etc/dashboards-agent.token"': 'TOKEN_FILE="{root}/token"', 'TOKEN_FILE="/etc/dashboards-agent.token"': 'TOKEN_FILE="{root}/token"',
'CERT_FILE="/etc/dashboards-agent.certs"': 'CERT_FILE="{root}/certs"', 'CERT_FILE="/etc/dashboards-agent.certs"': 'CERT_FILE="{root}/certs"',
'UNIT="/etc/systemd/system/$SERVICE.service"': 'UNIT="{root}/dashboards-agent.service"', 'UNIT="/etc/systemd/system/$SERVICE.service"': 'UNIT="{root}/dashboards-agent.service"',
'ROUTECTL_TARGET="/usr/local/sbin/dashboards-routectl.py"':
'ROUTECTL_TARGET="{root}/sbin/dashboards-routectl.py"',
'ROUTECTL_SOCKET_UNIT="/etc/systemd/system/dashboards-routectl.socket"':
'ROUTECTL_SOCKET_UNIT="{root}/dashboards-routectl.socket"',
'ROUTECTL_SERVICE_UNIT="/etc/systemd/system/dashboards-routectl@.service"':
'ROUTECTL_SERVICE_UNIT="{root}/dashboards-routectl@.service"',
'ROUTECTL_SOCKET_PATH="/run/dashboards-routectl.sock"':
'ROUTECTL_SOCKET_PATH="{root}/run/dashboards-routectl.sock"',
'ROUTECTL_STATE_DIR="/var/lib/dashboards-routectl"':
'ROUTECTL_STATE_DIR="{root}/var-lib/dashboards-routectl"',
} }
@@ -396,6 +408,11 @@ def _sandbox_installer(tmp_path: Path) -> tuple[Path, Path, dict[str, str]]:
script.write_text(text) script.write_text(text)
(tmp_path / "agent").mkdir() (tmp_path / "agent").mkdir()
(tmp_path / "agent" / "host_agent.py").write_text("# stub agent source\n", encoding="utf-8") (tmp_path / "agent" / "host_agent.py").write_text("# stub agent source\n", encoding="utf-8")
# The routectl sources resolve as `$SCRIPT_DIR/…`, so the real files land next to the
# copy: the guard's `[ -f "$_src" ]` check and the re-run `cmp` both need them.
for _name in ("dashboards-routectl.py", "dashboards-routectl.socket",
"dashboards-routectl@.service"):
shutil.copyfile(DEPLOY / _name, script.parent / _name)
stubs = tmp_path / "stubs" stubs = tmp_path / "stubs"
stubs.mkdir() stubs.mkdir()
@@ -2980,3 +2997,368 @@ def test_the_socket_capture_is_idempotent_so_a_re_run_does_not_churn(tmp_path: P
assert result.returncode == 0, result.stdout + result.stderr assert result.returncode == 0, result.stdout + result.stderr
assert _fixture_body(again[1] / "gateway_proc_net_tcp.txt") == _fixture_body( assert _fixture_body(again[1] / "gateway_proc_net_tcp.txt") == _fixture_body(
once / "tests" / "fixtures" / "gateway_proc_net_tcp.txt") once / "tests" / "fixtures" / "gateway_proc_net_tcp.txt")
# --- the route-switch helper: --with-routectl / --without-routectl (phase 33 task 02) --------
#: The install that adds the helper and its two units, at the sandbox's default bind/port.
ROUTECTL_FLAGS = ("--bind", "10.0.0.5", "--port", "9120", "--with-routectl")
def _target(tmp_path: Path, where: str = "") -> Path:
return tmp_path / where / "target"
@pytest.mark.skipif(hasattr(os, "geteuid") and os.geteuid() == 0, reason="running as root")
def test_with_routectl_installs_helper_and_units_and_leaves_the_agent_unit_byte_identical(
tmp_path: Path
) -> None:
"""The phase's central safety claim, as a bytes comparison.
Two sandboxes, the same flags, one with the helper: the rendered `dashboards-agent.service`
must be byte-identical — the temptation to "help" the agent reach the socket by relaxing
its sandbox is exactly what this task exists to close, and the comparison is the pin. The
helper and both units land in the other sandbox, byte-identical to the deployed copies, and
the install ends with the socket's activation and the agent-uid reachability check.
"""
plain_script, _plain_log, plain_env = _sandbox_installer(tmp_path / "plain")
plain = _install(plain_script, plain_env, "--bind", "10.0.0.5", "--port", "9120")
assert plain.returncode == 0, f"plain install failed:\n{plain.stdout}\n{plain.stderr}"
rc_script, rc_log, rc_env = _sandbox_installer(tmp_path / "rc")
rc = _install(rc_script, rc_env, *ROUTECTL_FLAGS)
assert rc.returncode == 0, f"the routectl install failed:\n{rc.stdout}\n{rc.stderr}"
plain_unit = (
_target(tmp_path, "plain") / "dashboards-agent.service"
).read_text().replace(str(_target(tmp_path, "plain")), "<ROOT>")
rc_unit = (
_target(tmp_path, "rc") / "dashboards-agent.service"
).read_text().replace(str(_target(tmp_path, "rc")), "<ROOT>")
assert plain_unit == rc_unit, "the agent unit must be byte-identical with and without --with-routectl"
target = _target(tmp_path, "rc")
helper = target / "sbin" / "dashboards-routectl.py"
socket_unit = target / "dashboards-routectl.socket"
service_unit = target / "dashboards-routectl@.service"
assert helper.is_file() and socket_unit.is_file() and service_unit.is_file()
assert helper.read_text() == (DEPLOY / "dashboards-routectl.py").read_text(), (
"the installed helper is a copy, not a re-render"
)
assert socket_unit.read_text() == (DEPLOY / "dashboards-routectl.socket").read_text()
assert service_unit.read_text() == (DEPLOY / "dashboards-routectl@.service").read_text()
calls = rc_log.read_text()
assert "systemctl enable --now dashboards-routectl.socket" in calls
assert "sudo -u dashboards-agent python3" in calls, "the reachability check runs as the agent uid"
assert str(target / "run" / "dashboards-routectl.sock") in calls, (
"the check connects the sandbox's socket path, as the agent uid"
)
def _unit_lines(path: Path) -> list[str]:
return [line.rstrip() for line in path.read_text().splitlines() if line.strip()]
@pytest.mark.skipif(hasattr(os, "geteuid") and os.geteuid() == 0, reason="running as root")
def test_allow_writes_adds_only_the_flag_to_the_agent_unit(tmp_path: Path) -> None:
"""The write path's one unit change is one argument, and nothing else moves.
Task 07's guard says a re-run diff that shows `NoNewPrivileges`, `ProtectSystem`,
`PrivateDevices` or `SystemCallFilter` moving is a stop, not a `--force` — so this is
the same diff in the sandbox: with `--allow-writes`, the generated unit differs from
the plain install's exactly in the `ExecStart` line, and every security directive is
byte-identical. The flag lands on the unit's own ExecStart, because the agent (not
the installer) is the one that must refuse a write it was not started for.
"""
script, _log, env = _sandbox_installer(tmp_path)
plain = _install(script, env, "--bind", "10.0.0.5", "--port", "9120")
assert plain.returncode == 0
plain_unit = _target(tmp_path) / "dashboards-agent.service"
plain_root = str(_target(tmp_path))
script2, _log2, env2 = _sandbox_installer(tmp_path / "w")
writes = _install(script2, env2, "--bind", "10.0.0.5", "--port", "9120",
"--allow-writes", "--with-routectl")
assert writes.returncode == 0, f"--allow-writes install failed:\n{writes.stderr}"
writes_unit = _target(tmp_path, "w") / "dashboards-agent.service"
plain_lines = _unit_lines(plain_unit)
writes_lines = _unit_lines(writes_unit)
writes_root = str(_target(tmp_path, "w"))
plain_exec = [line_ for line_ in plain_lines if line_.startswith("ExecStart=")]
writes_exec = [line_ for line_ in writes_lines if line_.startswith("ExecStart=")]
assert len(plain_exec) == 1 and len(writes_exec) == 1
writes_exec[0] = writes_exec[0].replace(writes_root, "<ROOT>")
plain_exec[0] = plain_exec[0].replace(plain_root, "<ROOT>")
assert writes_exec[0] == plain_exec[0] + " --allow-writes", (
"the flag is appended to ExecStart, nothing else changes there"
)
assert len(plain_lines) == len(writes_lines), "no other line is added or removed"
for line_ in plain_lines:
if line_.startswith("ExecStart="):
continue
assert line_.replace(plain_root, "<ROOT>") in [
other.replace(writes_root, "<ROOT>") for other in writes_lines
], f"the write path moved a unit line: {line_}"
# The four directives task 07's guard names: they must be byte-identical, not moved.
# `PrivateDevices` is `false` by documented decision (the unit carries no device filter
# at all — see the template's comment), so the pin is on the value that is there.
for directive in ("NoNewPrivileges=true", "ProtectSystem=strict",
"PrivateDevices=false", "SystemCallFilter=@system-service"):
assert directive in writes_unit.read_text(), f"the write path moved {directive}"
assert directive in plain_unit.read_text(), f"the baseline unit lacks {directive}"
# And a flag without a helper is a legitimate host state: it installs, and the agent
# answers a write with 503 rather than a write — the installer has nothing to refuse.
script3, _log3, env3 = _sandbox_installer(tmp_path / "w2")
solo = _install(script3, env3, "--bind", "10.0.0.5", "--port", "9120", "--allow-writes")
assert solo.returncode == 0, f"--allow-writes without --with-routectl must install:\n{solo.stderr}"
assert " --allow-writes" in _target(tmp_path, "w2").joinpath(
"dashboards-agent.service").read_text()
# Non-install modes do not render a unit, so the flag refuses there before any write.
result = _install(script, env, "--status", "--allow-writes")
assert result.returncode == 1
assert "needs an install" in result.stderr
def _serve_on_socket(tmp_path: Path, *, allow_uid: str,
request: bytes) -> tuple[int, str]:
"""Run the real helper as a subprocess the unit does: a connected AF_UNIX socket on
fd 0/fd 1 (`Accept=yes` + `StandardInput=socket`), `--allow-uid`, one request, one
answer line. Returns `(returncode, answer line)` — the process's exit code is the
gate's verdict as much as the line is."""
child_side, parent_side = socket.socketpair(socket.AF_UNIX, socket.SOCK_STREAM)
proc = subprocess.Popen(
[sys.executable, str(DEPLOY / "dashboards-routectl.py"), "--serve",
"--allow-uid", allow_uid,
"--agent-module", str(ROOT / "agent" / "host_agent.py"),
"--state", str(tmp_path / "serve-state.json")],
stdin=child_side.fileno(), stdout=child_side.fileno(),
stderr=subprocess.PIPE, close_fds=True)
parent_side.sendall(request)
answer = parent_side.recv(65536).decode("utf-8").strip()
parent_side.close()
proc.wait(timeout=60)
child_side.close()
err = proc.stderr.read().decode() if proc.stderr is not None else ""
if proc.returncode not in (0, 1):
raise AssertionError(
f"the helper exited {proc.returncode} instead of answering:\n"
f"{answer}\n{err}")
return proc.returncode, answer
@pytest.mark.skipif(hasattr(os, "geteuid") and os.geteuid() == 0, reason="running as root")
def test_serve_answers_one_request_on_the_socket_stdin(tmp_path: Path) -> None:
"""The live box's transport, pinned: with `Accept=yes` + `StandardInput=socket`,
the accepted connection lands on fd 0, and `serve()` must find it there (fd 3 first —
the classic contract — then fd 0), name its peer, and answer one request. The
first live install (phase 33 task 07) answered a real connection with
`peer: no connected socket on this transport` because only fd 3 was looked at;
the same task found the box's kernel delivers `SO_PEERCRED` reversed
(`{pid, uid, gid}`) and `read(2)` on a socket fd that does not wake on
buffered data (`recv(2)` does) — which is why the helper calibrates the uid
slot with a same-process probe and reads the socket path with `recv`."""
me = pwd.getpwuid(os.geteuid()).pw_name
code, answer = _serve_on_socket(
tmp_path, allow_uid=me,
request=b'{"id": "haproxy:80->127.0.0.1:443", "action": "show"}\n')
body = json.loads(answer)
assert isinstance(body, dict), (
f"the answer is one JSON object on the wire (one write, no double): {answer!r}")
assert body.get("ok") is not None, f"the named peer's request was not answered: {answer}"
assert "peer:" not in str(body.get("detail", "")), (
f"the named peer's request was refused by the gate: {answer}")
assert code in (0, 1)
@pytest.mark.skipif(hasattr(os, "geteuid") and os.geteuid() == 0, reason="running as root")
def test_serve_refuses_a_wrong_peer_and_a_transport_without_one(
tmp_path: Path) -> None:
"""The second look fails closed in both directions: a named peer that is not
`--allow-uid` is a refusal, and a transport whose caller the kernel will not name
(`/dev/null` on stdin) is a refusal too — the gate never opens on an unnamed caller."""
me = pwd.getpwuid(os.geteuid()).pw_name
_code, answer = _serve_on_socket(tmp_path, allow_uid="root",
request=b'{"verb": "list"}\n')
body = json.loads(answer)
assert body["ok"] is False and "peer:" in body["detail"], (
f"a foreign uid must not pass the gate: {answer}")
# the wire's own gate (a correctly-peered caller): a wider body is a
# refusal with the wire's own sentence — and the answer is a JSON
# *object*, not the (dict, code) tuple a caller-side inversion once
# serialised as a list (phase 33 task 07 caught it on the live chain).
_code, answer = _serve_on_socket(tmp_path, allow_uid=me,
request=b'{"verb": "list"}\n')
body = json.loads(answer)
assert body["ok"] is False, f"a wider body must be refused: {answer}"
assert body["detail"] == "body: exactly {id, action, dry_run}", (
f"the refusal must carry the wire's own sentence: {answer}")
proc = subprocess.Popen(
[sys.executable, str(DEPLOY / "dashboards-routectl.py"), "--serve",
"--allow-uid", me, "--agent-module", str(ROOT / "agent" / "host_agent.py"),
"--state", str(tmp_path / "pipe-state.json")],
stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
close_fds=True)
proc.wait(timeout=60)
out = proc.stdout.read().decode("utf-8").strip() if proc.stdout is not None else ""
assert proc.returncode == 1, (
f"an unnamed caller must exit refused (1), not answered: rc={proc.returncode} out={out!r}")
assert out == "", (
f"a pipe transport has no wire; the refusal is the exit code, not a line: {out!r}")
@pytest.mark.skipif(hasattr(os, "geteuid") and os.geteuid() == 0, reason="running as root")
def test_the_units_carry_the_grant_as_directives(tmp_path: Path) -> None:
"""The file-layer grant is a directive systemd enforces; the sandbox pins the lines.
`systemd-analyze verify` is not available in the sandbox, so the assertions are on the
directive lines: the socket is 0600 and agent-owned (the whole access control at the file
layer), the service is root with the socket on stdin and the one-state-directory
`StateDirectory`, and `ReadWritePaths` names every directory whose file the helper
replaces — `/etc/haproxy`, `/etc/nginx/conf.d`, `/etc/nginx/stream.d` — and nothing else
(the main `nginx.conf` is never written, so it must not be listed).
"""
script, _log, env = _sandbox_installer(tmp_path)
result = _install(script, env, *ROUTECTL_FLAGS)
assert result.returncode == 0, f"install failed:\n{result.stdout}\n{result.stderr}"
target = _target(tmp_path)
socket_unit = (target / "dashboards-routectl.socket").read_text()
# Without `Accept=yes` systemd would look for a `dashboards-routectl.service` that does
# not exist (a `Service=` line naming the template does not load on systemd 257) and
# refuse to listen — the live box's first install, phase 33 task 07.
assert _directive(socket_unit, "Accept") == ["Accept=yes"]
assert _directive(socket_unit, "ListenStream") == ["ListenStream=/run/dashboards-routectl.sock"]
assert _directive(socket_unit, "SocketMode") == ["SocketMode=0600"], (
"0600 + SocketUser is the whole access control at the file layer"
)
assert _directive(socket_unit, "SocketUser") == ["SocketUser=dashboards-agent"]
assert _directive(socket_unit, "SocketGroup") == ["SocketGroup=dashboards-agent"]
assert "WantedBy=sockets.target" in socket_unit
service = (target / "dashboards-routectl@.service").read_text()
assert _directive(service, "User") == ["User=root"]
assert "StandardInput=socket" in service and "StandardOutput=socket" in service, (
"one connection, one instance: the connected socket is stdin"
)
assert _directive(service, "StateDirectory") == ["StateDirectory=dashboards-routectl"]
assert _directive(service, "ProtectSystem") == ["ProtectSystem=read-only"]
assert _directive(service, "ReadWritePaths") == [
"ReadWritePaths=/etc/haproxy /etc/nginx/conf.d /etc/nginx/stream.d"
], "os.replace needs write on the directory: every one listed, and nothing the helper never writes"
assert _directive(service, "NoNewPrivileges") == ["NoNewPrivileges=true"], (
"the privilege comes from systemd starting it as root, not from escalation"
)
assert _directive(service, "SystemCallFilter") == ["SystemCallFilter=@system-service"]
assert _directive(service, "ProtectHome") == ["ProtectHome=read-only"]
assert _directive(service, "PrivateTmp") == ["PrivateTmp=yes"]
exec_lines = _directive(service, "ExecStart")
assert len(exec_lines) == 1 and exec_lines[0].startswith(
"ExecStart=/usr/local/sbin/dashboards-routectl.py --serve"
)
assert "--allow-uid dashboards-agent" in service, "the second look: the peer uid the helper accepts"
assert "--state /var/lib/dashboards-routectl/state.json" in service
# The on-disk modes the sandbox's real `install -m` applied.
assert stat.S_IMODE((target / "sbin" / "dashboards-routectl.py").stat().st_mode) == 0o755, (
"the helper is world-executable: the grant is the socket, not the file mode"
)
assert stat.S_IMODE((target / "dashboards-routectl.socket").stat().st_mode) == 0o644
assert stat.S_IMODE((target / "dashboards-routectl@.service").stat().st_mode) == 0o644
@pytest.mark.skipif(hasattr(os, "geteuid") and os.geteuid() == 0, reason="running as root")
def test_without_routectl_removes_the_units_and_not_the_config_or_the_state(tmp_path: Path) -> None:
"""The inverse keeps what the removal must keep.
The proxy configs and the helper's state directory are the operator's memory of what was
switched, so `--without-routectl` prints the still-off list first, removes the helper and
its two units, and leaves the state (and the configs) behind, ending with the reminder.
"""
script, call_log, env = _sandbox_installer(tmp_path)
assert _install(script, env, *ROUTECTL_FLAGS).returncode == 0
target = _target(tmp_path)
state_dir = target / "var-lib" / "dashboards-routectl"
state_dir.mkdir(parents=True)
state_dir.joinpath("state.json").write_text('{"nginx:443->127.0.0.1:44450": {}}', encoding="utf-8")
result = _install(script, env, "--without-routectl")
assert result.returncode == 0, f"--without-routectl failed:\n{result.stdout}\n{result.stderr}"
assert not (target / "dashboards-routectl.socket").exists(), "the socket unit must be removed"
assert not (target / "dashboards-routectl@.service").exists(), "the service unit must be removed"
assert not (target / "sbin" / "dashboards-routectl.py").exists(), "the helper must be removed"
assert (state_dir / "state.json").is_file(), "the state is the memory of what was switched; it stays"
calls = call_log.read_text()
assert "systemctl disable --now dashboards-routectl.socket" in calls
assert "daemon-reload" in calls
out = result.stdout
assert "routes the helper has switched off" in out, "the still-off list prints first"
assert "still off" in out, "the reminder ends the removal, not the file list"
@pytest.mark.skipif(hasattr(os, "geteuid") and os.geteuid() == 0, reason="running as root")
def test_the_with_and_without_flags_do_not_mix(tmp_path: Path) -> None:
"""One flag each direction, before any write."""
script, call_log, env = _sandbox_installer(tmp_path)
result = _install(script, env, "--with-routectl", "--without-routectl")
assert result.returncode == 1
assert result.stdout == ""
assert "one flag each direction" in result.stderr
assert not call_log.exists(), "refused before a single privileged command"
@pytest.mark.skipif(hasattr(os, "geteuid") and os.geteuid() == 0, reason="running as root")
def test_with_routectl_needs_an_install(tmp_path: Path) -> None:
"""`--status` probes a port; `--uninstall` deletes the agent. Neither installs files."""
script, _log, env = _sandbox_installer(tmp_path)
result = _install(script, env, "--status", "--with-routectl")
assert result.returncode == 1
assert "needs an install" in result.stderr
def test_no_new_sudoers_line_exists() -> None:
"""The grant is a file mode systemd enforces; the pin is the absence of a sudoers rule.
`deploy/agent-sudoers` is still exactly one line (the staged installer), neither of the
two new units names sudo, and the operator page says the sentence out loud.
"""
assert _grant_lines(SUDOERS.read_text()) == [CANONICAL_GRANT], (
"phase 33 adds a write without adding a sudoers line; a new rule is the regression"
)
for name in ("dashboards-routectl.socket", "dashboards-routectl@.service"):
text = (DEPLOY / name).read_text()
# `sudo\b` is the word sudo by itself: the units' own "not a sudoers line" comment
# (the negation that is the point) does not match, a grant would.
assert not re.search(r"sudo\b", text.lower()), f"{name} grants nothing by sudoers"
block = INSTALLER.read_text().split("routectl_off() {", 1)
assert len(block) == 2, "the --without-routectl path is a function"
assert "sudoers" not in block[1].split("\n}\n", 1)[0].lower(), (
"the removal path must not touch sudoers either"
)
readme = (DEPLOY / "gateway-routectl-README.md").read_text()
assert "No sudoers line is involved" in readme
assert "0600" in readme, "the operator page names the mode that is the grant"
@pytest.mark.skipif(hasattr(os, "geteuid") and os.geteuid() == 0, reason="running as root")
def test_the_install_checks_the_agent_can_reach_the_socket(tmp_path: Path) -> None:
"""A socket the agent cannot connect to is a feature that fails at 03:00.
So the check is part of the install: with the check's `sudo -u` step failing, the install
must exit non-zero rather than report a success nobody can use, and the check must have
run (the stub log is the proof it connected as the agent uid, not just been skipped).
"""
script, call_log, env = _sandbox_installer(tmp_path)
sudo_stub = tmp_path / "stubs" / "sudo"
sudo_stub.write_text(
'#!/bin/sh\nprintf "%s %s\\n" "sudo" "$*" >> "$STUB_LOG"\nexit 1\n', encoding="utf-8"
)
sudo_stub.chmod(0o755)
result = _install(script, env, *ROUTECTL_FLAGS)
assert result.returncode == 1, "an unreachable socket is a failed install, not a silent success"
assert "cannot connect" in result.stderr
assert "sudo -u dashboards-agent python3" in call_log.read_text(), "the check ran as the agent uid"
+368 -1
View File
@@ -1696,7 +1696,7 @@ def test_no_count_carries_a_verdict_or_a_window(gateway_snapshot: Any) -> None:
`sources/host.py` fails here — the first would be a judgement `severity.py` never made, the `sources/host.py` fails here — the first would be a judgement `severity.py` never made, the
second would claim a measurement this source does not take. second would claim a measurement this source does not take.
""" """
for mode in ("full", "predates", "sockets_silent"): for mode in ("full", "predates", "sockets_silent", "marked", "hand_edited"):
keys = walk_keys(gateway.parse(gateway_snapshot(mode), "gateway")) keys = walk_keys(gateway.parse(gateway_snapshot(mode), "gateway"))
assert "severity" not in keys, mode assert "severity" not in keys, mode
assert not {key for key in keys if "window" in key or key.startswith("Δ")}, mode assert not {key for key in keys if "window" in key or key.startswith("Δ")}, mode
@@ -2132,3 +2132,370 @@ def test_warnings_travel_in_the_payload_and_the_stub_share_the_key(gateway_snaps
assert gateway.stub("gateway")["warnings"] == [] assert gateway.stub("gateway")["warnings"] == []
assert gateway.parse(gateway_snapshot("predates"), "gateway")["warnings"] == [] assert gateway.parse(gateway_snapshot("predates"), "gateway")["warnings"] == []
assert set(gateway.stub("gateway")) - {"unreachable"} <= set(gateway.parse(snap, "gateway")) assert set(gateway.stub("gateway")) - {"unreachable"} <= set(gateway.parse(snap, "gateway"))
# --------------------------------------------------------------------------- #
# phase 33 task 04: `enabled` on every line, and the warnings learn the marker
#
# The helper's `#off <name>` marker leaves a switched-off line **in the config**; the agent
# reads it and publishes `disabled`, and this module draws the line either way — an off route
# is a claim the config made (a different fact from "never declared"), so it is drawn and
# counted, flagged. An arm that never read the config draws no lines at all, which is what
# keeps a `false` out of a payload nobody answered. The warning arms learn the marker the same
# way: a shape the operator's own switch caused is not a fault, and the same shape a hand edit
# left is.
# --------------------------------------------------------------------------- #
def marked(row: dict[str, Any]) -> dict[str, Any]:
"""A line the helper marked off: the agent's own `disabled: true` beside the intact line."""
return {**row, "disabled": True}
def test_a_marked_haproxy_server_line_is_an_off_line_with_a_distinct_id() -> None:
"""On and off are both drawn; the id's `#off` suffix is what keeps them distinct rows."""
section = haproxy_section(
[frontend("haproxy-22", [2222], default="be")],
[backend("be", [server("s1", "10.9.9.9", 80),
marked(server("s2", "10.9.9.9", 80))])])
payload = gateway.parse(snapshot(haproxy=section, agent_version="1.4.0"), "gateway")
by_enabled = {row["enabled"]: row for row in payload["connections"]}
assert set(by_enabled) == {True, False}, "one of these selectors is off is two facts"
on, off = by_enabled[True], by_enabled[False]
assert on["id"] == "haproxy:2222->10.9.9.9:80"
assert off["id"] == "haproxy:2222->10.9.9.9:80#off", (
"the suffix is the write path's key — the two halves of the split must not collide")
assert on["out"]["selectors"] == off["out"]["selectors"] == ["be"], (
"the off line keeps the config's own word for the route — switched off is not deleted")
# the flag is absence-tolerant: a row the agent did not report it on (an older agent's
# document) is on, because absence is "not switched off", never "broken"
older = haproxy_section([frontend("haproxy-22", [2222], default="be")],
[backend("be", [server("s1", "10.9.9.9", 80)])])
payload = gateway.parse(snapshot(haproxy=older), "gateway")
assert [row["enabled"] for row in payload["connections"]] == [True]
def test_a_backend_whose_only_server_is_switched_off_is_not_an_empty_warning() -> None:
"""The marker decision, pinned both ways: the line the helper left behind keeps the backend
non-empty (no warning — its state is the line's `enabled: false`), and the same shape the
operator left by **deleting** the line is the fault."""
switched_off = haproxy_section([frontend("haproxy-22", [2222], default="be")],
[backend("be", [marked(server("s1", "10.9.9.9", 80))])])
assert gateway.warnings(switched_off, None) == [], (
"the line is still in the document, so the backend declares a server — the off state "
"is the line's flag, not an empty-backend fault")
hand_deleted = haproxy_section([frontend("haproxy-22", [2222], default="be")],
[backend("be", [])])
assert kinds(gateway.warnings(hand_deleted, None)) == [gateway.WARN_BACKEND_EMPTY], (
"the config no longer says anything — that is the fault the same shape warns about")
def test_a_marked_proxy_pass_is_an_off_line_and_the_block_is_still_drawn() -> None:
off = {**proxy_pass(("10.9.9.9", 80), location="/off"), "disabled": True}
section = nginx_section(
[nginx_server([8443], ["x.example"], [proxy_pass(("10.9.9.9", 80)), off])])
payload = gateway.parse(snapshot(nginx=section, agent_version="1.4.0"), "gateway")
by_enabled = {row["enabled"]: row for row in payload["connections"]}
assert set(by_enabled) == {True, False}
assert by_enabled[False]["id"] == "nginx:8443->10.9.9.9:80#off"
assert by_enabled[False]["out"]["selectors"] == ["x.example"]
def test_a_marked_upstream_member_is_off_and_its_siblings_stay_on() -> None:
pool = {"name": "pool",
"servers": [{"host": "10.9.9.1", "port": 80, "disabled": False},
{"host": "10.9.9.2", "port": 80, "disabled": True}]}
section = nginx_section([nginx_server([8443], ["x.example"], [pool_pass("pool")])],
upstreams=[pool])
payload = gateway.parse(snapshot(nginx=section, agent_version="1.4.0"), "gateway")
by_host = {row["out"]["host"]: row["enabled"] for row in payload["connections"]}
assert by_host == {"10.9.9.1": True, "10.9.9.2": False}, (
"the member line's own flag is its state — the pool's other members are not switched "
"with it")
def test_a_map_entry_is_off_by_marker_or_by_the_sink_block_and_only_those() -> None:
"""Three off-lookalikes, one of which is routing: the marked line, the line whose value is
the helper's sink block, and a plain loopback target with no such block. Only the first two
are the switch — an address alone, without the block, is not off (a vhost that legitimately
lands on loopback is routing, not a switch)."""
sink_block = {"listen": [{"addr": "127.0.0.1", "port": 19999}],
"server_names": [], "proxy_passes": []}
entries = [("web.example", ("10.9.9.9", 80)),
("off.example", ("10.8.8.8", 80)),
("sink.example", ("127.0.0.1", 19999))]
maps = [mapping_block("$var", entries)]
maps[0]["entries"][1] = {**maps[0]["entries"][1], "disabled": True} # `off.example`, marked
section = nginx_section(
[sink_block,
nginx_server([443], (), [proxy_pass(None, variable="$var")])],
maps=maps)
payload = gateway.parse(snapshot(nginx=section, agent_version="1.4.0"), "gateway")
by_selector = {row["out"]["selectors"][0]: row["enabled"]
for row in payload["connections"] if row["out"]["selectors"]}
assert by_selector == {"web.example": True, "off.example": False,
"sink.example": False}, (
"the marked line and the line whose value is the sink block are the switch; "
"a plain target is not")
# the control target: a loopback address the config routes to, with no such block
maps[0]["entries"].append({"key": "plain.example",
"target": {"host": "127.0.0.2", "port": 29999},
"disabled": False})
payload = gateway.parse(snapshot(nginx=section, agent_version="1.4.0"), "gateway")
plain = [row for row in payload["connections"]
if row["out"]["selectors"] == ["plain.example"]]
assert plain and plain[0]["enabled"] is True, (
"an address alone, without the block, is not off")
# and the refusal the other way: the same config with the sink block absent — the value is
# just an address again, and the line is on
no_sink = dict(section, servers=[s for s in section["servers"]
if s is not sink_block])
payload = gateway.parse(snapshot(nginx=no_sink, agent_version="1.4.0"), "gateway")
sink_row = [row for row in payload["connections"]
if row["out"]["selectors"] == ["sink.example"]]
assert sink_row and sink_row[0]["enabled"] is True
def test_a_map_entry_with_no_value_warns_unless_the_line_carries_the_marker() -> None:
"""The same shape, two causes: the value hand-deleted is the fault the warning exists for,
the value the marker's line lost is the operator's own state and stays quiet. A marked entry
with no value has no endpoint to draw either, so it is a warning-less absence — the off
state lives in the line's flag, and the line is the config's."""
entries = [{"key": "gone.example", "target": None, "disabled": False},
{"key": "off.example", "target": None, "disabled": True}]
section = nginx_section(
[nginx_server([443], (), [proxy_pass(None, variable="$var")])],
maps=[{"variable": "$var", "entries": entries}])
assert kinds(gateway.warnings(None, section)) == [gateway.WARN_MAP_ROUTE_MISSING]
found = gateway.warnings(None, section)[0]
assert found["subject"] == "var:gone.example"
# the marked entry draws no line (it has no endpoint) and warns nothing
payload = gateway.parse(snapshot(nginx=section, agent_version="1.4.0"), "gateway")
assert all(row["out"]["selectors"] != ["off.example"] for row in payload["connections"])
def test_two_marked_map_entries_to_one_endpoint_merge_into_one_off_line() -> None:
"""Same state, same endpoint, two selector words: one line, the union of both keys — the
merge rule with `enabled` in the key, in the direction that unions instead of splits."""
entries = [{"key": "a.example", "target": {"host": "10.9.9.9", "port": 80},
"disabled": True},
{"key": "b.example", "target": {"host": "10.9.9.9", "port": 80},
"disabled": True}]
section = nginx_section(
[nginx_server([443], (), [proxy_pass(None, variable="$var")])],
maps=[{"variable": "$var", "entries": entries}])
payload = gateway.parse(snapshot(nginx=section, agent_version="1.4.0"), "gateway")
assert len(payload["connections"]) == 1
row = payload["connections"][0]
assert row["enabled"] is False and row["id"] == "nginx:443->10.9.9.9:80#off"
assert row["out"]["selectors"] == ["a.example", "b.example"]
def test_the_stub_and_the_predates_arm_have_no_lines_and_so_no_false(
gateway_snapshot: Any) -> None:
"""`false` is always a claim the config made, so an arm that never read the config carries
no `enabled` at all — not a row of `null`s (that would be a guess) and not `false` (that
would be a claim). The stub and the predates arm are that arm, both pinned."""
assert gateway.stub("gateway")["connections"] == []
assert "enabled" not in walk_keys(gateway.stub("gateway"))
assert "disabled" not in walk_keys(gateway.stub("gateway"))
predates = gateway.parse(gateway_snapshot("predates"), "gateway")
assert predates["supported"] is False and predates["connections"] == []
assert "enabled" not in walk_keys(predates)
def test_the_captured_configs_carry_no_marker_and_their_lines_are_on(gateway_snapshot: Any) -> None:
"""The recorded ingress, through the real agent: the flag is on every markable line and no
line of it is marked, so every line of the captured graph is `enabled: true` — the pin that
a re-capture in which the operator switched a route off is visible here rather than silently
redrawn as on."""
snap = gateway_snapshot("full")
for block in snap["haproxy"]["backends"]:
assert all(row["disabled"] is False for row in block["servers"])
for server in snap["nginx"]["servers"]:
assert all(p["disabled"] is False for p in server["proxy_passes"])
for block in snap["nginx"]["upstreams"]:
assert all(m["disabled"] is False for m in block["servers"])
for block in snap["nginx"]["maps"]:
assert all(e["disabled"] is False for e in block["entries"])
payload = gateway.parse(snap, "gateway")
assert payload["connections"], "the capture has routes"
assert all(row["enabled"] is True for row in payload["connections"])
assert not any(row["id"].endswith("#off") for row in payload["connections"])
def test_a_marker_off_route_is_declared_and_disabled(gateway_snapshot: Any) -> None:
"""Three of the fleet's own routes, switched off in the `marked` fixture (one haproxy
`server`, one vhost `proxy_pass`, one map entry pointing at the sink), come back as three
drawn rows reading `enabled: false` — selector and endpoint intact, every other row on, and
the marker-caused shapes warn nothing. The row is *declared and off*, which is a different
fact from never declared (absent) and from on, and the `#off` id suffix is the state's
spelling the write path keys on.
"""
payload = gateway.parse(gateway_snapshot("marked"), "gateway")
off = [row for row in payload["connections"] if row["enabled"] is False]
on = [row for row in payload["connections"] if row["enabled"] is True]
assert len(off) == 3, "three markers, three off rows — no merge stole one, none is missing"
assert on, "the other routes of the capture are still declared and on"
assert all(row["enabled"] is True for row in on), ("every non-marker row is on")
by_selector = {tuple(row["out"]["selectors"]): row for row in off}
# the selectors are the config's own words, verbatim — switching a route off is not deleting
# its name, and the endpoint is what the config (or the helper's rewrite) wrote, intact
assert by_selector[("gitea-2222",)]["out"]["host"] == "reeseapps-gitea.reeselink.com"
assert by_selector[("gitea-2222",)]["out"]["port"] == 2222
assert by_selector[("nextcloud.reeseapps.com",)]["out"]["host"] == "nextcloud.reeselink.com"
assert by_selector[("nextcloud.reeseapps.com",)]["out"]["port"] == 11000
assert by_selector[("brain.reeseapps.com",)]["out"]["host"] == "127.0.0.1"
assert by_selector[("brain.reeseapps.com",)]["out"]["port"] == 44450
for row in off:
assert row["id"].endswith("#off"), "the off state is the id's spelling, on every off row"
# the two marker-caused shapes are present in the raw sections, so `warnings == []` below is a
# suppression, not a vacuous absence: the gitea backend's only server is marked, and the
# brain entry's value is the sink
snap = gateway_snapshot("marked")
gitea = next(b for b in snap["haproxy"]["backends"] if b["name"] == "gitea-2222")
assert gitea["servers"] and gitea["servers"][0]["disabled"] is True
stream_map = next(b for b in snap["nginx"]["maps"] if b["variable"] == "targetBackend")
brain = next(e for e in stream_map["entries"] if e["key"] == "brain.reeseapps.com")
assert brain["disabled"] is True
assert payload["warnings"] == [], ("a marker-caused empty backend and a sink-pointed map "
"entry are states, not config-integrity faults")
def test_the_source_never_calls_a_route_broken_because_the_operator_disabled_it(
gateway_snapshot: Any) -> None:
"""The positive direction of both marker-aware kinds, over the captured config with the
markers injected (never a toy file): a backend whose only `server` the operator turned off
is not `haproxy_backend_empty` — the line is still in the document, and its state is the
row's `enabled: false` — and a map entry the helper pointed at its own sink is not a
missing route. Neither shape may earn a warning while the switch is what made it.
"""
snap = gateway_snapshot("marked")
# the shapes are present in the raw document, so the silence below is a decision
gitea = next(b for b in snap["haproxy"]["backends"] if b["name"] == "gitea-2222")
assert len(gitea["servers"]) == 1 and gitea["servers"][0]["disabled"] is True
stream_map = next(b for b in snap["nginx"]["maps"] if b["variable"] == "targetBackend")
brain = next(e for e in stream_map["entries"] if e["key"] == "brain.reeseapps.com")
assert brain["disabled"] is True and brain["target"] == {"host": "127.0.0.1", "port": 44450}
payload = gateway.parse(snap, "gateway")
assert kinds(payload["warnings"]) == [], (
"the operator's own switch is a state the page draws, not a fault the page alarms about")
def test_the_same_shape_without_a_marker_is_still_a_warning(gateway_snapshot: Any) -> None:
"""The negative direction, the same two shapes in the `hand_edited` fixture: the `server`
line deleted by hand is the fault `haproxy_backend_empty` exists for, and the map entry
whose value was hand-deleted (no marker, no sink file) is the fault
`nginx_map_route_missing` exists for. Suppression is marker-caused, not shape-caused —
together with `test_the_source_never_calls_a_route_broken_because_the_operator_disabled_it`
this is the pair that says so.
"""
snap = gateway_snapshot("hand_edited")
# the shapes are present and unmarked, so the warnings below are the checks working
gitea = next(b for b in snap["haproxy"]["backends"] if b["name"] == "gitea-2222")
assert gitea["servers"] == [], "the line is gone — the backend declares no server in the document"
stream_map = next(b for b in snap["nginx"]["maps"] if b["variable"] == "targetBackend")
brain = next(e for e in stream_map["entries"] if e["key"] == "brain.reeseapps.com")
assert brain["target"] is None and brain["disabled"] is False
payload = gateway.parse(snap, "gateway")
assert kinds(payload["warnings"]) == [gateway.WARN_BACKEND_EMPTY,
gateway.WARN_MAP_ROUTE_MISSING]
found = {row["kind"]: row for row in payload["warnings"]}
assert found[gateway.WARN_BACKEND_EMPTY]["subject"] == "gitea-2222"
assert found[gateway.WARN_MAP_ROUTE_MISSING]["subject"] == "targetBackend:brain.reeseapps.com"
# and the off state itself is gone with the marker: no `#off` row, nothing to draw off
assert not any(row["id"].endswith("#off") for row in payload["connections"])
assert all(row["enabled"] is True for row in payload["connections"])
def test_the_sink_address_is_recognised_not_hard_coded() -> None:
"""The sink is the *block* — loopback listen, no `server_name`, no `proxy_pass` — and the
port is the helper's to choose. A config that writes a different sink port still reads as
switched-off from the payload's own text, and a loopback target that is not the block's
port is routing, not a switch (a vhost that legitimately lands on loopback is on)."""
other_port = 45999
sink_block = {"listen": [{"addr": "127.0.0.1", "port": other_port}],
"server_names": [], "proxy_passes": []}
section = nginx_section(
[sink_block, nginx_server([443], (), [proxy_pass(None, variable="$var")])],
maps=[mapping_block("$var", [("sink.example", ("127.0.0.1", other_port)),
("plain.example", ("127.0.0.2", 29999))])])
assert gateway.sink_endpoint(section) == ("127.0.0.1", other_port), (
"the block, not the port constant, is what the source looks for")
payload = gateway.parse(snapshot(nginx=section, agent_version="1.4.0"), "gateway")
by_selector = {row["out"]["selectors"][0]: row["enabled"]
for row in payload["connections"] if row["out"]["selectors"]}
assert by_selector == {"sink.example": False, "plain.example": True}, (
"the entry whose value is the block's listen is off; the other loopback target is not")
def test_a_disabled_line_still_publishes_only_a_measured_count() -> None:
"""Nothing stamps `0`: an off row's `connections` is the join's answer, and the join says
`0` only when the table answered (and genuinely had none) and `null` when it did not —
with no sample in the latter case. `0` measured is a legitimate measurement; the
distinction is *measured*, never *assumed*.
"""
haproxy = haproxy_section([frontend("f", [2222], default="be")],
[backend("be", [marked(server("s1", "10.9.9.9", 80))])])
sink_block = {"listen": [{"addr": "127.0.0.1", "port": 44450}],
"server_names": [], "proxy_passes": []}
section = nginx_section(
[sink_block, nginx_server([443], (), [proxy_pass(None, variable="$var")])],
maps=[mapping_block("$var", [("brain.example", ("127.0.0.1", 44450)),
("web.example", ("10.9.9.1", 80))])])
section["maps"][0]["entries"][0] = {**section["maps"][0]["entries"][0], "disabled": True}
# the table answered (and had nothing to book): the off rows are `0`, and `0` measured
# publishes, because the endpoint is one the config wrote as an address
answered = gateway.parse(
snapshot(haproxy=haproxy, nginx=section, sockets=sockets_section(),
agent_version="1.4.0"), "gateway")
off = [row for row in answered["connections"] if row["enabled"] is False]
assert len(off) == 2 and all(row["connections"] == 0 for row in off)
by_selector = {row["out"]["selectors"][0]: row for row in answered["connections"]}
assert by_selector["web.example"]["connections"] == 0 # the on row, the same measured zero
off_metrics = {row["metric"] for row in off}
assert off_metrics, "a joinable off endpoint carries a gauge, like its on counterpart"
samples = {s.metric: s.value for s in gateway.samples(answered, 1.0)}
for metric in off_metrics:
assert samples.get(metric) == 0.0, "the off row's sample is the measured zero, not a stamp"
# the table did not answer: every row stays `null` and the off rows publish nothing at all
silent = gateway.parse(
snapshot(haproxy=haproxy, nginx=section,
sockets=sockets_section(available=None), agent_version="1.4.0"), "gateway")
assert all(row["connections"] is None for row in silent["connections"]), (
"no table, no count — the off state is not a zero")
assert gateway.samples(silent, 1.0) == []
def test_the_stub_and_the_answer_share_one_key_set(gateway_snapshot: Any) -> None:
"""The stub's `enabled` is `None` in the sense nobody read the config — and a stub has no
rows, so the honest unknown is the *absence of the claim*: no rows, hence no `enabled` key
anywhere in the document (a row of `null`s would be a guess, and a `false` would be a claim
the config never made). The answer is the other half of the contract: every row it draws
carries the state as a real boolean, never a `None` and never a missing key, so a page
indexing `connections[].enabled` tests one shape in both arms.
"""
answer = gateway.parse(gateway_snapshot("full"), "gateway")
assert answer["connections"], "the capture has routes to pin the answer half on"
for row in answer["connections"]:
assert row["enabled"] is True or row["enabled"] is False, (
"an answered row's state is a claim the config made — never null, never absent")
stub = gateway.stub("gateway")
assert stub["connections"] == []
assert "enabled" not in walk_keys(stub), (
"an arm that never read the config carries no `enabled` — its `None` is the absence")
assert "disabled" not in walk_keys(stub)
# top-level parity: the page indexes the same keys on both arms; the only keys the stub
# alone or the answer alone carries are the ones that *are* the difference
shared = set(stub) | set(answer)
assert shared - set(stub) == {"agent_version"}, "the answer's extra keys are the snapshot's"
assert shared - set(answer) == {"unreachable"}, "the stub's extra key is the reachability one"
+125 -3
View File
@@ -14,9 +14,11 @@ addresses the deployed dashboard dials, nothing forwarded:
Each test skips when its source is not configured, so running the whole set with half a Each test skips when its source is not configured, so running the whole set with half a
fleet reachable is a normal outcome, not a failure. fleet reachable is a normal outcome, not a failure.
**Nothing in here may write.** The UPS test asserts the command gating is honest; it never **Nothing in here may write — with exactly one documented exception.** The UPS test asserts
sends an instant command — a battery test on hardware someone else is plugged into is not the command gating is honest; it never sends an instant command — a battery test on
a unit test. hardware someone else is plugged into is not a unit test. The one write is the phase-33
round-trip test, gated by its own flag (`--allow-live-writes`) on top of `--live`: a
default live run never switches a route, and the skip reason names the route it would.
""" """
from __future__ import annotations from __future__ import annotations
@@ -562,3 +564,123 @@ def test_target_config_is_clean_and_leak_free(settings: Settings) -> None:
assert "@" not in target.display_url, f"{target.name}: display_url leaks credentials" assert "@" not in target.display_url, f"{target.name}: display_url leaks credentials"
for problem in settings.host_target_problems: for problem in settings.host_target_problems:
assert isinstance(problem, str) and problem, f"empty problem: {problem!r}" assert isinstance(problem, str) and problem, f"empty problem: {problem!r}"
#: The one write target of the opt-in round trip (phase 33 task 07): a benchmark service
#: with no human dependency. Never the auth, git, alerts or file routes — task 07's
#: precondition names them, and a test that would touch one of them is a test that does not
#: run.
SWITCH_DRILL_ROUTE = "haproxy:5201->web.reeselink.com:5201"
async def test_the_switch_round_trip_on_one_named_route(
request: pytest.FixtureRequest, settings: Settings,
agent_tls: dict[str, collectors.AgentTls]) -> None:
"""Phase 33's one live write: the named route off and back on, through the app's own
`POST /route` dial.
Gated twice so a default live run stays read-only: the file's `live` marker (skip
without `--live`) **and** the separate `--allow-live-writes` flag, whose skip reason
names the route it would touch. When it does run — the operator's drill, task 07 step
5 — it dials `collectors.post_route` (the app's second and last upstream write, through
the stored mTLS authority, no hand-rolled client) and proves the round trip on the
production read (`fetch_snapshot` → `gateway.parse`): the helper's own words for the
write, the row's `enabled` flipping both ways, the agent's own section carrying the
marked line in between, and the haproxy section the agent serves byte-identical between
the pre-drill and post-drill reads. The restore lives in a `finally`: even a failed
proof turns the route back on before the test ends, and a re-enable that itself fails
propagates the helper's own word — a marked line left behind must be the operator's
first line, never a silent one.
"""
if not bool(request.config.getoption("--allow-live-writes")):
pytest.skip(f"opt-in live write: skip without --allow-live-writes (this test would "
f"switch {SWITCH_DRILL_ROUTE} off and back on — the default live suite "
"stays read-only")
target = next((item for item in settings.host_targets if item.name == settings.gateway_host),
None)
if target is None:
pytest.skip(f"GATEWAY_HOST={settings.gateway_host!r} is not in HOST_TARGETS")
async def read_ingress(client: httpx.AsyncClient) -> tuple[dict[str, Any], dict[str, Any]]:
snapshot = await collectors.fetch_snapshot(client, target,
timeout=settings.gateway_timeout,
tls=agent_tls.get(target.name))
return snapshot, gateway.parse(snapshot, target.name)
async def post(body: dict[str, Any]) -> dict[str, Any]:
# One client per call: one write, one connection — the same discipline the agent
# keeps for its one helper dial (a retry of a write is a second reload).
async with httpx.AsyncClient(timeout=settings.gateway_timeout) as client:
return await collectors.post_route(client, target, body,
timeout=settings.gateway_timeout,
tls=agent_tls.get(target.name))
disabled = False
try:
async with httpx.AsyncClient(timeout=settings.gateway_timeout) as client:
try:
before, payload = await read_ingress(client)
except collectors.UpstreamError as exc:
pytest.skip(f"{target.name} did not answer: {exc}")
# Precondition 1: the agent on this host is the one with the write path.
version = str(before.get("agent_version") or "")
if not version.startswith("1.4."):
pytest.skip(f"{target.name} runs agent {version or 'unknown'} — POST /route is "
"1.4.0's; re-stage with deploy/install-agent.sh --allow-writes "
"--with-routectl")
# Precondition 2: a clean pre-state — the route is on and nothing else is off;
# a half-finished earlier attempt is the state task 07 step 4 refuses to drill.
route = next((row for row in payload["connections"]
if row["id"] == SWITCH_DRILL_ROUTE), None)
if route is None:
pytest.skip(f"{SWITCH_DRILL_ROUTE} is not on the ingress the agent published — "
"the drill target moved; update this test's target and re-verify")
already_off = [row["id"] for row in payload["connections"]
if row["enabled"] is False]
assert not already_off, (
f"a route is already disabled before the drill {already_off} — a previous "
"attempt left a marked line; restore it before re-running (task 07 step 6)")
# 1. dry_run: the diff, and no write (task 07 step 5.1).
preview = await post({"id": SWITCH_DRILL_ROUTE, "action": "disable",
"dry_run": True})
assert preview.get("ok") is True and preview.get("dry_run") is True, preview
assert preview.get("applied") is False and preview.get("changed") is True, preview
assert any("#off" in str(line) for line in preview.get("diff") or []), (
f"the dry-run diff must show the marker landing: {preview.get('diff')!r}")
# 2. apply: the helper's own word for the write (step 5.2). A checker that refuses
# (e.g. no `nginx -t` on the host's PATH) answers `applied: false, restored: true`
# and this test fails on the helper's word instead of printing a false green.
applied = await post({"id": SWITCH_DRILL_ROUTE, "action": "disable"})
assert applied.get("ok") is True and applied.get("applied") is True, (
f"the helper refused the write instead of applying it: {applied!r}")
assert applied.get("validated") is True and applied.get("reloaded") is True, applied
disabled = True
# 3. the off state, proven on the production read (step 5.3/5.4): the row and the
# agent's own section both report the marker.
async with httpx.AsyncClient(timeout=settings.gateway_timeout) as client:
off_snapshot, off_payload = await read_ingress(client)
off_route = next(row for row in off_payload["connections"]
if row["id"] == SWITCH_DRILL_ROUTE)
assert off_route["enabled"] is False, "the row must report disabled within one read"
marked = [server for backend in (off_snapshot.get("haproxy") or {}).get("backends", ())
for server in backend.get("servers", ()) if server.get("disabled") is True]
assert marked, "the agent's haproxy section shows no marked server line"
finally:
# The restore lives here, not after the assertions: a failed proof still leaves the
# route on, and a failed re-enable propagates the helper's own word.
if disabled:
await post({"id": SWITCH_DRILL_ROUTE, "action": "enable"})
# 4. the round trip closed: the row is on, nothing is off, and the haproxy section the
# agent serves is byte-identical to the pre-drill read (step 5.5/5.6).
async with httpx.AsyncClient(timeout=settings.gateway_timeout) as client:
after, after_payload = await read_ingress(client)
after_route = next(row for row in after_payload["connections"]
if row["id"] == SWITCH_DRILL_ROUTE)
assert after_route["enabled"] is True, (
f"the route is still off after the re-enable — the drill left a marked line: "
f"{after_route!r}")
left_off = [row["id"] for row in after_payload["connections"]
if row["enabled"] is False]
assert not left_off, f"marked lines left behind after the round trip: {left_off}"
assert json.dumps(after.get("haproxy")) == json.dumps(before.get("haproxy")), (
"the haproxy section is not byte-identical after the round trip")
+625
View File
@@ -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))
Generated
+180 -180
View File
@@ -22,15 +22,15 @@ wheels = [
[[package]] [[package]]
name = "anyio" name = "anyio"
version = "4.15.1" version = "4.16.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "idna" }, { name = "idna" },
{ name = "typing-extensions", marker = "python_full_version < '3.15'" }, { name = "typing-extensions", marker = "python_full_version < '3.15'" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/a9/d2/f4d173e22df740bc37b1db102b386ba719b66e95b0f0d751f556b387e6d2/anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94", size = 276966, upload-time = "2026-09-05T10:42:39.44Z" } sdist = { url = "https://files.pythonhosted.org/packages/a1/98/7e457a3b014976887a050561955c0b76e1e7e2d6e27b78ecef2ef80518f2/anyio-4.16.0.tar.gz", hash = "sha256:59bb03251907983d1603d27c3087ddb1e93299ea2163adcdba37899a4fb12ffb", size = 310971, upload-time = "2026-11-01T11:20:07.282Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" }, { url = "https://files.pythonhosted.org/packages/a9/16/7757fb84c819accc7d7e9af5291b261e4abb8627ee040f99e7d35ef2457a/anyio-4.16.0-py3-none-any.whl", hash = "sha256:c66ae634e0b8228396b801741beb3294326d4b6102f7bba4041c0dfb58c29026", size = 134521, upload-time = "2026-11-01T11:20:05.59Z" },
] ]
[[package]] [[package]]
@@ -453,23 +453,23 @@ wheels = [
[[package]] [[package]]
name = "nodeenv" name = "nodeenv"
version = "1.11.0" version = "1.12.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/105de02c1322cfada6d9710d9146ef8026419d433c9d08359a2d35805811/nodeenv-1.11.0.tar.gz", hash = "sha256:3ce8fe5b71d16e8af7039ca65257354100bc772965d6bc549070649e53b1b146", size = 99301, upload-time = "2026-09-26T11:29:21.367Z" } sdist = { url = "https://files.pythonhosted.org/packages/e6/99/d27cfc9518f49e4cbc25c683ec7cf8704a9d31311341c291c0e78ddf791b/nodeenv-1.12.0.tar.gz", hash = "sha256:4ea7f88339310afebc3253f2c7a91b94bacf97d73a36a5c108962abdfaca501e", size = 131292, upload-time = "2026-10-31T19:03:18.847Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/54/c8/12811c9b48fde162bb72b6f2e78fada9a247a09d7bb5be2050a5d099c77b/nodeenv-1.11.0-py2.py3-none-any.whl", hash = "sha256:edaa16e6c14d7cf395d75d4bbd5a26390f4dc06501a33b4e76282b02cc688a25", size = 34277, upload-time = "2026-09-26T11:29:19.933Z" }, { url = "https://files.pythonhosted.org/packages/2d/6a/8a9ac7d5e2f7cf53c459bfef90ebc17a2f8cd54b665af96799d4c534840e/nodeenv-1.12.0-py3-none-any.whl", hash = "sha256:80644d65c10cc2be12e3e4b54b3367ff288c7ad24ae75c2dcc65454fc7449d4d", size = 39442, upload-time = "2026-10-31T19:03:17.427Z" },
] ]
[[package]] [[package]]
name = "opentelemetry-api" name = "opentelemetry-api"
version = "1.45.1" version = "1.45.2"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "typing-extensions" }, { name = "typing-extensions" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/2e/02/6e0ae9cc61bd3169d401077b507b3ebc344745171e1051ab430be012dcd9/opentelemetry_api-1.45.1.tar.gz", hash = "sha256:aa38ed19bcc084ba42782a73255b3582283eced7ad6dddbd6695189e69adfb75", size = 72804, upload-time = "2026-10-06T17:32:58.133Z" } sdist = { url = "https://files.pythonhosted.org/packages/19/14/cf3a6b6448849656e1e5f4e2101f3988dc6789724050096aba1bc5ccf592/opentelemetry_api-1.45.2.tar.gz", hash = "sha256:d7b0008e21e5fd61cbe7a7f34ca3b6905a78ab677d9bc395e299359c75cda6b4", size = 73277, upload-time = "2026-10-29T17:29:37.888Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/41/f7dcf80b81ee8e71c1a2b59f14208bc723edbd89ed027a73b175abf6348e/opentelemetry_api-1.45.1-py3-none-any.whl", hash = "sha256:b31553efa588ae44bc306f863c785c5333a9ecc091248c6ee68b4b6c87fdedfb", size = 60256, upload-time = "2026-10-06T17:32:33.506Z" }, { url = "https://files.pythonhosted.org/packages/a9/2d/610db864d7513158cebb8f36cc88e67e73bd6c7aa1905ef18d89e1281c81/opentelemetry_api-1.45.2-py3-none-any.whl", hash = "sha256:2cbd737377e710ee740d261c94e93fa0f08e6d037c18f17513cc0a2d94479a34", size = 60335, upload-time = "2026-10-29T17:29:06.955Z" },
] ]
[[package]] [[package]]
@@ -501,7 +501,7 @@ wheels = [
[[package]] [[package]]
name = "pydantic" name = "pydantic"
version = "2.14.1" version = "2.14.2"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "annotated-types" }, { name = "annotated-types" },
@@ -509,81 +509,81 @@ dependencies = [
{ name = "typing-extensions" }, { name = "typing-extensions" },
{ name = "typing-inspection" }, { name = "typing-inspection" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/7c/0b/8e10b2e693af8ec54346a14caf36221334775975a747c9ceaa3f8371d96d/pydantic-2.14.1.tar.gz", hash = "sha256:94f478203dd03404682a1ada216965651dd74b1d2d5ffd62e00e0837caab5c26", size = 900362, upload-time = "2026-10-11T18:37:55.396Z" } sdist = { url = "https://files.pythonhosted.org/packages/9f/d5/b4867fa7c7f04c34997d7d8aad744f64d210bb99d67cf18bf3fb1eea1aed/pydantic-2.14.2.tar.gz", hash = "sha256:fb7b2db6cefe8414dd612edeede49c4c433acb0432b0f92b95024e45f66c505d", size = 911596, upload-time = "2026-10-29T16:37:12.476Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/35/ea/a56b9fe5066f3537b7882f77e9c5dfb26d8c2eefdaed9b5fc73d57b4dc22/pydantic-2.14.1-py3-none-any.whl", hash = "sha256:9195d967ec791692a04438115466764fb8b9a27b31f14a760437694f40d6b454", size = 488106, upload-time = "2026-10-11T18:37:53.437Z" }, { url = "https://files.pythonhosted.org/packages/97/01/c71a71258b58659c72504e5fd65b451f6e36e34dc931d0b237ded213c814/pydantic-2.14.2-py3-none-any.whl", hash = "sha256:7efbf7507b937931cd32a5755acb00ea1cf5ccb1d4e8067a5850d5069e382118", size = 490403, upload-time = "2026-10-29T16:37:10.181Z" },
] ]
[[package]] [[package]]
name = "pydantic-core" name = "pydantic-core"
version = "2.50.1" version = "2.50.2"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "typing-extensions" }, { name = "typing-extensions" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/a6/24/af4ec4be49fbc810f35b0bdcacc3d433b56bbfa469fd3bfd4ae116cd9bf1/pydantic_core-2.50.1.tar.gz", hash = "sha256:e50d7b94baac6c7d09927fa5ca5800a0c7ee5015c7fcff65beb3a1931b5a6e09", size = 513515, upload-time = "2026-10-11T18:35:44.82Z" } sdist = { url = "https://files.pythonhosted.org/packages/af/8e/23ec1021834e35041fbb2f10946141e6acdfd39379c45cc4d65a4e8a1d98/pydantic_core-2.50.2.tar.gz", hash = "sha256:1b0e8dc3932cf4e9ed5727570325cacc716b9409ffc794a221c0ccc72e8eb775", size = 518711, upload-time = "2026-10-29T16:34:57.465Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/49/33/28b96e81677153715e3eafb9f26663a80841e859fde282a380359b0d3fa1/pydantic_core-2.50.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:1cf41f1ae3fa155cf167a72689ad044bcc1e3c97e064123677149bdfb5dafc4a", size = 2224428, upload-time = "2026-10-11T18:33:07.153Z" }, { url = "https://files.pythonhosted.org/packages/f2/64/fc525bb981a9f287cfa34346e7ed1d5ef7c043c2b9b517f54c6136032d10/pydantic_core-2.50.2-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:efad0bdc67d3b119c3bab6b94d341a27333c2bb89ed75dfa182a4a06cd0ea329", size = 2226414, upload-time = "2026-10-29T16:32:58.559Z" },
{ url = "https://files.pythonhosted.org/packages/94/40/15c06410c9b7b8da5805d27b64e09bd3f900e986728106db2689dbc51513/pydantic_core-2.50.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4df197990c15b5a37c5a277d131d9f2c67de6133f2e5dafd80d9bba4b99f46f9", size = 2023740, upload-time = "2026-10-11T18:33:08.763Z" }, { url = "https://files.pythonhosted.org/packages/db/68/315701095f25687938ab79d263a6d939337456c2840fe69038fd38000338/pydantic_core-2.50.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6ffb4dce3ecb2cd563aa30f9eb5088a1cf1f4f688b90cbb374fa96e49fc50b02", size = 2095041, upload-time = "2026-10-29T16:32:59.873Z" },
{ url = "https://files.pythonhosted.org/packages/a1/4e/5eb629f6efc2a27e421d789dd4bfacbb5f6d09c80c28b13d1e87d73163d5/pydantic_core-2.50.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0036473f5583e6a60e50b8b21651511564277a3f05cc5dab8cf579f552cd5f6c", size = 2049435, upload-time = "2026-10-11T18:33:10.366Z" }, { url = "https://files.pythonhosted.org/packages/b8/4b/efd3abf31aa24bc97b311735f418886f947b4f193df02a1977643963b03e/pydantic_core-2.50.2-cp314-cp314-manylinux1_i686.manylinux_2_5_i686.whl", hash = "sha256:150f6e101f575c21a4ecfed1be1c05bb89a0ad49cb9c846ed377583bd71b32c2", size = 2257635, upload-time = "2026-10-29T16:33:01.096Z" },
{ url = "https://files.pythonhosted.org/packages/ba/8e/f195aebec49ad12318876ac2368c197a7939f5d52f8e156d2238ef8a4588/pydantic_core-2.50.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:992c3514ec891fa7858099183e4d64e6bd5a5d4ff452fae29df22faa77a006bb", size = 2172975, upload-time = "2026-10-11T18:33:12.368Z" }, { url = "https://files.pythonhosted.org/packages/2d/6c/146b36eed4de8115a0fda76840703e75f5f8b36aeb3af86976bf094d1a17/pydantic_core-2.50.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1db9cab6197e797824f0479dd2ae5fd2e272aa61c61b7faa5a6e450ca7a0d3b7", size = 2116807, upload-time = "2026-10-29T16:33:02.692Z" },
{ url = "https://files.pythonhosted.org/packages/e0/f5/7ad9fb83010cd5ea0948409db105676ed779c4e709e2d3d89b9fe2558794/pydantic_core-2.50.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:739dc730e6be3bd5ec2f4ab5cfc7eb047cc45fc1497b3bafec74ff2ed07df597", size = 2364197, upload-time = "2026-10-11T18:33:14.244Z" }, { url = "https://files.pythonhosted.org/packages/fb/94/db006ea79bbe405cc38b25899319c7d461ff1813cbdfe6aa90a65ed1dfff/pydantic_core-2.50.2-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:1b9f652e17a13bbc09b45f74c3c326cca72059ce4dcdef687fcb04389db413a9", size = 2201037, upload-time = "2026-10-29T16:33:03.942Z" },
{ url = "https://files.pythonhosted.org/packages/ca/fb/bf0aab3e78301d202b82a0322ec968cd703b11fc0623fde9a28708936f62/pydantic_core-2.50.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:32fad3a91e51b6d2039c572db04a5a873260b399f6bd62c3552671fa7a4a2899", size = 2444259, upload-time = "2026-10-11T18:33:15.79Z" }, { url = "https://files.pythonhosted.org/packages/41/f9/ea2bc1ec13a67af4a63c9a887fa0aa6ef4ade9018e2bb412d4fc80110e19/pydantic_core-2.50.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:823b222c4fc861a82c650998a6117ecbd150cc71bf8b78f9a31bf3f48c456108", size = 2374904, upload-time = "2026-10-29T16:33:05.458Z" },
{ url = "https://files.pythonhosted.org/packages/45/35/38f6d6564fae57d9b12e5676dfa947e0e7d5c46dbeaa7eb3352261d6d299/pydantic_core-2.50.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:42b54c2c90ad348b5e3a85e03e715d572c1fde357ef104cdfe3b03b697a404ea", size = 2159857, upload-time = "2026-10-11T18:33:17.51Z" }, { url = "https://files.pythonhosted.org/packages/44/8e/26a6a075301f0e563646037ea69bef8c63911d38fbd257f94cec69cdf985/pydantic_core-2.50.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:783abdfac21247d33bec40aa683d26bba30d61655c40de38b2decc4797aabf92", size = 2457626, upload-time = "2026-10-29T16:33:07.271Z" },
{ url = "https://files.pythonhosted.org/packages/65/a0/fb0a3ca10f139dcf765b2d312d10cf0f65c57c59993229d00ad12b14ceff/pydantic_core-2.50.1-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:2df1ff41884de2bc4b307bafd7c40a691094fad2ff8e767e5b45a319257bcf4e", size = 2233954, upload-time = "2026-10-11T18:33:19.591Z" }, { url = "https://files.pythonhosted.org/packages/9d/a2/8eeabaf1d394f0d3b9a27129ac21556eabee6f2ddcb418069ef4037e00b0/pydantic_core-2.50.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b29b7c5adf4b2be7c941f7f76432aa3269cd5052ff8610376b8ece24d7b02c51", size = 2245505, upload-time = "2026-10-29T16:33:08.824Z" },
{ url = "https://files.pythonhosted.org/packages/8c/6a/e63842252702aa4ec6e6b7178ca85e541a77459076592c23ebe2d9840b33/pydantic_core-2.50.1-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe90228920fd8ff2be62622b6bb8a2b11acd65046d50c6b130614b5879605a20", size = 2243876, upload-time = "2026-10-11T18:33:21.393Z" }, { url = "https://files.pythonhosted.org/packages/bd/54/449778cd397e2682b30d1bcfab0872f6ac36702b59cc326296b7f4f14928/pydantic_core-2.50.2-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:4ac996e42a93d80122a23edce44e02c6b9c4913f5c6198a077da31475341a39a", size = 2259563, upload-time = "2026-10-29T16:33:10.107Z" },
{ url = "https://files.pythonhosted.org/packages/39/25/5991cf8318b37e0dfab47b87541619a1df8501a793cba0d978846cba37a7/pydantic_core-2.50.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:844b869f118e22a41a091bdcedda8a71bc1b0f62c38d1a0c3211cece47e1d8fc", size = 2277908, upload-time = "2026-10-11T18:33:23.324Z" }, { url = "https://files.pythonhosted.org/packages/26/c2/2f65691d25488ee0f3c17861d079e8ccb7817955522d07cd3c3970d30af5/pydantic_core-2.50.2-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:99e1d482138a71902415ba375bb4627679a5fefd3bd07803c3a8f37632f4981d", size = 2290470, upload-time = "2026-10-29T16:33:11.273Z" },
{ url = "https://files.pythonhosted.org/packages/a7/3e/3ee8baaa6cc25a6961c69168cf9ff0f002d56f4e0d541ad6724c18fb61f3/pydantic_core-2.50.1-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:2eb75304506894a281d346220a4f7481a1b8729577c5ed2a05395991966a8396", size = 2449285, upload-time = "2026-10-11T18:33:24.942Z" }, { url = "https://files.pythonhosted.org/packages/9f/32/aa7c40234cd24536afa0895ff1d26f9f15638004e25242c5193ce053b96b/pydantic_core-2.50.2-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:a53d5f11cd6d15569ba3e53f3498077f0d5e633f6ec604d2ea613e895017ebee", size = 2477272, upload-time = "2026-10-29T16:33:12.771Z" },
{ url = "https://files.pythonhosted.org/packages/c0/c7/acbec6deac13fe697a80c275a9b6661db62c4d323bac8a47347b1f39c7cd/pydantic_core-2.50.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:6b20a4bffabdad0db2927ac034ae3b8a681b1f7a0182f3e60b479ad2fde21ebb", size = 2479013, upload-time = "2026-10-11T18:33:26.925Z" }, { url = "https://files.pythonhosted.org/packages/c4/43/c347c68e040cf93d8487a8f4fc28f2768312834c7d21c301538f416f9955/pydantic_core-2.50.2-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:23385c4626f8b5a7792aa381ea573d9432cfbf4c21375a0a5eed2a4fb64e0e58", size = 2484352, upload-time = "2026-10-29T16:33:14.485Z" },
{ url = "https://files.pythonhosted.org/packages/10/08/21a3f237b264389f6219d053ee78fc1b5c2fd402c1b1cb2b6cb8b85f9834/pydantic_core-2.50.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:99ba9bc2b8062ea0c326a990f7f00e6530c23579de66dd246e72c4cafef950a5", size = 1484400, upload-time = "2026-10-11T18:33:28.693Z" }, { url = "https://files.pythonhosted.org/packages/b8/8e/72556126c8b6154fe7acaf757b96b2daa37aeffc3a517deb76e272f5ad09/pydantic_core-2.50.2-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:9a8a62281fd9c6a373b3fd3d473685e6838d43341eecde5f47729748e2c11ee3", size = 1491226, upload-time = "2026-10-29T16:33:15.603Z" },
{ url = "https://files.pythonhosted.org/packages/09/ce/077a6d262d12ef09108377ac0717f029f420d773ace63cafd6143af75568/pydantic_core-2.50.1-cp314-cp314-win32.whl", hash = "sha256:cf356f70551d40374eaffb1aa63f1eb6d2006681cbd7a9faea173ce0f4dd7cd2", size = 2103222, upload-time = "2026-10-11T18:33:30.326Z" }, { url = "https://files.pythonhosted.org/packages/5b/66/004c85511812964a36b3535af022c5c0dfcb760a31e4905b96310e8dd6c8/pydantic_core-2.50.2-cp314-cp314-win32.whl", hash = "sha256:18f008ef58cd30782f81b6e0119e20b7e884edcefbc2d370162ebe033f6e5c99", size = 2113134, upload-time = "2026-10-29T16:33:16.87Z" },
{ url = "https://files.pythonhosted.org/packages/14/4c/350a2415209c43d670eb71d3c040f332c04a30583d39e311b05c7ac15762/pydantic_core-2.50.1-cp314-cp314-win_amd64.whl", hash = "sha256:d32f3acc081cc3923386d88f422cde8892335e95f034e0104bb4cf9310d9915f", size = 2171586, upload-time = "2026-10-11T18:33:32.139Z" }, { url = "https://files.pythonhosted.org/packages/a3/5f/dcd3df20ab2a7279d42a8415fc184e8e77bcf9aa3c9ac3e938c1300a242c/pydantic_core-2.50.2-cp314-cp314-win_amd64.whl", hash = "sha256:b62c89545d21d930c333208fd548e7b4f262228ba3a322fd75c987a75ab11f31", size = 2253407, upload-time = "2026-10-29T16:33:18.47Z" },
{ url = "https://files.pythonhosted.org/packages/bf/92/9bea6ca96580a0902fed366f064f0829e404f41889b34543279a1162b888/pydantic_core-2.50.1-cp314-cp314-win_arm64.whl", hash = "sha256:bed5163e03b98bc1fa2eb05d74c63d9c5c95d8ed6254985481640fbf5e237dea", size = 2155773, upload-time = "2026-10-11T18:33:33.896Z" }, { url = "https://files.pythonhosted.org/packages/4e/fb/0685895f5d199b215011dc4eb8ccea032a64ec23dfa88eeb5acca939c2d2/pydantic_core-2.50.2-cp314-cp314-win_arm64.whl", hash = "sha256:6e731bf5a5e8e25398544d7709e39068ec4230b0177e4db7fa955692fd415ba3", size = 2160456, upload-time = "2026-10-29T16:33:19.782Z" },
{ url = "https://files.pythonhosted.org/packages/86/8c/f121f073cf32bdd5cba7e6230b1ce0ac845b0cf43e44dd597d95af272db2/pydantic_core-2.50.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:9572c1369e9c9da2d64a7b7992c786d90ff295abc93964cfe3125e4290768070", size = 2234017, upload-time = "2026-10-11T18:33:35.588Z" }, { url = "https://files.pythonhosted.org/packages/84/2f/4c8f67efe62837a508b74cf910904f73029cb211371351c5ceb36d56e85e/pydantic_core-2.50.2-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:33c6800ab9563adaa0d7893e9cd8f5b972ef1e6a23df70498e05c24f3fcec5e1", size = 2217558, upload-time = "2026-10-29T16:33:21.323Z" },
{ url = "https://files.pythonhosted.org/packages/a4/69/2bb2bcd6146cffe98d760a46c42ae71efd5151d9b2f9c9bf6619a3b32083/pydantic_core-2.50.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2005207aafe1231315718bf6ed5d064a7300fb4772754af35ee72fc68159492e", size = 2001016, upload-time = "2026-10-11T18:33:37.57Z" }, { url = "https://files.pythonhosted.org/packages/ce/73/e0ef88cfbcca5409f57751289bc20be63a58af596a36e94b8cd795e59747/pydantic_core-2.50.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f81df206b937d0dca5efb38c25fc99037d82dbcd3740e34c3f81997d2322ac1c", size = 2074335, upload-time = "2026-10-29T16:33:22.658Z" },
{ url = "https://files.pythonhosted.org/packages/20/b3/fbf854c7d07ec114260c26e9e2071a4381740f9ae09641dbfcbdf2a18c45/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:64f6047f62a6c5ae08d0a6afb035667aa2d97c3d20d69762e034c5ea144d92a5", size = 2023015, upload-time = "2026-10-11T18:33:39.433Z" }, { url = "https://files.pythonhosted.org/packages/7b/cc/47844a3179ebce27064e13619594b3fc1a9b4509d8dec1e3960c5f87f5bb/pydantic_core-2.50.2-cp314-cp314t-manylinux1_i686.manylinux_2_5_i686.whl", hash = "sha256:21ea3b00f1e87c66f3f9e92f9f6fec20495ac0e60d7d2e68ba1fc49739d7cf40", size = 2264201, upload-time = "2026-10-29T16:33:24.953Z" },
{ url = "https://files.pythonhosted.org/packages/65/20/6de55b2f92cdb614b745c6e9ced639fc4fd7e1e77604825a88bdece6fcbd/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1ef800dd7d85bcdadf4c3076e4c94e43939493558a3b69a1ea830c706d4617bb", size = 2192543, upload-time = "2026-10-11T18:33:41.381Z" }, { url = "https://files.pythonhosted.org/packages/bc/d1/30e9d3c86d81884ee3199560ddfa2c1a66a8dc0736441748eae43064abbc/pydantic_core-2.50.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:9eff570402175c7d5fff8497d5a9c05580664a79bb66c07b8f205d17f69e580b", size = 2094353, upload-time = "2026-10-29T16:33:26.739Z" },
{ url = "https://files.pythonhosted.org/packages/0c/d9/19e91c94bd5c405945ce2f15526808aea37e162c253160de8ed7bf70b406/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b0135bcdcaa0f23573f286e4cb5e0fd2962700964ed13df085b85f2b97aeab9e", size = 2388704, upload-time = "2026-10-11T18:33:43.167Z" }, { url = "https://files.pythonhosted.org/packages/d4/8f/392a5999480d0bcab4539db0dafa93265152396fd63e87be4d24e13d6d82/pydantic_core-2.50.2-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:240fec32a74af0979f7253532859b3fd209ad5eea822416bb010929dba445c3d", size = 2203119, upload-time = "2026-10-29T16:33:28.154Z" },
{ url = "https://files.pythonhosted.org/packages/b6/bc/2e24c8415eae1a25ee5a5484946a917123bad2e9d01689a759236928175a/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0b3a6f334c6a2345ca15318ff894502a90012536404b37c844a976c76c846e0b", size = 2464118, upload-time = "2026-10-11T18:33:44.856Z" }, { url = "https://files.pythonhosted.org/packages/cb/80/6e4e276a3fde5500224a39150e06a4f134f136165908be69402720718a36/pydantic_core-2.50.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92acf02c770b9d5c4e9a3e8d64e9d95ae26456a86ebb7b7cf64a97f899e5871b", size = 2392881, upload-time = "2026-10-29T16:33:29.305Z" },
{ url = "https://files.pythonhosted.org/packages/bd/1f/945b8053cb061c64e102bcaf7bfb9ed740c0bd4349f9bb978a7d4ddff4ab/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:06e01fbbfdb9be777b316a71b6c49efaf4a08b615d0a98d678cda3023f79d019", size = 2150142, upload-time = "2026-10-11T18:33:46.661Z" }, { url = "https://files.pythonhosted.org/packages/70/d5/5eb56c6317ab4abf33ef9519a50bfac0c96f7f408bad1bc36da6f27d43e4/pydantic_core-2.50.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:1ac5b99bc7b0a68ec64f5f75a6828a7bab5535c8df0cdc07901deb50e0815910", size = 2457290, upload-time = "2026-10-29T16:33:30.587Z" },
{ url = "https://files.pythonhosted.org/packages/2b/78/96a3e50bf0d64aaae781107eb9335b7529d05a85793ed6e7241c4cd1d931/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:a29a061fec0b4e2d714f277e70a3a18125ecff803f2fea6eade2f2e53711d112", size = 2245577, upload-time = "2026-10-11T18:33:48.574Z" }, { url = "https://files.pythonhosted.org/packages/7b/9e/f1fe9384bb37e4e728c51c79151808deacf7b2502e245f50b57c85eeb199/pydantic_core-2.50.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:713d7c33c0e231e3786eb0ec95885814dd65d5f3a09754399b6acbf5e9365f11", size = 2241568, upload-time = "2026-10-29T16:33:32.04Z" },
{ url = "https://files.pythonhosted.org/packages/7a/5d/a6038a0322232758a6ebfa709f4ca14a60bf5b93940cd0b345056a557da4/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f5187624823423e1d1b82b1072ac41dc837389e18d3d0572cc19bbee46cd550a", size = 2263728, upload-time = "2026-10-11T18:33:50.527Z" }, { url = "https://files.pythonhosted.org/packages/9d/f1/cffc4f8456c35690bcb65a8f2304afbf2f519766345a28aba553d5a55cc8/pydantic_core-2.50.2-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:61e13100b30d8d25fc0d1f0297014b32941bf0975a3584bd08f3adebb68d1ead", size = 2257185, upload-time = "2026-10-29T16:33:33.36Z" },
{ url = "https://files.pythonhosted.org/packages/0d/4b/76ded3333a457a9344c4f2d63f2d82d0101654b05178fa4ddfe7d3627674/pydantic_core-2.50.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:3e46a9eb0a0901dd6275e6b06ac3a464885ef350ec4121fe486869de8053e4bb", size = 2293630, upload-time = "2026-10-11T18:33:52.362Z" }, { url = "https://files.pythonhosted.org/packages/84/34/ccd4df1cc39cb1e42fb7ec86a0dc0a23e8479de04d3012ca6b0bf2279a03/pydantic_core-2.50.2-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:523fe6574aea50f456cbc301c1cdddcbf237939f7657f9af99b4f0f71942b59c", size = 2299384, upload-time = "2026-10-29T16:33:34.799Z" },
{ url = "https://files.pythonhosted.org/packages/a9/00/9eca378335c9c1b72bc779bf6e4c2a82ce784f14ec48a0e87102c9870e05/pydantic_core-2.50.1-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:756d669f04e62ec4148ecfe22be6a4484d9b1181a6ef32e205ebfd200540858b", size = 2468048, upload-time = "2026-10-11T18:33:54.118Z" }, { url = "https://files.pythonhosted.org/packages/83/60/404b4b0c416a61867ee642cdc1450bca4d8a9a10e5f5f21e1ecaad05fdc8/pydantic_core-2.50.2-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:6c25b8826be85714b27dafe908d3461e1b09e3e31c0279a7029040c832c781ef", size = 2479384, upload-time = "2026-10-29T16:33:36.106Z" },
{ url = "https://files.pythonhosted.org/packages/35/ca/e3832e9cf93651251de43c8c5c029ed680a3c1a0a1b17ee26c81bed08cbd/pydantic_core-2.50.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:c516cc5367ca3448995d42cb994bf3f4c9002d2a7c22eac9622551269ad1b807", size = 2512159, upload-time = "2026-10-11T18:33:55.99Z" }, { url = "https://files.pythonhosted.org/packages/ca/0d/57c81418a2fdfde0578c71396df773965adebd4421d9985a0f8dcbe9ec4d/pydantic_core-2.50.2-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a59db03b80a156a6fbeae41096866d1cb89cdf31031ba44c768a804538880636", size = 2504616, upload-time = "2026-10-29T16:33:37.423Z" },
{ url = "https://files.pythonhosted.org/packages/4b/f2/773469b5a10a39116a2cb17edaf6d722f017dd8da07161b371465311c542/pydantic_core-2.50.1-cp314-cp314t-win32.whl", hash = "sha256:9d1bed94af6a63835461f3cf7502058eb166c58c4778e11d0f433cfb1bd69e19", size = 2098406, upload-time = "2026-10-11T18:33:58.043Z" }, { url = "https://files.pythonhosted.org/packages/08/66/dec78e28a604f3f4985f5a795d4da3e5fef78b0921aeb7f614ed7c780a32/pydantic_core-2.50.2-cp314-cp314t-win32.whl", hash = "sha256:0d090ae76d5b09c003600c7779b3adcf3d1b49268f616c5c51d844e54c8e318d", size = 2093179, upload-time = "2026-10-29T16:33:38.705Z" },
{ url = "https://files.pythonhosted.org/packages/ee/42/0bb74f8f25204b259b11ab7c12dc7f180893b6bd706118b385147fb6efd5/pydantic_core-2.50.1-cp314-cp314t-win_amd64.whl", hash = "sha256:c8dce1f1e0e5358b682a6ad3fa5e31b31d4560997b8e61417e9217c8d60f8a0c", size = 2148481, upload-time = "2026-10-11T18:33:59.913Z" }, { url = "https://files.pythonhosted.org/packages/d2/ea/79d9ee60648463804145d44b08f1e7e2474427d9704e3331d814483908b3/pydantic_core-2.50.2-cp314-cp314t-win_amd64.whl", hash = "sha256:9b01a092955c1c7530196ad526755cff4f2d11f37ca9a6fc9ebe37ca49a48977", size = 2220575, upload-time = "2026-10-29T16:33:39.929Z" },
{ url = "https://files.pythonhosted.org/packages/47/0d/d801646c9679a4e630e15cf521d4108b93854b42a6e6e02391bd4c6b1095/pydantic_core-2.50.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ceff0acc940be2715bd6ad17b24c0e5304abf44f6efd0f81ee8499e640f9dc86", size = 2174972, upload-time = "2026-10-11T18:34:01.875Z" }, { url = "https://files.pythonhosted.org/packages/eb/01/aa6a7bd017c3228a41942da4fd788b86fb4aad9677212a4737a0c157fe74/pydantic_core-2.50.2-cp314-cp314t-win_arm64.whl", hash = "sha256:4ea01f30e119f1ec7a2034e85c40f491d469896aef02cd6436fd9292fe1be3fb", size = 2160866, upload-time = "2026-10-29T16:33:41.237Z" },
{ url = "https://files.pythonhosted.org/packages/b2/84/23984b763d8862a02a13d27a44b6e8169428fd85ecfed88f54c29108604d/pydantic_core-2.50.1-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:8a6791afa2245e6c6b180122d105941644f5bd410bb18623b408808cc41a3102", size = 2224599, upload-time = "2026-10-11T18:34:04.016Z" }, { url = "https://files.pythonhosted.org/packages/4b/7f/ec2974015b4be5e50266ce732b8942d3d0b3bff751ace8900999c6577bce/pydantic_core-2.50.2-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:a3317630e2791f56071b82f89bb71624146125e4311e6643f708c6ba744b5ac5", size = 2230936, upload-time = "2026-10-29T16:33:43.656Z" },
{ url = "https://files.pythonhosted.org/packages/4d/90/a63cf8586abc1d1a3f6d9b18f0224789ebf003851f092ebda3c7c863fd32/pydantic_core-2.50.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:84f34323a61a365b4e9295de6028474754829aaddd59c7bf1a040e7487ef8f3c", size = 2024191, upload-time = "2026-10-11T18:34:05.901Z" }, { url = "https://files.pythonhosted.org/packages/ae/ff/af99a09eddc348c1d0744c0df28dfcd4839ca497067f0fa2168208b41b1b/pydantic_core-2.50.2-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:f73930dacad5e611fe019e18df6f53c30629b1843d038d303b8dec86a32493bd", size = 2100409, upload-time = "2026-10-29T16:33:44.946Z" },
{ url = "https://files.pythonhosted.org/packages/a6/6a/f34bff9808ffb4907fbf5f5040457d253cacf2da7fce9efbc99ca1b1a44d/pydantic_core-2.50.1-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:23edad659e8dbd8ca7e4e877fe6c81573abbdf215bd25a68b53e1272f58b80c7", size = 2048252, upload-time = "2026-10-11T18:34:07.757Z" }, { url = "https://files.pythonhosted.org/packages/84/75/8db3c46f02d6005277e5c2ff5933c85e7e1eb8798aa334c676c828650c3f/pydantic_core-2.50.2-cp315-cp315-manylinux1_i686.manylinux_2_5_i686.whl", hash = "sha256:e2e6240a0a5ad847017b3d65187877fdb7a11d5a58d43c30736f9f523478913b", size = 2259728, upload-time = "2026-10-29T16:33:46.461Z" },
{ url = "https://files.pythonhosted.org/packages/b2/54/13f419bf1eb59852003818e25935aaf175687d978f4c4bca70e08fe40a3b/pydantic_core-2.50.1-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a5fce22f1e87d181e924e12da7d81cfe031fb3881a5ddf26ad28f141756ca43", size = 2173376, upload-time = "2026-10-11T18:34:09.592Z" }, { url = "https://files.pythonhosted.org/packages/ee/9d/98c8d524921ca24e5c8a81a4b8f12209ac6ab05d0dd58b374e357da0ffc5/pydantic_core-2.50.2-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1fdf5b52ed22f1404c2433d0b46df2d86d9befb742d0b9b4042a2325c43cdb97", size = 2121771, upload-time = "2026-10-29T16:33:47.952Z" },
{ url = "https://files.pythonhosted.org/packages/6d/6c/b5a34d24cd0c81669d8f8339d74e6815abcf2f8fb48ab4b49b84c09be1d5/pydantic_core-2.50.1-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c73622ef819328873b53109ee4f77ceb598bffedd02daf916102be3228866b78", size = 2364559, upload-time = "2026-10-11T18:34:11.534Z" }, { url = "https://files.pythonhosted.org/packages/c4/ea/68c3b1fa919946012c940dea88e94e33e7dde46abe3e3d57053793c9bab8/pydantic_core-2.50.2-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:4e9392ddf67fe97ab9889f796d468c9908a8754b8b6c7520eb03a65012b9a1e7", size = 2196328, upload-time = "2026-10-29T16:33:49.131Z" },
{ url = "https://files.pythonhosted.org/packages/eb/8d/d64d6216a8df365082665927ff923f183056f9049fee08e9777c9ac05296/pydantic_core-2.50.1-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ce8c25ca38cc0e3d7753ba180808de2c0c8cb24eae0df64491e40921454e9831", size = 2444697, upload-time = "2026-10-11T18:34:13.535Z" }, { url = "https://files.pythonhosted.org/packages/eb/9b/cfb6312a5b65b02eab6bc00fb0d258d0e2e49468dab29b430e7cb49b2ceb/pydantic_core-2.50.2-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:48e944091b7bff233920bed8a60b0ed6c96c73d3c5836bf51c4b9a4bfb58e4ca", size = 2380152, upload-time = "2026-10-29T16:33:50.503Z" },
{ url = "https://files.pythonhosted.org/packages/b8/0d/1b1149f60a00ea21ba5f70e28acbd40feb4598af414f80f53c921fac07c9/pydantic_core-2.50.1-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7689580e72a642ab5ec64d5f55b2e33636fa43b4ebe63c0c2c965ef307c7d1aa", size = 2159961, upload-time = "2026-10-11T18:34:15.56Z" }, { url = "https://files.pythonhosted.org/packages/41/5a/7fcced1d84ea294200abfa93e488f36d00732101d17bf5e1f1389e66de26/pydantic_core-2.50.2-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:91c92856b846aa690ea6239ffd6c5003c5e3e296a69095570bf4efee5bfa0bdc", size = 2462299, upload-time = "2026-10-29T16:33:51.884Z" },
{ url = "https://files.pythonhosted.org/packages/1e/35/f236549299dcc78e71e945495d7ec67e78d20844d0999c60601685003031/pydantic_core-2.50.1-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:d5c0e32fdbce7f1e8ef4d11f655694bf5f4175c757a9f1dc2be09b8864e5bcf5", size = 2234581, upload-time = "2026-10-11T18:34:17.502Z" }, { url = "https://files.pythonhosted.org/packages/e6/d0/dd0e355694094f0045922f4da60ac9bc7a314babc494e46089fb00640fd3/pydantic_core-2.50.2-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5299296ff5cafcb69851fd652de8f9268a0bb036bcbedbfcaa94cf743baf3986", size = 2249999, upload-time = "2026-10-29T16:33:53.333Z" },
{ url = "https://files.pythonhosted.org/packages/6a/85/26901a490522b7f75ef9bb9a7afb73e5bb550f0e1cb5b99a0069183e2eb9/pydantic_core-2.50.1-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:40f523349960fa30f3ea51404308ff50f9997a90df639590f47a057c1f32b415", size = 2244090, upload-time = "2026-10-11T18:34:19.402Z" }, { url = "https://files.pythonhosted.org/packages/3a/79/f5f4b36434604d22aacb7152667f87a470e68847c89b136c68a278cf409d/pydantic_core-2.50.2-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:d3d049b557875f9d15230eec8a731749a7a873d8ed6a051903b05bdf46e0e65f", size = 2262745, upload-time = "2026-10-29T16:33:54.79Z" },
{ url = "https://files.pythonhosted.org/packages/ca/2c/481bcfc70ceeb77a778ca6e5b705fe592cb64735c108157f81b7dd9c280e/pydantic_core-2.50.1-cp315-cp315-musllinux_1_1_aarch64.whl", hash = "sha256:d4193206b6587047437f6f11d7e776df23e1c1e23af2a54d9347275614791e10", size = 2277948, upload-time = "2026-10-11T18:34:21.317Z" }, { url = "https://files.pythonhosted.org/packages/14/21/a4c47a286745857758a4bb8d4d5e260c736a7bd6fab5da618f5711d1a3f1/pydantic_core-2.50.2-cp315-cp315-musllinux_1_1_aarch64.whl", hash = "sha256:b0b25d647036972c2951059046cb498f72b7af5a2def27bf8e5f44c3067ab7dd", size = 2293663, upload-time = "2026-10-29T16:33:56.132Z" },
{ url = "https://files.pythonhosted.org/packages/24/eb/f1e09333faa7ba447cde967310f758430cc7c5e987bdab5228816c70030d/pydantic_core-2.50.1-cp315-cp315-musllinux_1_1_armv7l.whl", hash = "sha256:84bc765b282a9d5b7fe0348b8648904f25a6a04b2139da52b1dd30c8ac3a2c8f", size = 2449688, upload-time = "2026-10-11T18:34:23.321Z" }, { url = "https://files.pythonhosted.org/packages/0d/db/25330e609fd219ce16d0a089af1b4b62095f01595dbe6972f7a7dfc313b9/pydantic_core-2.50.2-cp315-cp315-musllinux_1_1_armv7l.whl", hash = "sha256:8835566b3e94026da3ce2249f9ee35c9d2ebe4cd131f6a39bfc82708d3010a46", size = 2473247, upload-time = "2026-10-29T16:33:57.607Z" },
{ url = "https://files.pythonhosted.org/packages/d1/b3/036bde636db8f76d92996e81aefc75678ab5cec4a07eea1ad0c72a893fc3/pydantic_core-2.50.1-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:ed1e728b39a383c81035b2459cfcb35d99dfb01f7d6ebe3a913bc1cc5b81e459", size = 2479286, upload-time = "2026-10-11T18:34:25.214Z" }, { url = "https://files.pythonhosted.org/packages/53/16/704ca88b82156dc485da2dfb11d5d52a10b9ef15a294639c0aa5ebd1643e/pydantic_core-2.50.2-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:3fbcd402e4b607d4b8de5d4591215f31f042af03f9d09e58f693a6221e32833f", size = 2490536, upload-time = "2026-10-29T16:33:59.297Z" },
{ url = "https://files.pythonhosted.org/packages/d0/a3/07f018294ee18d144afeb6df1a47d9e92960f014be45c5ed13519fa5af95/pydantic_core-2.50.1-cp315-cp315-win32.whl", hash = "sha256:bc94f474417604bd383d2cd445d071b07dd55fedceed3ce33407bf1fcc107290", size = 2103683, upload-time = "2026-10-11T18:34:27.42Z" }, { url = "https://files.pythonhosted.org/packages/50/34/1fdd0eebee9f8b6c4ec3c2490f49dc59a652c8c5a7fa0cbcaada0d91cda9/pydantic_core-2.50.2-cp315-cp315-win32.whl", hash = "sha256:8431ecb131489cbdaf6f51f7ade47475f73aff4f1912552f75ba19a6cfe233a8", size = 2117204, upload-time = "2026-10-29T16:34:00.514Z" },
{ url = "https://files.pythonhosted.org/packages/5f/98/f9bd7e1f9b6709f155acb9ef826d9c3884fe925f811fd8e55b9b52280bad/pydantic_core-2.50.1-cp315-cp315-win_amd64.whl", hash = "sha256:983a662de2571cb2502fc8ff47b6770b03d025d2eb314c92f77b3f07c74720ed", size = 2171681, upload-time = "2026-10-11T18:34:29.508Z" }, { url = "https://files.pythonhosted.org/packages/24/6b/702adc91486fb96ba55675d58ee15ddc38fb48553fb35e26fd005e303206/pydantic_core-2.50.2-cp315-cp315-win_amd64.whl", hash = "sha256:04cf672960b8c4fc28bb159c8cdd101fbd311c520237a5c0360448b851adfb41", size = 2261064, upload-time = "2026-10-29T16:34:01.773Z" },
{ url = "https://files.pythonhosted.org/packages/10/87/4bb3e1e7f385c076ab5af4d6dd0571b22042cd8207eb810d5b9fef15ae31/pydantic_core-2.50.1-cp315-cp315-win_arm64.whl", hash = "sha256:94845ff54dc5193f228cab81b2662a04bfbb892e95bdc15edf7399000ce57d54", size = 2156251, upload-time = "2026-10-11T18:34:31.455Z" }, { url = "https://files.pythonhosted.org/packages/36/0d/7f117432b8614526aa0d96fadd2b7fb7393156ddf2caf26a6fe606e6cfb6/pydantic_core-2.50.2-cp315-cp315-win_arm64.whl", hash = "sha256:36917e29c0b815d6b940bc91560fcf453b5b85d55c579796373f0ea11d1c111a", size = 2161783, upload-time = "2026-10-29T16:34:02.966Z" },
{ url = "https://files.pythonhosted.org/packages/47/47/83643225b08f2aef6c8cc4bbe6e3f79c5e139c4364a6e450d0f399d87774/pydantic_core-2.50.1-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:4a53d13cdfbedbfa87f08b83c1a0a5efcc767d785a4b41934fa9cb672670493a", size = 2234363, upload-time = "2026-10-11T18:34:33.65Z" }, { url = "https://files.pythonhosted.org/packages/45/1c/2ba7a34fe8da4c83ec200fb9f85ab4d54b961be08367922743623f987849/pydantic_core-2.50.2-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:bd1fe69056a19c377a6c6de2c1240fb8676ba0512a969965efb486510c5e0f1f", size = 2223184, upload-time = "2026-10-29T16:34:04.214Z" },
{ url = "https://files.pythonhosted.org/packages/5e/66/127ca649ba2f2462039dc1e694c6c01e00a3689f023a617364d3507e6d97/pydantic_core-2.50.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:efbecf43d321f7b9281441f1f213f7c21c66988b0e06c2730ba13ed47a46bb08", size = 2000972, upload-time = "2026-10-11T18:34:36.037Z" }, { url = "https://files.pythonhosted.org/packages/47/01/f71a8da1f53a5be1787d2a97ba6fd9adba115305c7f15ea0bf733ed8336c/pydantic_core-2.50.2-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:9b2383933b1dd7a8c93e02f99ebfd305f1286057172483b8e6898bed3fef69d3", size = 2078174, upload-time = "2026-10-29T16:34:05.426Z" },
{ url = "https://files.pythonhosted.org/packages/5e/4f/e421e0a5d653b1203b090b2e48745976988d7338a647940704b5b9c2b399/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bc1f08f68dac9f9e83845a8039880aba2ab553eb9b2259c3243a313182c253fe", size = 2022925, upload-time = "2026-10-11T18:34:37.972Z" }, { url = "https://files.pythonhosted.org/packages/f6/0a/847e1f5427372692f6eb5c38261fed6b8cedf379e37d9f6134d48b9c8ee9/pydantic_core-2.50.2-cp315-cp315t-manylinux1_i686.manylinux_2_5_i686.whl", hash = "sha256:a1b4d60bad57d6e0e7cd30cc4d1dc646d258dd6dfb7935ce76c7dd72dd1e98b0", size = 2268813, upload-time = "2026-10-29T16:34:06.733Z" },
{ url = "https://files.pythonhosted.org/packages/d5/4e/ea5568e2491e1a71100f15ae8c2d01ef52db42a184a2d4e716bc79e5eb8f/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5dfe41f232befddb9c4377f6cfc702b51595e2d78ed082672adf8758d2c4619f", size = 2192867, upload-time = "2026-10-11T18:34:39.921Z" }, { url = "https://files.pythonhosted.org/packages/f5/bb/12966dc82b5e9c347981658b52e24349b6242902b644725c00ee7bdf4267/pydantic_core-2.50.2-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:93bedc68c4138088ea3c042189c01d2945f530f0dcc36e1ea02df8418bd4f1cd", size = 2098331, upload-time = "2026-10-29T16:34:08.69Z" },
{ url = "https://files.pythonhosted.org/packages/ae/5e/3b8c3a35acbe219909ada5defad5d7d9fed845fb2b37bd5ec518f453c119/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:adc06d218a1cadfd2ec4628424d7d79ce4eba69c2965e7e7b55106f0da5208c8", size = 2388687, upload-time = "2026-10-11T18:34:42.184Z" }, { url = "https://files.pythonhosted.org/packages/d9/fe/340416b7ebe664524e7ada16ad2e3e069888b168010d75bda6a502bf2bf8/pydantic_core-2.50.2-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:d684ff87d00de2f6abcbbfa9b0fdce8b513dd799a900809dcfa7eb62ad69832a", size = 2197432, upload-time = "2026-10-29T16:34:10.091Z" },
{ url = "https://files.pythonhosted.org/packages/b6/aa/7889b4e515f91a2e8c0ae6b5081fec0feb30c4398d1434e14793c60f173a/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2cf91809d0721ab81592ba67bea7694821679c10b1a2e3c3460082b286c1918a", size = 2464415, upload-time = "2026-10-11T18:34:44.384Z" }, { url = "https://files.pythonhosted.org/packages/c9/4d/425162c3d88af0d190c4dc4f940402cc6dd44d934359a37f50da6b7e4672/pydantic_core-2.50.2-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:bb6d49f00e2babd40af4889d6b9f4bf50879f522dd4e2e22cb705cac5e0c5e8e", size = 2399300, upload-time = "2026-10-29T16:34:11.462Z" },
{ url = "https://files.pythonhosted.org/packages/08/78/93449e628eb8a6fdcce3eff9043081179d1bc7ce6f1bff32dc5006f41e00/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:23923ab9292c40da026330b1ecf4dc2618c8e86e0422e5d1fbf50d94d64ca4f8", size = 2150823, upload-time = "2026-10-11T18:34:46.392Z" }, { url = "https://files.pythonhosted.org/packages/2b/fe/2882bddf2e2038c8f41fa69f16ddeb6eeb1c002cc0cc0eb2157f73bb34e9/pydantic_core-2.50.2-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:6d47b2d0e00e030e0ebdb08115a37da7294e7a6d196d134a77ed7ee6c142284b", size = 2462672, upload-time = "2026-10-29T16:34:12.79Z" },
{ url = "https://files.pythonhosted.org/packages/f4/f1/72c5bc129fceb0d00f05dc1e67f518c1728de1928c55f81fc13d7690de39/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:f3377c8c2b3ce898423c5e5dd94c7982e30aa7717a7e6ab2470b9de364963709", size = 2245806, upload-time = "2026-10-11T18:34:48.805Z" }, { url = "https://files.pythonhosted.org/packages/6e/ac/d7e6bfc6a95573647d1a699e19228dd99914ae6aab79a2bec293a038e980/pydantic_core-2.50.2-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:95838be125133fa314753f8cc5168f7c41e3a9dd5eef5383dbbbeed511bd4853", size = 2246293, upload-time = "2026-10-29T16:34:14.065Z" },
{ url = "https://files.pythonhosted.org/packages/28/2a/922a0e78f3aa6ab837b59f88190233fb8dad546c17995fde9bb3ed3b9b49/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:455a773617b5913bf5c20d0692e5787b119e52c4d40ea644ca31f5758fd31be2", size = 2264160, upload-time = "2026-10-11T18:34:50.876Z" }, { url = "https://files.pythonhosted.org/packages/74/64/97473ccaeb4ae5eb338162bd8479af121484e67f7078499730e3ee0a5592/pydantic_core-2.50.2-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:2d4a043c820363359e40ac96270e1915e5376e116be1f59cee0701775de3e376", size = 2259507, upload-time = "2026-10-29T16:34:15.391Z" },
{ url = "https://files.pythonhosted.org/packages/52/a8/0f1449e3e1b20941c9372faa18e7b6a092e30cdfa841902473f7989532ac/pydantic_core-2.50.1-cp315-cp315t-musllinux_1_1_aarch64.whl", hash = "sha256:1a9006395dece0e32e704c315eff8a00bede494f6108546cfc5539c89fef4f9a", size = 2294031, upload-time = "2026-10-11T18:34:52.876Z" }, { url = "https://files.pythonhosted.org/packages/48/15/78a9b7a6a0d0679ce1e46cdd38ff95afd18dd2d6a01ed87c80a01bc003f7/pydantic_core-2.50.2-cp315-cp315t-musllinux_1_1_aarch64.whl", hash = "sha256:57bccd3e1128736c792094da1feac6da46ec3feb1957ef649a9b3e4f8de6fa04", size = 2303550, upload-time = "2026-10-29T16:34:17.162Z" },
{ url = "https://files.pythonhosted.org/packages/d1/d0/1031f492857de70355fb16524bbb03efce5fd34c93ed4a1ec60be07e0d4b/pydantic_core-2.50.1-cp315-cp315t-musllinux_1_1_armv7l.whl", hash = "sha256:d2d82aa62521c55ddfb000ae70f88cdd8de974078f6024e821dfe5addd0c818f", size = 2468423, upload-time = "2026-10-11T18:34:54.995Z" }, { url = "https://files.pythonhosted.org/packages/39/ba/b697173f82e99cdcf5ec6654b2edb66a00f2f92ef9d9a168861fca3fcded/pydantic_core-2.50.2-cp315-cp315t-musllinux_1_1_armv7l.whl", hash = "sha256:24120c644322a35b5eb765416ac8442214c45689e255f5fc6e761e7e7c743c8b", size = 2474919, upload-time = "2026-10-29T16:34:18.841Z" },
{ url = "https://files.pythonhosted.org/packages/46/52/269ffffa645b8e47906395a39cf9db1151ae9fa4bcd7f47b960bc31baf37/pydantic_core-2.50.1-cp315-cp315t-musllinux_1_1_x86_64.whl", hash = "sha256:009634b83993777ddcd69cad0ffcace43dabde692109528e35f0fde91e386a8b", size = 2512598, upload-time = "2026-10-11T18:34:57.149Z" }, { url = "https://files.pythonhosted.org/packages/71/10/986be76723784ae87f9664658fae39df340eaa21681cea95b6339d2e1b3e/pydantic_core-2.50.2-cp315-cp315t-musllinux_1_1_x86_64.whl", hash = "sha256:b1adf2f37521b578c438f6ec61d1c1d0c20b076863ddd5d8b6f3ab658857db3f", size = 2509095, upload-time = "2026-10-29T16:34:20.16Z" },
{ url = "https://files.pythonhosted.org/packages/31/5c/e47e28281f20326ff6f3c31d626f0a83e615d2d94ba41bb0ad6ec237184a/pydantic_core-2.50.1-cp315-cp315t-win32.whl", hash = "sha256:3fde4fdc6487a58d944ca87cf5adc95d5f266e872c19599f5f4c0a8a1b1f9f9f", size = 2098535, upload-time = "2026-10-11T18:34:59.313Z" }, { url = "https://files.pythonhosted.org/packages/5f/0b/38dbd38c67de7642f87348da70122b89ca4c5fe8227cfdd5cc00c1cdb454/pydantic_core-2.50.2-cp315-cp315t-win32.whl", hash = "sha256:d79aaa468193c1bed6b0560d3c5c9f9e369e3efb0b128b38a2537e2d51d42773", size = 2096972, upload-time = "2026-10-29T16:34:21.715Z" },
{ url = "https://files.pythonhosted.org/packages/03/ad/759e181e69c1b472c60b2049e5f61d5da1deefdd5ce1df4bb2ebcf771f25/pydantic_core-2.50.1-cp315-cp315t-win_amd64.whl", hash = "sha256:1c8632d4ac04e6f91128fca584b3a8a507d81604c24eeaaad00d4be42765c32b", size = 2148645, upload-time = "2026-10-11T18:35:01.571Z" }, { url = "https://files.pythonhosted.org/packages/7a/df/154baf8413b08d6daf2f5dec211c303ec6bed50c032717f354f8dd85c994/pydantic_core-2.50.2-cp315-cp315t-win_amd64.whl", hash = "sha256:cc488daac19e6f5292180fac5e0ef7efffabeef4e096af9a17a75a7d3d6781f6", size = 2226659, upload-time = "2026-10-29T16:34:23.177Z" },
{ url = "https://files.pythonhosted.org/packages/6d/56/8a702c27e5be9f47e5f19d8669227424290e4c024e7c370279cbaf244b4e/pydantic_core-2.50.1-cp315-cp315t-win_arm64.whl", hash = "sha256:c3ede305158e75510be50869b319550ab072008c13d64d4ab1e094fb286b6f44", size = 2175297, upload-time = "2026-10-11T18:35:04.079Z" }, { url = "https://files.pythonhosted.org/packages/b5/17/eea7a1e7d98a79ef11b6d31748b70c58f8723f87d7ee42edbb6a101f6245/pydantic_core-2.50.2-cp315-cp315t-win_arm64.whl", hash = "sha256:91315aea7adb51821d4a3378ede953bfdd76e3bf71551de4a82d7638cef7dc02", size = 2166387, upload-time = "2026-10-29T16:34:24.433Z" },
] ]
[[package]] [[package]]
@@ -610,7 +610,7 @@ wheels = [
[[package]] [[package]]
name = "pytest" name = "pytest"
version = "9.1.1" version = "9.2.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" }, { name = "colorama", marker = "sys_platform == 'win32'" },
@@ -619,9 +619,9 @@ dependencies = [
{ name = "pluggy" }, { name = "pluggy" },
{ name = "pygments" }, { name = "pygments" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } sdist = { url = "https://files.pythonhosted.org/packages/79/42/bb4641652e58dedeac2ec51abc1f8cae4fee4ec63899a0999016229a0f2a/pytest-9.2.0.tar.gz", hash = "sha256:97c4f3b1f3c7414aa36465e09b8220f1d03627993bd51b1282e193753c80557b", size = 1797555, upload-time = "2026-10-27T21:30:20.728Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, { url = "https://files.pythonhosted.org/packages/51/39/1f5d8c8877a0fa327db959c19341d4be61660de1ab6b7391f9eb805bcd67/pytest-9.2.0-py3-none-any.whl", hash = "sha256:07acdcf88ec174f907dc01af70bf52d49b014bbab03faeeade31c96b18c2e588", size = 411776, upload-time = "2026-10-27T21:30:19.131Z" },
] ]
[[package]] [[package]]
@@ -687,27 +687,27 @@ wheels = [
[[package]] [[package]]
name = "ruff" name = "ruff"
version = "0.17.2" version = "0.17.3"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ae/58/690cf56da80c8f2c8bf12042b6017beac2c34fe961f65420bcb3fb7fac38/ruff-0.17.2.tar.gz", hash = "sha256:84d632402d14c23b488f941db23712d6736a013225162062d34558c3f3c780eb", size = 5333400, upload-time = "2026-10-22T18:38:00.181Z" } sdist = { url = "https://files.pythonhosted.org/packages/59/73/81f140c4edd9cc323de5478e6bbd3bbcfae9a82692df1a258de8cec6a6d2/ruff-0.17.3.tar.gz", hash = "sha256:a57049c8addb3662073859bb55a7ad35aa54f8a7c9ed268911d1333c5aadaaa9", size = 5339444, upload-time = "2026-10-27T00:34:05.914Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/98/b8/8b6d69c57e8fe1cc6af1aa7493b72a1a5b5a8afe34694e302025441729d4/ruff-0.17.2-py3-none-linux_armv6l.whl", hash = "sha256:96a65e755d8dba094707fb23ab602111d9a31ec2835b3a6992a1914a8fcb211e", size = 10183857, upload-time = "2026-10-22T18:37:14.956Z" }, { url = "https://files.pythonhosted.org/packages/9e/8c/083365310c4e79c842f25bf5a842facbd9b0c0ec336f96dffa5074fe723d/ruff-0.17.3-py3-none-linux_armv6l.whl", hash = "sha256:ab86b84cd9d05e56d329742c0cabdcbe2d1d56c98f4a04aab9b5b66196febfb4", size = 10216522, upload-time = "2026-10-27T00:33:29.822Z" },
{ url = "https://files.pythonhosted.org/packages/e8/61/12cc2ff7b2fa90a268ce1e2162c23aaf76e601d22623f6a31b0eeaa764b0/ruff-0.17.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:39c5b55c960515cdb32be16e69efb5734c589c06a0eb38a22694524a336b889d", size = 10210904, upload-time = "2026-10-22T18:37:17.985Z" }, { url = "https://files.pythonhosted.org/packages/72/26/4b2edf31cfebe9605d8f6ed8f1d6f376d3a53d4c9a123e62661c64c440f0/ruff-0.17.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e407b4a6852853a9a2cdce96963e94ad9775578bb44e7561a0f2829f3cc04b11", size = 10217786, upload-time = "2026-10-27T00:33:33.949Z" },
{ url = "https://files.pythonhosted.org/packages/e8/7c/b8850a8e4ad29f64a827dd6c6902db4a9aa538fb2b9eef4e26084001a3bb/ruff-0.17.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:92a80d2f44584dabb0189b3a6c393e37e525e04942838482010575cb81945be7", size = 9408439, upload-time = "2026-10-22T18:37:20.964Z" }, { url = "https://files.pythonhosted.org/packages/a2/7e/537aba4b7f54397a374d2f6a6fc93e9c8015e18553f31a4a7b7dbced48b5/ruff-0.17.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:b75cc113b3aca388933d8296b667b1ac401a80317ea3074c9b0a66541199da29", size = 9440362, upload-time = "2026-10-27T00:33:35.784Z" },
{ url = "https://files.pythonhosted.org/packages/b7/b6/6a64fae86ca5e5e29d5adc311e5481de726cedf9aee607435a16c31c442a/ruff-0.17.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b7d0906677d89bbf3cf73cde64b5bbe2ab35df3ae43e49ee2627301e91265bac", size = 10047078, upload-time = "2026-10-22T18:37:23.717Z" }, { url = "https://files.pythonhosted.org/packages/3d/66/283e39734f66c8a5ebaa93c22eb7b7515779a0b1d5a5c0ad20e05c0edb41/ruff-0.17.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab5fa7ed5ce18554ea69d850ad162cbaa0c85fc47c08d83a569f806681c9a6ad", size = 10094281, upload-time = "2026-10-27T00:33:38.216Z" },
{ url = "https://files.pythonhosted.org/packages/10/19/23d376547715175aa1bd46df48df6aacc7c6439d9daf19cac15975180aa5/ruff-0.17.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7cfd783590edf2b77317882631900f6a13ea2e8eacefe6ebf2370d2715299160", size = 9990478, upload-time = "2026-10-22T18:37:26.584Z" }, { url = "https://files.pythonhosted.org/packages/21/4c/1ffc768497bd025a32c72152771e40c0b99bddc5cc6e4f50c7949a395346/ruff-0.17.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c673c170e27834d1262e3a9c1e97bdc9b9c3b180c63d89173ce69747232474c1", size = 10022543, upload-time = "2026-10-27T00:33:40.181Z" },
{ url = "https://files.pythonhosted.org/packages/93/6c/34cda32cbe13adac5c6b291a307a5570d8ebd37c0c86339f0fe628e55632/ruff-0.17.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:122258f6ecde0e0dd1bb60b24263c3edde4338caf723fca711cc729f9bfd414a", size = 10673624, upload-time = "2026-10-22T18:37:29.767Z" }, { url = "https://files.pythonhosted.org/packages/73/c3/f90db547301080fcb82c92abf914c8bd57b89911d9b65a6c4cc6b52d0635/ruff-0.17.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6a754c0341bc9ca151b7b335988b3263921c08859f84ca219b70cee825571b6f", size = 10683921, upload-time = "2026-10-27T00:33:42.182Z" },
{ url = "https://files.pythonhosted.org/packages/bf/10/5d762a4dbc851d8bb47863cf3a28a61bf31c804d393b70d891fac899f5d1/ruff-0.17.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7f8fba429d6ac4128ab29cfb8fa6240d2fa671d04adf7f84d3b22fc7b6b6bea4", size = 11351797, upload-time = "2026-10-22T18:37:32.535Z" }, { url = "https://files.pythonhosted.org/packages/e5/98/92d4a8a26c6c96cf1a5b34dd59036d8a71e52d650aab065c58867e964e63/ruff-0.17.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4590b9d86c0b1543ebb09094254e191cd43fe8edaf330baa035aab03da251532", size = 11373408, upload-time = "2026-10-27T00:33:44.19Z" },
{ url = "https://files.pythonhosted.org/packages/3f/04/a0e92a81faf5c81c1c98d4374c8401734ccf2b8cca7d0170cb0fec08c6e2/ruff-0.17.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:583bd67bb9e7061dd09bc46a0a884bae2bbd214dbcd48e509813612619683869", size = 10949414, upload-time = "2026-10-22T18:37:35.501Z" }, { url = "https://files.pythonhosted.org/packages/43/02/1f24d2e3064c9753926deeb901afa57777b37ee46799b09ddd7aee6e5145/ruff-0.17.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bbde18beccd98c29a227c63c99022e3443b99258e08326df6425dfe0443705ef", size = 10977469, upload-time = "2026-10-27T00:33:46.179Z" },
{ url = "https://files.pythonhosted.org/packages/c3/d8/59894cf630d2d86882457a2a6a0a1533410c5c805bfbac6f6eb4b5aef69d/ruff-0.17.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c60f93fec7142dba5b8f487f94bf2f6231dae24dc11e4c48648347c8b0720004", size = 10528000, upload-time = "2026-10-22T18:37:37.88Z" }, { url = "https://files.pythonhosted.org/packages/f0/20/fecedea2209f574ab92033b344e01a7f0c2dc76c6d6d19a14c9f8141173f/ruff-0.17.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7fa4b0f931cdbcb8a52044f370a3eb429fb42eb88807b0e800fcaa98dd42b24e", size = 10577491, upload-time = "2026-10-27T00:33:48.233Z" },
{ url = "https://files.pythonhosted.org/packages/ab/95/d6e30c51e851d37e10762ad91fa1e76ddaad998589b5667962a3cfbc9edd/ruff-0.17.2-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:c555a877f8f41447e318101cfefea9bf12dfa9f82eedf90c376e57e069f917d0", size = 10571779, upload-time = "2026-10-22T18:37:40.444Z" }, { url = "https://files.pythonhosted.org/packages/33/36/b93fdf0bc6f84f0cd487aadfb0354deb40013669ea35ac3d26855de1e959/ruff-0.17.3-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:39405f341deec934b4289634324ad695f72df2970f9121582872eaf5b722f0f5", size = 10608748, upload-time = "2026-10-27T00:33:50.284Z" },
{ url = "https://files.pythonhosted.org/packages/f4/5a/a7e1d74fb464250137d3d4eca645d323536533b672004d04ee0e46e0caf3/ruff-0.17.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:ba405cbd5ec7bda1f555d5e52be1fd68d02608d4d95d24591bdac92cfacb579f", size = 10051707, upload-time = "2026-10-22T18:37:42.812Z" }, { url = "https://files.pythonhosted.org/packages/5d/b7/7d3f5f14145daead1efb0b9a8289ed1daa100f419d60774375951723ce00/ruff-0.17.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:68074094f55ca3d29ab19491f640282f7a84061c4c7977aa5094b968ca457e9a", size = 10075057, upload-time = "2026-10-27T00:33:52.074Z" },
{ url = "https://files.pythonhosted.org/packages/6e/f2/02fca29ca7999380f0350a7212fe755c11ab9c22d61327356cce8decdbc6/ruff-0.17.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8c21b4ab24485358f17291a4f8dc5399b834e963b8efedd920fe4fdb02081bb4", size = 9998227, upload-time = "2026-10-22T18:37:45.224Z" }, { url = "https://files.pythonhosted.org/packages/b0/ed/93ff7a5909ee56e2bd4b982deae9e0efb2724417874a90c9e41ad66c6dd7/ruff-0.17.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:27bb571ac78fef178fe5118d2827df95c2aae74a48fff2b15f6acd03af5ff5bb", size = 10022431, upload-time = "2026-10-27T00:33:54.061Z" },
{ url = "https://files.pythonhosted.org/packages/d1/00/55aa604a093dfdc1526e570fae0bb7d37a2221c7b4728d5f080b2add16ba/ruff-0.17.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:5875a16b0de70b43cb81eabea5ae5e8f5d1b2fd1b5e6ed07f80cfe5019d0bd5a", size = 10377692, upload-time = "2026-10-22T18:37:47.704Z" }, { url = "https://files.pythonhosted.org/packages/9b/07/a83b9eeec8c1c3c0e4eda15f06c3a91001bfd2687ea2f07b750719bf9712/ruff-0.17.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:029c37cbd3de48ca37aa12117bead2d065f15da17d66bf84e0d4274013705db2", size = 10394903, upload-time = "2026-10-27T00:33:56.158Z" },
{ url = "https://files.pythonhosted.org/packages/e4/5a/a7a640f47bb906ec8fa233ea84319d503e6dee02b59e3901bd312ebd1f9b/ruff-0.17.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:2d086be452eed686aa51271ecf2cbe5037956517cc6ee39a17b4489ac12f8bbe", size = 10790587, upload-time = "2026-10-22T18:37:50.191Z" }, { url = "https://files.pythonhosted.org/packages/af/b2/05c39857c2526fcbebe46ad93a8dc97424656dfef14d3ef933abe1ffd331/ruff-0.17.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:692fdb46d0fd9c71e380947dc5fc72f2c3f881073bc1f739e4579376231affbe", size = 10799780, upload-time = "2026-10-27T00:33:58.204Z" },
{ url = "https://files.pythonhosted.org/packages/2c/d4/c3b74e1b4808438ea7f14bb914478025e2d51261f60774a54a0fa222ec46/ruff-0.17.2-py3-none-win32.whl", hash = "sha256:6bce7d1f5165701ae18f5df85c65afe78cddfa6c5fb4b9bdb0014d60388f3edd", size = 9870537, upload-time = "2026-10-22T18:37:52.486Z" }, { url = "https://files.pythonhosted.org/packages/ec/d9/47171a16739eb7a353936b31c20cf54340531e68c9af7d72fd7a784e235d/ruff-0.17.3-py3-none-win32.whl", hash = "sha256:46539cb031b01fd29ae0a02142483c46640b233ee12bb1c1b690bffa1555d5ca", size = 9908481, upload-time = "2026-10-27T00:34:00.087Z" },
{ url = "https://files.pythonhosted.org/packages/1c/9a/c7da96c2c96d20704cf9027f7a5121da272a8c05f1d1531ba949e3b3df04/ruff-0.17.2-py3-none-win_amd64.whl", hash = "sha256:1b59d447f197bef9ef48a22ba1a6016137753c6099e9bac9c36eb566e422424c", size = 10282826, upload-time = "2026-10-22T18:37:55.2Z" }, { url = "https://files.pythonhosted.org/packages/d0/a0/f08c1631bfd81faa6a90eaf70c37c786c11bed2046302b56bb506c25e1a0/ruff-0.17.3-py3-none-win_amd64.whl", hash = "sha256:56b8ffb3f3b3cbccb8b76b71b38261b7ed982ef5d20f5b447b1b61a7f4949b24", size = 10341333, upload-time = "2026-10-27T00:34:02.099Z" },
{ url = "https://files.pythonhosted.org/packages/81/60/a3604c79cb02777b6cb84da894104021986f06c4bc41375102cc95049a32/ruff-0.17.2-py3-none-win_arm64.whl", hash = "sha256:195ad918027f817c119310ca89a42d347887dfc28934aaa869325e9c5dc9d962", size = 9827036, upload-time = "2026-10-22T18:37:58.031Z" }, { url = "https://files.pythonhosted.org/packages/99/d5/4ad4ed31413c301430918e70d3d38ae6f097af0766cb9ab8c9d502b2a4d6/ruff-0.17.3-py3-none-win_arm64.whl", hash = "sha256:81fce74bcd6be0e9da7c67c7553fd0f1f12964a2052cd728ef917b937a5ca1d9", size = 9854332, upload-time = "2026-10-27T00:34:03.847Z" },
] ]
[[package]] [[package]]
@@ -839,89 +839,89 @@ wheels = [
[[package]] [[package]]
name = "websockets" name = "websockets"
version = "17.3" version = "17.4"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/30/4b/4692be6d5bf739ed049b3813901c42091f8c054df89d5c089343823dea0d/websockets-17.3.tar.gz", hash = "sha256:1c7dd93a1c9f89297471493674b1fc1d9467cafd45374f2172213046bdb3a329", size = 188878, upload-time = "2026-10-19T16:58:05.813Z" } sdist = { url = "https://files.pythonhosted.org/packages/75/79/590c5f0cc7d1661195aeefccb0e342b79f4c77d368d526d028821a390332/websockets-17.4.tar.gz", hash = "sha256:09d3939b70df0c8ad2da145ebd50978bccaee1d565e838b0e7921a34d38571eb", size = 191391, upload-time = "2026-10-30T15:10:35.873Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/27/ad/f65e365bbf38d61164b0c2f64776fc7714bfa59ac51e492b038a35f4ab1b/websockets-17.3-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:bc8fd230ef013951900c1fda100eebe4a4b1b407dde2a44186995042dda2c628", size = 218288, upload-time = "2026-10-19T16:55:53.387Z" }, { url = "https://files.pythonhosted.org/packages/1f/5b/085530961668661300de7248f9bf3ba84e98f47875f0fcc87637d024f82b/websockets-17.4-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:d0f943b53bb1d7389d72a5380078670e5fcc34a1328142148b72d20fd82031c7", size = 218810, upload-time = "2026-10-30T15:08:12.14Z" },
{ url = "https://files.pythonhosted.org/packages/48/1c/33271eba53b4927a1b91d99d838a6d7b888e6f708425262c911450d73696/websockets-17.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:cdb8e4b0f6a41b857c74c03116e8ffbef59ef8e533e934b532843178990d01d5", size = 215977, upload-time = "2026-10-19T16:55:55.063Z" }, { url = "https://files.pythonhosted.org/packages/f2/fb/5a29e9569f74988822b7bc076287dd7f9f5b68a2070cf5eb4cfada5863d8/websockets-17.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2fd562372d4afaf18d1c7f1c68e39bd32818bb16d5f57f778a4a4ed88eedcaae", size = 216506, upload-time = "2026-10-30T15:08:13.561Z" },
{ url = "https://files.pythonhosted.org/packages/3d/f3/6d982c60f6c7c95e5faed53d39263df273ecfda904c7d6920c37eee58dd1/websockets-17.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:018fab26140ce221c3853454a608e4e7004f4f10641cb97819dcd5b917bb2e99", size = 216193, upload-time = "2026-10-19T16:55:56.424Z" }, { url = "https://files.pythonhosted.org/packages/df/73/eb8544367c13cd87df39d41b74853741fb7182e2a4bb659ad7d36f9066c8/websockets-17.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a0457561e8a87a6a3b509e21c35084d7d207ced262139de3f738e9676a476bbd", size = 216719, upload-time = "2026-10-30T15:08:15.08Z" },
{ url = "https://files.pythonhosted.org/packages/ff/c6/4ff6b8d2a4b32719d4658bbd791ee9d19939a47a69b961c961fd9dc3a64c/websockets-17.3-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:dd0f3037d501178a761fd2814370aca547f79e99a200a61a1f6e7b79ec3d78b9", size = 225573, upload-time = "2026-10-19T16:55:58.252Z" }, { url = "https://files.pythonhosted.org/packages/24/fe/c3707e426d9bd779dfad9f319b2b431e5e60d563d7547d3780cbd1646028/websockets-17.4-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7b4ee34acefa9ba1333f02b817982f1e4127f6f9b975017a707c4e738d7f9fb9", size = 226098, upload-time = "2026-10-30T15:08:16.581Z" },
{ url = "https://files.pythonhosted.org/packages/eb/8f/46e4030947f5590cb61832901f44a3edb07ef8d1e27f6c06b42c5f1c4d33/websockets-17.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8dbb3862aa649de95660b6337d97101977f160b5b354d07b82fe309874d2c8bc", size = 225927, upload-time = "2026-10-19T16:55:59.561Z" }, { url = "https://files.pythonhosted.org/packages/c1/24/d203439f909ffbf37260ca28a3432381412d2e79912ebf98be572e63055e/websockets-17.4-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ef03fb4b3685df0ed9dc6a074a090f030345cad2c23c61818da3e08e693a9caa", size = 226449, upload-time = "2026-10-30T15:08:18.29Z" },
{ url = "https://files.pythonhosted.org/packages/f0/1b/3db30fdf4fbefa4ac463060d3f87e2d444f960aa1234028468c09b88bffc/websockets-17.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:af37a1e6b6bd506b597d5fb4cf111fb527d1b2223608a5d441d4738827cfd166", size = 227169, upload-time = "2026-10-19T16:56:01.029Z" }, { url = "https://files.pythonhosted.org/packages/4b/17/3ae5d9486a170a222dd0437b4f2bc02ecf324177498a942dcac200e65d05/websockets-17.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c5e970d3996c597186c9dbb6e77a8048737a834ac03cb13186a54999e0e1c31e", size = 227695, upload-time = "2026-10-30T15:08:19.692Z" },
{ url = "https://files.pythonhosted.org/packages/02/ed/849799cfc0e3e359b75ca1d50605376da52dc9ef0b2a53931e3f5991f96e/websockets-17.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:740bba4223fa5216d4d6c5dcd29e697f37229b8a61b69e1a50527b0ea993ab2c", size = 228542, upload-time = "2026-10-19T16:56:02.377Z" }, { url = "https://files.pythonhosted.org/packages/34/37/1307a8e30a961726f2350982fc01b0b3498db29b030c78c566e65ba64487/websockets-17.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6afc32a7a1a40b071070419537df3559d26907086556769683fa97b7b6e90fd5", size = 229069, upload-time = "2026-10-30T15:08:21.682Z" },
{ url = "https://files.pythonhosted.org/packages/19/53/dcc397e20ce792fffd9a8f79b6333275748ee7a23f807e576166a0b16edd/websockets-17.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4170556762c6fd2913d99dfdd8dbb4c927584899df7facb1034627e355263c45", size = 227772, upload-time = "2026-10-19T16:56:03.768Z" }, { url = "https://files.pythonhosted.org/packages/9c/bd/4c6167744e202d20a3c770bcde8fae5d427317b1885712c2e02718cd8133/websockets-17.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5da7f7f1ecf866186f195f7ecc374c915e1838496b86f2f3b41da27c798a25c3", size = 228296, upload-time = "2026-10-30T15:08:23.371Z" },
{ url = "https://files.pythonhosted.org/packages/c9/36/e5935782f71e9e22247b2a2e562da05e18d8945c49f2692ca1bdaceec178/websockets-17.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d355b22270be8062360b9df2ee0b10277590789a1059257916fb9e256a0cff80", size = 226518, upload-time = "2026-10-19T16:56:05.332Z" }, { url = "https://files.pythonhosted.org/packages/14/b3/3438eee41b39afb44c3c6e45dd686b88a7f8d742b96877b77de4fe0e0255/websockets-17.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:043945a43873c7c11a3f75238d475c95f902e967de329eb8763704546cfaa6b6", size = 227045, upload-time = "2026-10-30T15:08:24.749Z" },
{ url = "https://files.pythonhosted.org/packages/27/76/bd5978a1e403fc646230cf35cf7c06b40b3a232d440881b9e89ede229b18/websockets-17.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4aa85b34c21cf97b3470793d0bf0763d32bfd1e67b83067a0a3ba8dc3474dacf", size = 223766, upload-time = "2026-10-19T16:56:06.703Z" }, { url = "https://files.pythonhosted.org/packages/46/39/0bac9052d402efaad1b202ff2f1a57b6334aa624d20f509312765601b507/websockets-17.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:927d602fb8fb595a69e6b1ae998a6ec19851d0fb9391759456e6425090996647", size = 224297, upload-time = "2026-10-30T15:08:26.275Z" },
{ url = "https://files.pythonhosted.org/packages/da/87/c66eb39bd851d5c14d9eb75aa64d03cfcf2ded77927ca067b5917291d19d/websockets-17.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a0e2e7c2c1a4f25db7b6596738d810c10732e77f852247300a1bc49e364d6dc9", size = 226644, upload-time = "2026-10-19T16:56:08.198Z" }, { url = "https://files.pythonhosted.org/packages/a1/7f/5809f6ba5a7d1065301800daf605bdb661a9db5ef868ed189dbf691122ac/websockets-17.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:769e4f7b090e10cae4c082e3e39b9458ec2444505df84953c16c0468b16539c4", size = 227169, upload-time = "2026-10-30T15:08:27.703Z" },
{ url = "https://files.pythonhosted.org/packages/fe/ae/b9a31d5497fa0b73629a097b173a26123da2ed656b562114644bc623414b/websockets-17.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:7fa69212ea60ea24e2d8aff23fe102b1f87f1b27a320f92d2e7b3468997a496a", size = 225107, upload-time = "2026-10-19T16:56:09.627Z" }, { url = "https://files.pythonhosted.org/packages/fc/f7/c8f6771e7cc2a038cf78bb12d04d29dbbbabf2a84d76999bcc94532f3020/websockets-17.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:03ddf4eb76ced8725dcbb000ff60b0fb8d7330ed011f6dc725a1925c5fe02051", size = 225632, upload-time = "2026-10-30T15:08:29.441Z" },
{ url = "https://files.pythonhosted.org/packages/61/0c/b773a102f84b60b2052b2cfde773d2f3cd3e8eaa93a145f54450460549cf/websockets-17.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:80a6e37e235780f189558ab804f3cbc01c18f5c578995739b49613d544d34ebf", size = 225891, upload-time = "2026-10-19T16:56:10.973Z" }, { url = "https://files.pythonhosted.org/packages/2c/2f/9104786d952978ca2d011321109e35c05be24c3937ef2a62b4d6e25f7515/websockets-17.4-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:94622cf24e80d03054ceb69a06a86c950e12fe993ed06b933f7895232cacbc3e", size = 226415, upload-time = "2026-10-30T15:08:30.962Z" },
{ url = "https://files.pythonhosted.org/packages/b2/fa/e7e538575dd4912dea9ea4c9ae86519aa1420969d9cb5cf02fd10fd20ba7/websockets-17.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:628efe13630ac3397e7d1278b6be6f22f37a86d638a324334b4cd43c1daf9928", size = 227064, upload-time = "2026-10-19T16:56:12.295Z" }, { url = "https://files.pythonhosted.org/packages/ae/a7/2e1606b3d616b86bb6c70a62d5f87fc9a3dbd81f0bbe24c4706ace76751d/websockets-17.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:bc581579885728bb54ef15000295c20e6a64a200b1b73d5cfca71879a3eb3e1f", size = 227594, upload-time = "2026-10-30T15:08:32.443Z" },
{ url = "https://files.pythonhosted.org/packages/28/87/bf2b928c156deefd0c50285a48fc985e2ca85d284e7d9981cc5b79b0e696/websockets-17.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:443fa28652682d2a3ec3eca49af1b87cb2523561257683e62e03a98967b22138", size = 224618, upload-time = "2026-10-19T16:56:13.73Z" }, { url = "https://files.pythonhosted.org/packages/67/2d/78ca44e2be6d397aee63a4e68d3a99a33bb14bb3405e94688bc5a2160db5/websockets-17.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f42671900e0b602d05a074c52fd3d757c2f8e5c4f73f771794dae1aa4aea5f0e", size = 225146, upload-time = "2026-10-30T15:08:33.888Z" },
{ url = "https://files.pythonhosted.org/packages/8c/9a/d59817ba3535f787c1cf8990a01b3d2896a7d9d8f3b5d55bed22fce595a6/websockets-17.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6705af9d1005127302c04d9f1533ba56e289336c465923ef878430e99295a0b9", size = 225571, upload-time = "2026-10-19T16:56:15.199Z" }, { url = "https://files.pythonhosted.org/packages/d9/b6/6dad684e54fc8e09d70c9a095506db5c836207d8e846e3cb1e79424e3492/websockets-17.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:2b23dacf7a8a88afe92f77bcdbf4cf043a84692bac2f2da02f4011c687e19b48", size = 226095, upload-time = "2026-10-30T15:08:35.285Z" },
{ url = "https://files.pythonhosted.org/packages/a6/96/8210379dac889e16c75099aefffa200dc55dc8cb030010f309a7eb9c75c1/websockets-17.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9bde5d82d7ac4859e0cb3a35e148f537a8d4e56aa43f42582b5f263982f095ae", size = 225869, upload-time = "2026-10-19T16:56:16.569Z" }, { url = "https://files.pythonhosted.org/packages/d9/e5/7b04bad7c11c1f92238429352defbac1406743338049afe1328c7a7f95c0/websockets-17.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d48795c7d64ca1c42e3d61fcc6478cb15a19b6ad3f9c882bc8d8dee49d4a420f", size = 226395, upload-time = "2026-10-30T15:08:36.8Z" },
{ url = "https://files.pythonhosted.org/packages/97/0b/d4741c977fec9e6482beffece572d544d42d230f56fc3ef504f96521063a/websockets-17.3-cp314-cp314-win32.whl", hash = "sha256:be7be57097a65a8fab3d0f0a10d12931deb626a7d8082eb732b1ae41f3a202e3", size = 218232, upload-time = "2026-10-19T16:56:18.028Z" }, { url = "https://files.pythonhosted.org/packages/b1/16/4eb29c4b12f77b3139470063308ae1e6a0860b9073b1d27d56e8ce19c512/websockets-17.4-cp314-cp314-win32.whl", hash = "sha256:8588edf317fbe4f16a949bc5a6ccdd22b6e6fd5b4745d128bc40bccf31024ce5", size = 218784, upload-time = "2026-10-30T15:08:38.263Z" },
{ url = "https://files.pythonhosted.org/packages/33/69/2f9ce899776d0c522f7f44080cad4228a1c14ec13b0125a2efc12ebe41c9/websockets-17.3-cp314-cp314-win_amd64.whl", hash = "sha256:2b06213c8e1ce93b18d0476d7a5e647436c8f23d099888c9065e930e80f4bed5", size = 218582, upload-time = "2026-10-19T16:56:19.399Z" }, { url = "https://files.pythonhosted.org/packages/6d/85/2496a24de80c215b55a15f037bc368a2ed0ce7ecb1e909039ff4e76395de/websockets-17.4-cp314-cp314-win_amd64.whl", hash = "sha256:70293c25d79d1f084fdd665894481547575e429d2a75e8d72caec2f6b6b07af8", size = 219137, upload-time = "2026-10-30T15:08:40.112Z" },
{ url = "https://files.pythonhosted.org/packages/4e/e9/11b885666d72add674440a3435c059bea325616959cec8be42605948714c/websockets-17.3-cp314-cp314-win_arm64.whl", hash = "sha256:6b7b540bb321f7f0a7b8f5d107f941bb361e3a154c0b594dae0394997b91b743", size = 218465, upload-time = "2026-10-19T16:56:21.005Z" }, { url = "https://files.pythonhosted.org/packages/9a/a3/cfe95d4eb569035e5a1d2e5cbeb4ccf0eb15a19e1ef2ad819a85960bf375/websockets-17.4-cp314-cp314-win_arm64.whl", hash = "sha256:adc30e5d064c9e33049e825408fa4e952f4e8a0cf4c329dc2ded72e1bb5bae08", size = 219017, upload-time = "2026-10-30T15:08:41.778Z" },
{ url = "https://files.pythonhosted.org/packages/c4/0e/0c0f40ab8eefe9d5a6c78b4e267d8e37d70d3db8dd195574e0ab867e4f81/websockets-17.3-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:c9d06edfd974e918b40e0662a7e76dac13125529ee5ec9fbe42dc0d6faa209c3", size = 218614, upload-time = "2026-10-19T16:56:22.457Z" }, { url = "https://files.pythonhosted.org/packages/2b/dd/4143048a2b27303e7de8ef53d7d0ee3987734fbf93e09491d5a1b0a55187/websockets-17.4-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:bbb6bed49f083e2ec8ae0bb21c00c7c5171ab8e08a8b008c61a43fc6c944fdac", size = 219141, upload-time = "2026-10-30T15:08:43.184Z" },
{ url = "https://files.pythonhosted.org/packages/1f/93/d22b3d63aa439bc01a9477880070d05e0b257b0548294786a33be3e842ec/websockets-17.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:0a0bcf66289b9d017c175562c35c5304b5cc3ed56aded994ae30b0a9654ba405", size = 216151, upload-time = "2026-10-19T16:56:23.95Z" }, { url = "https://files.pythonhosted.org/packages/a8/3e/a63b5f3a5c24be2898eb22a3093a56d4ac4cd24ae79e7ab7a1b971ed4611/websockets-17.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c7a56140fcbf4662cb263754a754c651a16fe2196cae1fd232d18f89acd30c83", size = 216675, upload-time = "2026-10-30T15:08:44.682Z" },
{ url = "https://files.pythonhosted.org/packages/b9/92/f60a9672cdeb8cb4f7156e6e83c76d59fdc586737a97ac77129e9fc188a2/websockets-17.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f3ed747b7651668131029c720fbc32c7ee2d129bdad200e6c8f4037b20f7913a", size = 216354, upload-time = "2026-10-19T16:56:25.251Z" }, { url = "https://files.pythonhosted.org/packages/d6/80/f238a10f0b23853c665c602a42784023995ac26674e480252c21af39f4f8/websockets-17.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:18d3710becc612ec6f0229425bd6495133dfcbf30f7b6569585acc8ed2f1b452", size = 216880, upload-time = "2026-10-30T15:08:46.304Z" },
{ url = "https://files.pythonhosted.org/packages/9d/da/2be8c5f3c6612d4523f95b074e2352bc11bd090cf121d32bc11af1d52e70/websockets-17.3-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5868a02c4f6d698ddd19b4c9742748c03dec045289915806845189d96bc3d314", size = 225937, upload-time = "2026-10-19T16:56:26.726Z" }, { url = "https://files.pythonhosted.org/packages/d1/b1/899b69aba1e8d65ac5bfe90b97ada22a815f1dcd3840bf188104738aac33/websockets-17.4-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:00ad98e4bb0df7a6711c645eec16d5a9fdf99f0692bd4f5b6cb85a68677ba8df", size = 226462, upload-time = "2026-10-30T15:08:47.723Z" },
{ url = "https://files.pythonhosted.org/packages/85/1c/cb84aec871840acd66dfa84520bf7b8e48ce70bdb3f4baa4efd097fbe6b1/websockets-17.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fa87ea2ad64e59fe5622c1dc5716106b6d6e7e4b26b848cb5f5710a75c292ef5", size = 226215, upload-time = "2026-10-19T16:56:28.1Z" }, { url = "https://files.pythonhosted.org/packages/28/a8/598c2b2a22dfe14d352400df804db8b78a68c1498b3f775a658f07f21618/websockets-17.4-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7849a096d6fdba3f2bdf849eb89f2d233a24400c92faa3efb25fa8dc615b706c", size = 226741, upload-time = "2026-10-30T15:08:49.314Z" },
{ url = "https://files.pythonhosted.org/packages/9b/7b/cfb65557accec2eda2c80288e13eca6f1c797c579375906eb73f4053cfd1/websockets-17.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:65e937576141ac50fac32fa9736dbb3e0e4a7769f91be004011a09216c6ab05f", size = 227389, upload-time = "2026-10-19T16:56:29.518Z" }, { url = "https://files.pythonhosted.org/packages/6e/ad/84a6de3cc3dd4ee336428c31f89a82e002299f83e10d967f0e9a5cbaf591/websockets-17.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e30550965c78223439815dbdf40c93eb8931dfbf12427110dcfcaeebef76feb6", size = 227914, upload-time = "2026-10-30T15:08:50.746Z" },
{ url = "https://files.pythonhosted.org/packages/70/a3/7e08ec4b23e376ed228831aa44b2f2a3f2e7025cc66dda4b2e53dcc2619e/websockets-17.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:35381b575f70a08d4241e73c0cde4672bb9b2a9aae8767ca6b7fb61305a052c8", size = 229534, upload-time = "2026-10-19T16:56:31.072Z" }, { url = "https://files.pythonhosted.org/packages/69/ab/1e191b97d60a934c071b7b73c39c5db39721a925cc53acd929c615f85550/websockets-17.4-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:725be1aff55f1c873a79cea0f612df9dff21a9d2c2e05783665b824b8aa7c24e", size = 230059, upload-time = "2026-10-30T15:08:52.596Z" },
{ url = "https://files.pythonhosted.org/packages/4b/7d/8a813e3e53956f876cfaad4f6358625bbd94168aa8046c44e2f3561f12af/websockets-17.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5142798871a248b422f353e0684d51dfa119de3403a6fa0904b7cb806d9790ce", size = 227959, upload-time = "2026-10-19T16:56:32.656Z" }, { url = "https://files.pythonhosted.org/packages/f2/57/ef8a64732ef4367091ab05f9b22c1ee4c28657d4a0d088a644198eb5ded6/websockets-17.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b6c7f91520c29420639925fd1cecceac9ca07366b50c09ea24eac1cd116b4866", size = 228486, upload-time = "2026-10-30T15:08:53.967Z" },
{ url = "https://files.pythonhosted.org/packages/41/23/5f3dab6ab44f2f30042db83a658a57129d5e3c0425c798b3c3e727bc060a/websockets-17.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e164c4a6868d11988c7f2386e3278ccff21c665cd7d08279b62709dd37a3167c", size = 226691, upload-time = "2026-10-19T16:56:34.101Z" }, { url = "https://files.pythonhosted.org/packages/48/1f/dbad3afcacf2c52d1fdf83b611d849598f0a047d7333ff476266532877b8/websockets-17.4-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6531540aa63e6864cb0615a3a5da70ae03191334507852a5385320bb7916df3f", size = 227216, upload-time = "2026-10-30T15:08:55.687Z" },
{ url = "https://files.pythonhosted.org/packages/5b/49/99a3fb5a2e94d677fbd0d014a6f9a56a3d2b8e8007b7175727bc4fc4ae94/websockets-17.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:10b24e836ee0233ca078e1244f6ba40343a22127a9a736f46a7e7b18de2cc711", size = 224225, upload-time = "2026-10-19T16:56:35.452Z" }, { url = "https://files.pythonhosted.org/packages/5b/47/59e325d6ca3e04ba5cc7a139d891ef83cad3fb460a183c8f359f378b69a9/websockets-17.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0a57b99f2e28922b5a2434317580fd23fdf118caff4c5b752f5452836426205a", size = 224755, upload-time = "2026-10-30T15:08:57.203Z" },
{ url = "https://files.pythonhosted.org/packages/6f/e5/f503a4d757858d083c90ea85dd1fd636ae9eb63c9552c75a4c38514ee7ed/websockets-17.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:56a76556ab04ab54a675acce2cac8b4e74ea2cec558a7c9ed37e0367b04b5d50", size = 226984, upload-time = "2026-10-19T16:56:36.887Z" }, { url = "https://files.pythonhosted.org/packages/b8/b6/28a471318dfb0060b58c47d0a252cb5078528a1aa1fdf5e6e88e2a61cf18/websockets-17.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8fd30d217a4275107667fe1d4f601e2d50c797d0727148cfbb384536d65f04d3", size = 227508, upload-time = "2026-10-30T15:08:58.597Z" },
{ url = "https://files.pythonhosted.org/packages/4a/a6/8586844ad4b21988837624e1f4b331bc71919c85a6eb1ee3c8bd29893f27/websockets-17.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:33221e49be79d7bfe6d12bc52ef9458b256ab74fa8ac5660acfa3065de1c7870", size = 225436, upload-time = "2026-10-19T16:56:38.213Z" }, { url = "https://files.pythonhosted.org/packages/ee/29/7485ac6fc4b342b43964811381f56276fec967cae6a851ae4b815613b10c/websockets-17.4-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cc0cd4065a65ac98f3e2742ee9f314da9d224db49b07dc17d3a20cef0d79658d", size = 225961, upload-time = "2026-10-30T15:09:00.414Z" },
{ url = "https://files.pythonhosted.org/packages/a7/b9/f419506494d6ffd205a9c3258f56ff5aeae6976de26361057de74f4ab659/websockets-17.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:2fa79b9a48ae59744316c71a34cb9f76f30c93e117338522c5135cb7eac10351", size = 226325, upload-time = "2026-10-19T16:56:39.606Z" }, { url = "https://files.pythonhosted.org/packages/95/a6/520e1f7aa9644ddf58f5a51fc37f46e15293a68cd35b70d5b81b7331ca1e/websockets-17.4-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:71f76bb8e59be847330b8a8d3753f85397677e128f9f9692c215f8cf721cefc7", size = 226851, upload-time = "2026-10-30T15:09:01.979Z" },
{ url = "https://files.pythonhosted.org/packages/28/c5/55a58faa67149e84cb98b33f9d8fda95242677ea07b78f3ecaf2e77877d6/websockets-17.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:f87f02afe05c1c246012e3de100a77cfbabd17db709fe93cf074271ab239b312", size = 227342, upload-time = "2026-10-19T16:56:41.041Z" }, { url = "https://files.pythonhosted.org/packages/e5/0d/444799abe7418f361da8c201c6858b64b4e8d47265f90851e5cb68b4d90f/websockets-17.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:c2db7b9e2d726f7e051bedb67abcf460b84b11437b2f9fdacda7e6931dace85b", size = 227869, upload-time = "2026-10-30T15:09:03.405Z" },
{ url = "https://files.pythonhosted.org/packages/35/e0/fbe561dff24b6ef1b2034877f37b56eb9b81b3c103e2a8b03819bf2b3b6d/websockets-17.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8135a549e27e9941a70e2d2156676ef2ffeff4b06e9d564dbaaae963482afdef", size = 225036, upload-time = "2026-10-19T16:56:42.433Z" }, { url = "https://files.pythonhosted.org/packages/cb/86/6da20322a07364a0ab0787a2b0d4367770f59404fdab6e35122413a7f63e/websockets-17.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:332b88022d0acd47c44158bcd43f47f14eaddcd979247f242b1a33291c45cdd1", size = 225562, upload-time = "2026-10-30T15:09:04.891Z" },
{ url = "https://files.pythonhosted.org/packages/0d/37/d80753064f8370a24d3bec74807c3928d7b0ebef5381956e2a0108b365c2/websockets-17.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:ace8dd1efa5f44c482d091aa38322e20e3ffe5adcdba6b67ad9f4e7da58f3f68", size = 225807, upload-time = "2026-10-19T16:56:43.774Z" }, { url = "https://files.pythonhosted.org/packages/2a/da/53e44d96238dc207011ad5d93b0540c58e1c680a86f48dc3fa8e5138b343/websockets-17.4-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:5cec3e7fbc911ff4fcf43b24aaf1b96906381ad36030f42897def3ff4b78729e", size = 226334, upload-time = "2026-10-30T15:09:06.42Z" },
{ url = "https://files.pythonhosted.org/packages/f1/9d/a11d3168522a2959c4a5661728744d62b9eee344abdba5f9fbc1d359742f/websockets-17.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2129c9a5fc05a230ce088745a790af073e582b584badc7b05db067ee326f56d7", size = 226241, upload-time = "2026-10-19T16:56:45.088Z" }, { url = "https://files.pythonhosted.org/packages/18/08/82cea972a87f229ee1903e9adf74c17d1e6c4371d581ebb11ccbed4e3a7b/websockets-17.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cbdf4b42f4514422051889424fd03bc269be8c5d751bf39ec29ad761590bd10d", size = 226767, upload-time = "2026-10-30T15:09:07.837Z" },
{ url = "https://files.pythonhosted.org/packages/06/28/16107b3b83fd2b5024bfecf8d000140f555204a0191202f9c4c981137810/websockets-17.3-cp314-cp314t-win32.whl", hash = "sha256:af7c474f0d8f4d2a196370e85033873c7b063a0b5da9a4f143aa023e3bd25407", size = 218365, upload-time = "2026-10-19T16:56:46.41Z" }, { url = "https://files.pythonhosted.org/packages/96/f3/ee6afff802e39e8d6ef29ee4bdc30f8272aaa38295c19a911fad91fe95fc/websockets-17.4-cp314-cp314t-win32.whl", hash = "sha256:16550e24ac22c27e0a728100d8de196b5f054bab6748ef9f0b168fb8b3c32e35", size = 218918, upload-time = "2026-10-30T15:09:09.31Z" },
{ url = "https://files.pythonhosted.org/packages/77/dd/8b00e1dd552788d8c7762d7ef61de8f2f238d7357b1a02d4c4650b112196/websockets-17.3-cp314-cp314t-win_amd64.whl", hash = "sha256:0c63ad657695e8f1a48624754cb6b464118ac50d4c1d477c26461c98390f0a62", size = 218706, upload-time = "2026-10-19T16:56:47.754Z" }, { url = "https://files.pythonhosted.org/packages/11/56/b0b07917f9c85a4d24a37300f4ae888bfa7c20f08902dd6a5c61a0d1b26f/websockets-17.4-cp314-cp314t-win_amd64.whl", hash = "sha256:5ec906bb21b35e66cfdcc2ea0133f249f52b7d89e3e45ec8d2bd2d84a0255d7d", size = 219260, upload-time = "2026-10-30T15:09:10.832Z" },
{ url = "https://files.pythonhosted.org/packages/39/eb/5d90d57a5cbe614b2f13433aaf1c0b068c804589379061ba06812a185a89/websockets-17.3-cp314-cp314t-win_arm64.whl", hash = "sha256:3c4f631dc8d00e470cde3ec0a35d4b58a817bea592411956f1cf4099dbb306b3", size = 218622, upload-time = "2026-10-19T16:56:49.07Z" }, { url = "https://files.pythonhosted.org/packages/19/a6/a63113909ea602d3d22b0f32a7a9950d8d18b725cfdce735eab9b5f5d198/websockets-17.4-cp314-cp314t-win_arm64.whl", hash = "sha256:c00f0c45851a2c61efffd03dde8fb4d594026b5cc18196c94e15460f1225f2ed", size = 219176, upload-time = "2026-10-30T15:09:12.444Z" },
{ url = "https://files.pythonhosted.org/packages/6e/41/1efe5ecae1f32495dc75641704f959bd2d8e56cdc825f3acc5e010a51960/websockets-17.3-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:f300f28dff74ff4148c21e6971e4de6ca0c34e0086a3d95c017c5174ebf85ca5", size = 218211, upload-time = "2026-10-19T16:56:50.423Z" }, { url = "https://files.pythonhosted.org/packages/ee/be/4032fde05e62d1c52edd250fda475e910cac0cd180e377690bff24147b5e/websockets-17.4-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:8c51ac7b31578a7275650f580aac985940985b6d2e57247ac6d5e07f2c936cd6", size = 218740, upload-time = "2026-10-30T15:09:13.833Z" },
{ url = "https://files.pythonhosted.org/packages/01/ec/4edb653533241203db3f11522e122338d4fda50a41ce2a542029086f8eda/websockets-17.3-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:32029441af953e980b9940f042b337fe4ad9a97fa8b17a4ed878ec67b5b89ef4", size = 215944, upload-time = "2026-10-19T16:56:51.766Z" }, { url = "https://files.pythonhosted.org/packages/74/1c/b47f5786eb45599072411571651606fb22f6e825d522d627daad974ea636/websockets-17.4-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:023dc13bdc42a55e152acc81c39c90ec6cc4159ed17d23c6d73460fa2a73ab16", size = 216470, upload-time = "2026-10-30T15:09:15.394Z" },
{ url = "https://files.pythonhosted.org/packages/d5/b9/ec450b9650925dd5cf27c1f36d0464786fe3ac702ed656879c4c0dd5c4df/websockets-17.3-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:09f007b2150eff015b2109f006e2ac2012d376f41486defa88ae78a776b00cff", size = 216164, upload-time = "2026-10-19T16:56:53.125Z" }, { url = "https://files.pythonhosted.org/packages/70/98/0021f55de633617b4a8390cca17e700c9ba5d99323103128412dc2c15aca/websockets-17.4-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:6fd8cac4907fec8302726d49e6969acfffaf720bc39b1cee23f473b81f4dce01", size = 216695, upload-time = "2026-10-30T15:09:17.185Z" },
{ url = "https://files.pythonhosted.org/packages/15/1e/63f7fbff9f0540a0331f1a62424c9ff24faa6ba8aec264f55e9e31017eea/websockets-17.3-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7bdc658c9474864893e0b6b94f1390bf6a912952df1870dd93bb7d1a902b5628", size = 225608, upload-time = "2026-10-19T16:56:54.442Z" }, { url = "https://files.pythonhosted.org/packages/28/56/2c67389b47c4b9e9e9318d9b3e4bb52a2fef3749ff00b0b151a342c4d42d/websockets-17.4-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4441d655a53185ef1b01b1d42ad9b3a96b905110a9219043e3fdaec855ba3b6e", size = 226134, upload-time = "2026-10-30T15:09:19.156Z" },
{ url = "https://files.pythonhosted.org/packages/0c/1b/f6679ad61cb8c110033a1b30d561d5c1052983e202157251ff1778f27932/websockets-17.3-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:15decc0fe293d9e3f924bd483f3d76f3d0ff26845112a470e3f8842fec2c82a1", size = 226042, upload-time = "2026-10-19T16:56:55.727Z" }, { url = "https://files.pythonhosted.org/packages/75/bd/ee3a679a10741a0cdd7aa8bdd6d03999069672826aa7a6495fdf589ed961/websockets-17.4-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3acc45494c08a4d27375a2c28474537f68910219f42624a08011221b04623d04", size = 226569, upload-time = "2026-10-30T15:09:20.587Z" },
{ url = "https://files.pythonhosted.org/packages/28/c6/ed993aaef8046cbbb939ae367ee3dd5383dbd055f0500a5b8aed101168b6/websockets-17.3-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:997617dd7856c5dad742bf6893b872319156dd43f47587541a59e4a852d52bbc", size = 227203, upload-time = "2026-10-19T16:56:57.076Z" }, { url = "https://files.pythonhosted.org/packages/c4/42/b293a5d07099549797138d09fda5bf878d302b4980e61678981c8dfdc98f/websockets-17.4-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3b78b39377630a70aa2198e5f03be416e690364a046b1d8af80a9263733083d0", size = 227726, upload-time = "2026-10-30T15:09:22.015Z" },
{ url = "https://files.pythonhosted.org/packages/aa/96/7057dc71affe6c8252df8648f5a225642228a6fdd8d046a6b9aa1fb0e9eb/websockets-17.3-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a004c1fe5a72c496f28c6c1d8c2dc5cfe3278d99478e18f8b838c70a912b366f", size = 228900, upload-time = "2026-10-19T16:56:58.633Z" }, { url = "https://files.pythonhosted.org/packages/2d/81/d048ff83c4d0fc386f6008529d12690d0dcb410f73570430f13fe4d543f0/websockets-17.4-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6cf4c002268da5c11cae9083aec71ac943bce9d95e0f83c8bf9a95a3ce421fbb", size = 229426, upload-time = "2026-10-30T15:09:23.444Z" },
{ url = "https://files.pythonhosted.org/packages/43/e3/8b3268bb7e72b465e55172b86d55f58aa277fb6d9bac92aee615345874df/websockets-17.3-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:35fe35d20baa661cef4a12ee7bc42eaf2376d43ef9816ab29dd47ca957f7a73a", size = 227684, upload-time = "2026-10-19T16:56:59.975Z" }, { url = "https://files.pythonhosted.org/packages/e6/21/1df51f94047dfb76c32fd7508036e4b55352d0f04fd0e3bee525a2122225/websockets-17.4-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ed7fe197b8bb66eef4f62c052e33752d456d2fdda74805469d4bd954950ab339", size = 228211, upload-time = "2026-10-30T15:09:24.902Z" },
{ url = "https://files.pythonhosted.org/packages/59/15/fa31c0a12240b1225c3712ff080d2f7ede4f695ca90e59fdb58e8affabba/websockets-17.3-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8b3bede7d583bc9f47d8693a29984882e59077db1a5238ad977fc70ae8169741", size = 226580, upload-time = "2026-10-19T16:57:01.371Z" }, { url = "https://files.pythonhosted.org/packages/cf/13/33d6a28471da4c30a98a2313c76f984be2056a703c5c532225dc28f5dba3/websockets-17.4-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f0494d3d97502e270282cc6a1f8648cd7335b4fe8d5c452006e5b7f9d62b8892", size = 227106, upload-time = "2026-10-30T15:09:26.315Z" },
{ url = "https://files.pythonhosted.org/packages/22/b5/75d911c206a3035e2ecfad107878e62c06e574b2437fa2cfb4ecef436fa3/websockets-17.3-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dafbb58a149d1ebd5767b3f064f911c5ee0897608005e51c3904b0ada2a8d99c", size = 223967, upload-time = "2026-10-19T16:57:02.839Z" }, { url = "https://files.pythonhosted.org/packages/4a/86/ff8441e56ca677e51f9c826868fccde2a82bafe0ffa337fd38823b64253a/websockets-17.4-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ab9e4bd60796866c578e3afa0a17c98636887827a39c2e238a61989c18fd8e7b", size = 224499, upload-time = "2026-10-30T15:09:28.116Z" },
{ url = "https://files.pythonhosted.org/packages/0e/f8/f98aa36f611acd5770819f6690125c20d55f221fd6db8d00d284b5f78e89/websockets-17.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:80326bc571164bbbb6bead2e35d0c943b23b24fde629032197c58151529d24d8", size = 226689, upload-time = "2026-10-19T16:57:04.458Z" }, { url = "https://files.pythonhosted.org/packages/da/91/470d7a619ce4aa837ff5b6a5836227c43bdfdc42ef837cce48f508992438/websockets-17.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:8d148b4b295224d3a19e7bd9f02ebe7344de254b3b2c4d0628ce42ade0bca119", size = 227213, upload-time = "2026-10-30T15:09:29.847Z" },
{ url = "https://files.pythonhosted.org/packages/1a/36/d52f5dff7597de6fbfc26c3d126abd668137915719aebf97abcc3be77eca/websockets-17.3-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:7d53fc4b90ef6e11de639dc18744d9be8cbd70ccb5ce80027aaffe7b1ab0215a", size = 225353, upload-time = "2026-10-19T16:57:05.857Z" }, { url = "https://files.pythonhosted.org/packages/a4/ba/55ed1422d6c73b5b81996ca195f2a256a339b26c553f5686afc2a926bc2b/websockets-17.4-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:6597a1f7413189b4bc58437babcdf39b79da416e5c477ae98323a88ecc108188", size = 225876, upload-time = "2026-10-30T15:09:31.249Z" },
{ url = "https://files.pythonhosted.org/packages/d4/10/dcd70ad9f9cd20e8af43284ac250af610e8bf9022da25f5b409153d033b4/websockets-17.3-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:fdd00937f15f5f93c8e53d8060a8c4c010431ab595a9b5b50ddd569a04603944", size = 225879, upload-time = "2026-10-19T16:57:07.249Z" }, { url = "https://files.pythonhosted.org/packages/e9/59/f133d260e7a48d3e231c3a495eeb6114b880c1e96bfc781a949c1f73991c/websockets-17.4-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:a9b0026538719f7a532998d84640ab2e91ca1a051ce4b8d924fdcb607307e921", size = 226403, upload-time = "2026-10-30T15:09:33.199Z" },
{ url = "https://files.pythonhosted.org/packages/70/ab/f26c6bfa99d38ca3d1bf23145306d97c5d0c75e133a8d837373e4b3d4629/websockets-17.3-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:1dae5cd6c92d4440b15f20aa2987baa73c47ea14baa0fec8536a542e82e0ab33", size = 227031, upload-time = "2026-10-19T16:57:08.817Z" }, { url = "https://files.pythonhosted.org/packages/45/16/bb3355453ffdc31d3a14ddc0b50e058f13a97d2ed019ba6c380e03456169/websockets-17.4-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:a703f23ca15a51251fc94f1774683f5aa511588a7ad6bbb83960cdb2a4dcd978", size = 227558, upload-time = "2026-10-30T15:09:34.703Z" },
{ url = "https://files.pythonhosted.org/packages/d7/3f/c8fd74cdb526cc65295b7468f2f84ba16678646d8174288822f99f510274/websockets-17.3-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:b225c656efa2dedecc81651d2290170af25887b7de4f38c5efc7c5ae2b9442ab", size = 224842, upload-time = "2026-10-19T16:57:10.298Z" }, { url = "https://files.pythonhosted.org/packages/0c/ab/f3d55a83c2a4daa8bf62594b00a721dc8767518ccb833f932b0178eb6c19/websockets-17.4-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:6b061ea7ca94966af7cd7a87cb189ff15972c103244611a2b1e8408c04f1dbe6", size = 225369, upload-time = "2026-10-30T15:09:36.204Z" },
{ url = "https://files.pythonhosted.org/packages/c4/c5/b3a07ce1c54fc0f5441642f5352662b90d7e658c966730eaf6d78a5f20bc/websockets-17.3-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:bd645b5d2f810a181c44f4a71135e7c365b44f4be9ed3d1892635be13a857553", size = 225744, upload-time = "2026-10-19T16:57:11.615Z" }, { url = "https://files.pythonhosted.org/packages/88/66/3a320bfb718d9ae1eff15ba7790979c0e6fdec74e388430f426e6647d7f4/websockets-17.4-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:e6d2a185ec4ff0478089305abd833da3a9fda8803eb77325b89ebeae5585a3b7", size = 226268, upload-time = "2026-10-30T15:09:37.565Z" },
{ url = "https://files.pythonhosted.org/packages/66/37/8321a8a6ab0215d4df0238e49ace5f539364cf337bb122b85c788e8c93ec/websockets-17.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:d2605a3b2af8d66f8b75312d223642c065ec773446c04566d9030ad5e2db13c9", size = 226006, upload-time = "2026-10-19T16:57:13.049Z" }, { url = "https://files.pythonhosted.org/packages/da/ee/1f9cdeeaaa2d5e158b7a40f971e74b641b35f977674b46229a5bb14e270a/websockets-17.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:9a4e95d56994a45ac76541853796720436ffe462957d0b1b89e87ff3d3b1627e", size = 226530, upload-time = "2026-10-30T15:09:39.171Z" },
{ url = "https://files.pythonhosted.org/packages/f8/04/8c457a7e6a260b673bf4e6f2f422eb4af5f11c48505748359706f24f1bef/websockets-17.3-cp315-cp315-win32.whl", hash = "sha256:82e60c00cfb8493a5ba27cdf9b3695fdd153f666f73e11f18968f78ead1d8705", size = 218196, upload-time = "2026-10-19T16:57:14.41Z" }, { url = "https://files.pythonhosted.org/packages/da/28/506406ef09e6e168796f0187bdac0767f38a560e6785c4ee26e583aaaf76/websockets-17.4-cp315-cp315-win32.whl", hash = "sha256:b7ea61af293a153f2d67430038cf0efc10140caa623f2bdf5a9f5a457ff20f57", size = 218751, upload-time = "2026-10-30T15:09:40.748Z" },
{ url = "https://files.pythonhosted.org/packages/a0/7c/c2df129d9e344b99bb388bcac54fbbdfeec29e6c716c12dc9a3398757940/websockets-17.3-cp315-cp315-win_amd64.whl", hash = "sha256:4d5f2492cb2192aab2d42e57f2caa2159b4fd20fde06f859ec13354f524b176c", size = 218490, upload-time = "2026-10-19T16:57:16.234Z" }, { url = "https://files.pythonhosted.org/packages/25/a6/818647a9a9b3ee8bf131e9ab4fa1f2db9c6539f08e7084dd57549e0bd49d/websockets-17.4-cp315-cp315-win_amd64.whl", hash = "sha256:8b45d40f07081b377f0b837ddbfba4a9ab4a89a7b569d3c095436564e767a27b", size = 219045, upload-time = "2026-10-30T15:09:42.251Z" },
{ url = "https://files.pythonhosted.org/packages/e6/4f/721b778c85559b2949e46246838e31a346dd815b3cdd8460fe517235675a/websockets-17.3-cp315-cp315-win_arm64.whl", hash = "sha256:55494475206113dbaf731dc8c5ffd5e1094175b753b7bbaad09fe3d7df61165c", size = 218403, upload-time = "2026-10-19T16:57:17.631Z" }, { url = "https://files.pythonhosted.org/packages/3d/b2/0338a9abec3df8556627af987352657ce1c8456f2060b54741570b174122/websockets-17.4-cp315-cp315-win_arm64.whl", hash = "sha256:29b615c2d5dd883f89c107a782681b73a7aa8b2bd894130310e45eb09b143913", size = 218957, upload-time = "2026-10-30T15:09:43.866Z" },
{ url = "https://files.pythonhosted.org/packages/b3/28/241d9ebb4dad79346b5a6abf9f14d7685b12ca358d250eea4920cdb1caf3/websockets-17.3-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:23cb6f6d64e3a389bb8ba150f947f9a6c8c0aca383e814ca3c5cffab738d5646", size = 218523, upload-time = "2026-10-19T16:57:18.978Z" }, { url = "https://files.pythonhosted.org/packages/68/92/22e83d003cdbd22be5d35e28c260203e9fbb6c73b17a8e1509f4ad49e74d/websockets-17.4-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:c568e72f5425a74616daeb824afe28da460cbeea036432f41c6af5380b83300d", size = 219056, upload-time = "2026-10-30T15:09:45.528Z" },
{ url = "https://files.pythonhosted.org/packages/b0/bb/559fe539992102c66cbca38b488af6c7b184ab7736d819afc0f72268a2b4/websockets-17.3-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:f27e7e8a85512b6a4b490d5756edde8e6772883a5800e1476172e44eda3f7d7c", size = 216103, upload-time = "2026-10-19T16:57:20.412Z" }, { url = "https://files.pythonhosted.org/packages/2f/ca/071d38e7237f6ada4f577b74f7cbf5bc2ff7e1f9360ebd2837e74a22dfd4/websockets-17.4-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:bfc86dff091104eec770c29ea1831dbe191968a70aa2d8ae8eda3240d863970a", size = 216630, upload-time = "2026-10-30T15:09:46.951Z" },
{ url = "https://files.pythonhosted.org/packages/34/ee/e8ade4f13a3d0da68636fe44bb8ac1a3e36fc57070512788320dead42067/websockets-17.3-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:6342f79d6f50e680c8aa59e0615d745e867e51ffd59c77093be60b7c433067af", size = 216316, upload-time = "2026-10-19T16:57:21.761Z" }, { url = "https://files.pythonhosted.org/packages/fa/76/22d4523f92d88b8f5ebfd3df7ab5e825146af100f7e2caeae7d359ae51ea/websockets-17.4-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:f5f06f59e19aa0770aaf651ce91c196fa620b35aed49a7e6c36e7335f3f83db7", size = 216841, upload-time = "2026-10-30T15:09:48.285Z" },
{ url = "https://files.pythonhosted.org/packages/4d/06/4812b2a7a5e6571d9be425e76cf54ee69aaa15720caa02307785aa5b19a7/websockets-17.3-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fc23e72bd7f8a8e540fed2244524d0a9aeb6ae3325f3d56f04a7a3debe5b04c2", size = 226467, upload-time = "2026-10-19T16:57:23.077Z" }, { url = "https://files.pythonhosted.org/packages/87/29/bc94be4a8bc19ef518e9efecd90a992f2991b7cfc6eb8a7430c4e2dc097c/websockets-17.4-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8f32a6cb0b101170abc2d8053af1473a2a29ef2a06d7130994ee8455a50f5dbf", size = 226992, upload-time = "2026-10-30T15:09:49.776Z" },
{ url = "https://files.pythonhosted.org/packages/d3/e8/adfc05d7f9b6b0cce6cc554426c50b7058609119b6b84a39171fe5ed4e50/websockets-17.3-cp315-cp315t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:722ab747ff5dc28070b1a8c020dba6466f13f2ab56bc251ad301fefa2454aa1e", size = 226736, upload-time = "2026-10-19T16:57:24.419Z" }, { url = "https://files.pythonhosted.org/packages/fa/25/58f69b5fec064764ddf5b88045397433cdb028f5b17c82fbe047fd31fff7/websockets-17.4-cp315-cp315t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:72d0a731eccf482d5661c6711abfcd5921f3dbfbbb0789061e1918ef4bc89c70", size = 227258, upload-time = "2026-10-30T15:09:51.172Z" },
{ url = "https://files.pythonhosted.org/packages/96/b2/d5b93dd933552554507edae9e3a4890269a8ea8b46019450ea9c8a30e7cc/websockets-17.3-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0d20d6ea5aabbeba2e054d33e405192ef75dc12947bbd00ec97d23b14969d325", size = 228065, upload-time = "2026-10-19T16:57:25.908Z" }, { url = "https://files.pythonhosted.org/packages/21/7c/bc932a54c5f1bd78461574aea539fc562fd2de494738a2c0675c4de860be/websockets-17.4-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48c83103f82fce36191a9e3a8a80731d4340e2da544eae8c5aaaf6a45280e7d0", size = 228589, upload-time = "2026-10-30T15:09:52.628Z" },
{ url = "https://files.pythonhosted.org/packages/10/37/642f0f1cf1c4814147a4f0586f796a0b38e42b69e5a296bfc270fb660051/websockets-17.3-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:90cc9973c51811918247a78d9ab2f54871296e4e37e69a98aba4d3c9acedbf62", size = 229692, upload-time = "2026-10-19T16:57:27.63Z" }, { url = "https://files.pythonhosted.org/packages/c4/92/57ec83066d0bfa8a9b9f594bc61951073c4a86e34c562a5292604118dab6/websockets-17.4-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c8052c3058c402d53602583da5ddcf1a15a63ab1efe0222f47c7be59944e6463", size = 230217, upload-time = "2026-10-30T15:09:54.058Z" },
{ url = "https://files.pythonhosted.org/packages/5b/33/eac81cf62ac394b61db6a27ccbe8fb9ae382016ef73ede1edf9242a6a43e/websockets-17.3-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:63b87c93e28957e6dc2bb92d31727a863c9bf4a4daa080f0995fdcb0242c9166", size = 228503, upload-time = "2026-10-19T16:57:29.031Z" }, { url = "https://files.pythonhosted.org/packages/6e/fc/0f2fe533b5b4dc22ac704b3357f5c99fa60a3837218b3c6c6ad63a60120e/websockets-17.4-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0c22a9c6b2349204f56123dd0ca7779e989d68e336d0dc02ad823485d44925de", size = 229026, upload-time = "2026-10-30T15:09:55.727Z" },
{ url = "https://files.pythonhosted.org/packages/0d/84/e057788a9bd056f4f7d3a06b59efaaf724bbc6f2d1e1d316f8a93c951abc/websockets-17.3-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4f7431e71072962d3ed1768038200aa4f1e76d9c2d7bedd554d57a55faaf6644", size = 227379, upload-time = "2026-10-19T16:57:30.571Z" }, { url = "https://files.pythonhosted.org/packages/7c/cc/ed28307835106a9a2dbd1a5efc597e8befa3d0a5f2b46a4c1e466b2f4759/websockets-17.4-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0dcce83837328e6795225ead920d3aa8d867cc9d6922aa558b0e9111599fdbb9", size = 227904, upload-time = "2026-10-30T15:09:57.221Z" },
{ url = "https://files.pythonhosted.org/packages/fa/a7/cca1f9f45b893bb5b7545f2b234f910e07fcd2f8065724ac1a62a0961be2/websockets-17.3-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:534ed583fd4323de45d196535a444cebd1af2105e6e4e0cdcbf8cea266a8747a", size = 224719, upload-time = "2026-10-19T16:57:31.895Z" }, { url = "https://files.pythonhosted.org/packages/39/4d/24ad7deddc7b31c3e430acc1c00c7b384ae42f1f973e9cc01aa42d19628f/websockets-17.4-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4b79ea31cf277c72d2307c6cb8bd32a2c151959e80a2f24fa85df620c5f2860c", size = 225253, upload-time = "2026-10-30T15:09:58.624Z" },
{ url = "https://files.pythonhosted.org/packages/1f/a0/5d07451027150c802ce4c9999e86994a3f0de5f4dbe3d1cce46995999269/websockets-17.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:8879b6a4040ee925c638548238ab7fe612cf5fa8752fe847346da0da8be81c3f", size = 227564, upload-time = "2026-10-19T16:57:33.236Z" }, { url = "https://files.pythonhosted.org/packages/93/f6/9c92230ebfab42fee482b236c45c32ed34b24f7360080b400cce6cfcac11/websockets-17.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:3d27153a0413696767b98b5d96173a2a7e27cbfca02000a6069cea7b977765a7", size = 228089, upload-time = "2026-10-30T15:10:00.308Z" },
{ url = "https://files.pythonhosted.org/packages/62/90/835f37e13fac0c615a700caae0d39deff1de2d2b9b341836a786f9b359e0/websockets-17.3-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:58e5d1e935a90878a935c7d2737a25c71cf4f1c13b6e6bef454ab722c4f338bb", size = 226218, upload-time = "2026-10-19T16:57:34.876Z" }, { url = "https://files.pythonhosted.org/packages/fe/7a/f523830e562da9b1d7a610174752753720c5e47e84b59b5fc47721d416bd/websockets-17.4-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:e0feb03a338f8f5e819ae23a73aabb7b46fd3b73f15f7df782dd12d99e2b02ad", size = 226743, upload-time = "2026-10-30T15:10:01.744Z" },
{ url = "https://files.pythonhosted.org/packages/16/8a/718639939b947fe715501e8073350fd3f8a42f8b5eba3c0c2389c5836513/websockets-17.3-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:0a657b51e4a5f4ac577c11e706ff9a884ea5aaf3e2666bae283a251c63e66acb", size = 226712, upload-time = "2026-10-19T16:57:36.248Z" }, { url = "https://files.pythonhosted.org/packages/2c/e6/d1a56495cb7b1a0a4688145131be38cfd0c1342bde8125e136a6e6ee494e/websockets-17.4-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:9ff89e36451a19775e7556d95ebfa4a0b24b84cc87565d6b5d0ec8ea82bf3f2c", size = 227237, upload-time = "2026-10-30T15:10:03.417Z" },
{ url = "https://files.pythonhosted.org/packages/e8/45/6bef8572b9b0dee51369b69776af1e4c7cc3a807f199dc3c165bbcf1199a/websockets-17.3-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:411aa0fb234f42a1444dea1e177890ddfc577b36ffdfafec25cb0d2c818e1af8", size = 227819, upload-time = "2026-10-19T16:57:37.586Z" }, { url = "https://files.pythonhosted.org/packages/44/41/c4e53f7ad63838d7eba6e82c5d743827e91dafe5e48c88a58f7e90043945/websockets-17.4-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:ee6c4e0085bff5f6621bf9c53452584dcb91ebd993ce841e1f4061ac12b6b556", size = 228348, upload-time = "2026-10-30T15:10:05.149Z" },
{ url = "https://files.pythonhosted.org/packages/f1/3f/3b726d0b46a132e641d2d8c0243ea8a7d39464dbaf28c7a206e51fbae681/websockets-17.3-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:623d4b44f64efdf235b1e8dce266057073d93cde6197e7d7684f20c297f67f30", size = 225573, upload-time = "2026-10-19T16:57:39.285Z" }, { url = "https://files.pythonhosted.org/packages/ca/01/f6f78c3edeb8ad2b3c35a8c4117ec2cf9346215afaed8155af1e7933818b/websockets-17.4-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:1962f3bfde085af18878f1bb1aafb300c9a6ac33da886d0b0d97e33c9ce7a222", size = 226099, upload-time = "2026-10-30T15:10:06.588Z" },
{ url = "https://files.pythonhosted.org/packages/90/96/cd972a12e1412d9b42c42ef42efbd81aa396ca9408d982d524012f011a8d/websockets-17.3-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:5d0c871efc696ef5eb8514df64b86ab28736db753ebb42a2101f3b562f4849c0", size = 226430, upload-time = "2026-10-19T16:57:40.915Z" }, { url = "https://files.pythonhosted.org/packages/8d/01/d8c11740231b63d1aba6bad6b1d4e72e8384b26c5448801da238ebe82c92/websockets-17.4-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:42d7a6e818214336cf7af9a3aae5c6923f94f09fa07ac5aab98268ba2a98a6d5", size = 226956, upload-time = "2026-10-30T15:10:08.141Z" },
{ url = "https://files.pythonhosted.org/packages/7d/2d/e11524771588188a39f6da234779d4942496b96c5a10b92f3df30db48608/websockets-17.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:1db2604e6aedb7a2cb2f6e0575d6c9a852ccac8239703a930ac83b63309177a5", size = 226728, upload-time = "2026-10-19T16:57:42.245Z" }, { url = "https://files.pythonhosted.org/packages/99/87/dbfd43ae01ea8fdb7478a1b3d58b32aca83bb9c4aa61ebe187d1e188ac8e/websockets-17.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:c9f8fdb7bbefc4907c2bb3593f810f2b67a2d5329e1d38724a1aa3d763cf63eb", size = 227253, upload-time = "2026-10-30T15:10:09.533Z" },
{ url = "https://files.pythonhosted.org/packages/17/9b/63722aed973f53ea579e1ce0c4563a2b8b536629e556feff26e4c6a2a60d/websockets-17.3-cp315-cp315t-win32.whl", hash = "sha256:07246d4f0293ef0b87eff3d7dd88c1f03dd9f678fa39f6de87b2af8504e7e0de", size = 218325, upload-time = "2026-10-19T16:57:43.585Z" }, { url = "https://files.pythonhosted.org/packages/bf/8e/fbd968a75428063560ada02d564de8d6c49ca34ec29dfb78e5d900f4b0ef/websockets-17.4-cp315-cp315t-win32.whl", hash = "sha256:506623128d7a55b726a5a46e4c9a8eae4c9dd0f04ff60b041fe7df85b9d034fe", size = 218877, upload-time = "2026-10-30T15:10:10.925Z" },
{ url = "https://files.pythonhosted.org/packages/34/07/5e0c92e225546f1789c957c0383852797be7c899b07207e9b6eb4a93473f/websockets-17.3-cp315-cp315t-win_amd64.whl", hash = "sha256:856bbb24f2ee6436af7609267e73309e4bb0a0f9982f79f1c2e01019279c6671", size = 218641, upload-time = "2026-10-19T16:57:44.918Z" }, { url = "https://files.pythonhosted.org/packages/f9/53/8f37a3943517bba77fc86f7c4162c455018c5b6d760c5e9c9a11de9e37f7/websockets-17.4-cp315-cp315t-win_amd64.whl", hash = "sha256:6e6558efbb256b532c9f06a21ba42bcac729718e365c505819f181f25e5c0eca", size = 219195, upload-time = "2026-10-30T15:10:12.336Z" },
{ url = "https://files.pythonhosted.org/packages/d4/e4/339663de65c44ab3eb5209890e86eed26e1a131d6afab3df6f1c3e5b4d8c/websockets-17.3-cp315-cp315t-win_arm64.whl", hash = "sha256:a22c4956a4d858200dba83f0691bb692d77b0eeddfca80157820d20b85dea4f1", size = 218564, upload-time = "2026-10-19T16:57:46.332Z" }, { url = "https://files.pythonhosted.org/packages/69/35/cf33cba7248fcd80cfe5911ffe67aa2637f50c2bfaada123b1e9d2aeca90/websockets-17.4-cp315-cp315t-win_arm64.whl", hash = "sha256:fcac44d917672ad6257ad64b80b5168306f168a78cab5c0612e4c699caed18cf", size = 219117, upload-time = "2026-10-30T15:10:13.771Z" },
{ url = "https://files.pythonhosted.org/packages/d7/b0/3f01fcb3406662f6deb1e3e72b34dd0979e993991c0b7435a9e12bc14f02/websockets-17.3-py3-none-any.whl", hash = "sha256:dc2b495a92b3971966bf102e73589f950db3ec1ca6713265355ce7771d97b199", size = 212375, upload-time = "2026-10-19T16:58:04.483Z" }, { url = "https://files.pythonhosted.org/packages/eb/27/9db924cb0dbf22eade78d98fbae77790f9bf6bc729e6ab29aef9d68e4156/websockets-17.4-py3-none-any.whl", hash = "sha256:c6bd2c9781915592b209160d0f017d4c7c82ad53075e6b0c24d2a89299e7f9e9", size = 212900, upload-time = "2026-10-30T15:10:34.482Z" },
] ]