phase: 10_dev_direct_dial
**Phase 10 final pass — repo green; live criteria blocked off-repo.**
- Verified: `gateway` runs `dashboards-agent` (active/enabled, `10.1.1.27:9120` → `/health` 200, `/snapshot` 401 no-token / 200 bearer on-host, token matches `/etc/dashboards-agent.token`); `driveripper`/`deskwork` inactive, `sudo -n` → "a password is required", `:9120` empty.
- New measurement (transient runtime rule on gateway `firewall-cmd --add-port=9120/tcp`, **reverted** — runtime+permanent port lists verified back to the original set, no rich rules): laptop still gets no SYN through, while `:22`/`:80` on the same address answer in ms ⇒ the block is the lab gateway `10.2.0.1` port filter, not this app/host firewall/agent. No other live change made (no sudoers, no upsd, no UPS command).
- Tests/lint (exact): `bash .agents/validate.sh` → **EXIT 0** — `uv run pytest --cov --cov-fail-under=94` **793 passed, 11 skipped, coverage 98.33 %**; `ruff check src tests agent` clean; `pyright` 0 errors; `node --check` clean; hygiene clean; `uv pip list --outdated` empty (bumped fastapi 0.143.3→0.143.5, `uv.lock` only).
- `uv run pytest -m live --live` (`.env.dev` sourced): **4 passed, 1 skipped, 6 failed** — all six are `:9120`/`:3051` connect timeouts; zero parse/contract failures.
- Dev server restarted on current code: engines `ok`; 3×`host` + 3×`disks` + `nut` `DOWN` with real reasons (designed degradation, stub rows intact, no credential leakage).
- Completion criteria: screenshots ✓ (`data/logs/phase10-{index,system,disks,llm,ups}-live.png`); tunnel-vocabulary grep ✓ outside `.agents/` history (PLAN.md locked line, phase overviews, past reports; fixtures/`uv.lock` hits are digit substrings — `tests/test_deploy_scripts.py` guards operational files); no page-payload change ✓ (contract tests). **Not met, operator-side:** agents on all three hosts, `LIST UPS` from the laptop, `dev-server.sh status` all-ok, live suite green.
- Fixed in this pass: `sources/nut.py` unreachable-upsd message no longer prints a bare `(TimeoutError: )` (now `(timed out after 4s)` / `(ConnectionRefusedError: …)`), pinned by a new test + extended refused-port test.
- Docs: README verified consistent (every env var in `config.py` documented, routes/flags/`--with-llm`/pager sizes checked) — no changes needed. AGENTS.md fixed 4 inconsistencies: per-host sudo reality (gateway grants `NOPASSWD ALL`, which is why it installed without `agent-sudoers`; driveripper scoped; deskwork unrelated), the now-proven firewall finding, `/system` table typo ("agent per host"), gap-4 DB/WAL sizes + test count. `.env.example` `upsd.users` sample now one `instcmds` per line (it contradicted README and the in-app copy). `.env.dev` (gitignored) comment corrected to match the evidence.
- Flagged, untouched (locked): `.agents/PLAN.md` L92–95 still says upsd is "forwarded to `127.0.0.1:3494` in dev" — false after this phase; evidence + exact operator commands appended to `data/logs/NOTES-verify.md` §11.
- Next pending phase: `11_admin_login`.
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
**Phase 10 final pass — repo green; live criteria blocked off-repo.**
|
||||
|
||||
- Verified: `gateway` runs `dashboards-agent` (active/enabled, `10.1.1.27:9120` → `/health` 200, `/snapshot` 401 no-token / 200 bearer on-host, token matches `/etc/dashboards-agent.token`); `driveripper`/`deskwork` inactive, `sudo -n` → "a password is required", `:9120` empty.
|
||||
- New measurement (transient runtime rule on gateway `firewall-cmd --add-port=9120/tcp`, **reverted** — runtime+permanent port lists verified back to the original set, no rich rules): laptop still gets no SYN through, while `:22`/`:80` on the same address answer in ms ⇒ the block is the lab gateway `10.2.0.1` port filter, not this app/host firewall/agent. No other live change made (no sudoers, no upsd, no UPS command).
|
||||
- Tests/lint (exact): `bash .agents/validate.sh` → **EXIT 0** — `uv run pytest --cov --cov-fail-under=94` **793 passed, 11 skipped, coverage 98.33 %**; `ruff check src tests agent` clean; `pyright` 0 errors; `node --check` clean; hygiene clean; `uv pip list --outdated` empty (bumped fastapi 0.143.3→0.143.5, `uv.lock` only).
|
||||
- `uv run pytest -m live --live` (`.env.dev` sourced): **4 passed, 1 skipped, 6 failed** — all six are `:9120`/`:3051` connect timeouts; zero parse/contract failures.
|
||||
- Dev server restarted on current code: engines `ok`; 3×`host` + 3×`disks` + `nut` `DOWN` with real reasons (designed degradation, stub rows intact, no credential leakage).
|
||||
- Completion criteria: screenshots ✓ (`data/logs/phase10-{index,system,disks,llm,ups}-live.png`); tunnel-vocabulary grep ✓ outside `.agents/` history (PLAN.md locked line, phase overviews, past reports; fixtures/`uv.lock` hits are digit substrings — `tests/test_deploy_scripts.py` guards operational files); no page-payload change ✓ (contract tests). **Not met, operator-side:** agents on all three hosts, `LIST UPS` from the laptop, `dev-server.sh status` all-ok, live suite green.
|
||||
- Fixed in this pass: `sources/nut.py` unreachable-upsd message no longer prints a bare `(TimeoutError: )` (now `(timed out after 4s)` / `(ConnectionRefusedError: …)`), pinned by a new test + extended refused-port test.
|
||||
- Docs: README verified consistent (every env var in `config.py` documented, routes/flags/`--with-llm`/pager sizes checked) — no changes needed. AGENTS.md fixed 4 inconsistencies: per-host sudo reality (gateway grants `NOPASSWD ALL`, which is why it installed without `agent-sudoers`; driveripper scoped; deskwork unrelated), the now-proven firewall finding, `/system` table typo ("agent per host"), gap-4 DB/WAL sizes + test count. `.env.example` `upsd.users` sample now one `instcmds` per line (it contradicted README and the in-app copy). `.env.dev` (gitignored) comment corrected to match the evidence.
|
||||
- Flagged, untouched (locked): `.agents/PLAN.md` L92–95 still says upsd is "forwarded to `127.0.0.1:3494` in dev" — false after this phase; evidence + exact operator commands appended to `data/logs/NOTES-verify.md` §11.
|
||||
- Next pending phase: `11_admin_login`.
|
||||
@@ -0,0 +1 @@
|
||||
ok
|
||||
@@ -0,0 +1,52 @@
|
||||
== uv run pytest --cov (hermetic: no network, live tests skip) ==
|
||||
........................................................................ [ 8%]
|
||||
........................................................................ [ 17%]
|
||||
........................................................................ [ 26%]
|
||||
........................................................................ [ 35%]
|
||||
........................................................................ [ 44%]
|
||||
........................................................................ [ 53%]
|
||||
..................................sssssssssss........................... [ 62%]
|
||||
........................................................................ [ 71%]
|
||||
........................................................................ [ 80%]
|
||||
........................................................................ [ 89%]
|
||||
........................................................................ [ 98%]
|
||||
............ [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.4-final-0 ________________
|
||||
|
||||
Name Stmts Miss Cover Missing
|
||||
------------------------------------------------------------------
|
||||
src/dashboards/__init__.py 3 0 100%
|
||||
src/dashboards/__main__.py 14 1 93% 28
|
||||
src/dashboards/alerts.py 176 0 100%
|
||||
src/dashboards/api.py 221 0 100%
|
||||
src/dashboards/app.py 101 0 100%
|
||||
src/dashboards/collectors.py 155 5 97% 141-142, 146-147, 164
|
||||
src/dashboards/config.py 115 0 100%
|
||||
src/dashboards/debug.py 14 0 100%
|
||||
src/dashboards/poller.py 165 3 98% 192-194, 279
|
||||
src/dashboards/prom.py 105 5 95% 107, 144, 158, 161, 164
|
||||
src/dashboards/rates.py 37 1 97% 51
|
||||
src/dashboards/severity.py 49 0 100%
|
||||
src/dashboards/sources/__init__.py 0 0 100%
|
||||
src/dashboards/sources/disks.py 130 0 100%
|
||||
src/dashboards/sources/host.py 199 1 99% 147
|
||||
src/dashboards/sources/llm.py 150 1 99% 228
|
||||
src/dashboards/sources/nut.py 390 20 95% 137, 151, 157, 169, 175, 187, 195, 208, 279-280, 282, 498, 510, 545, 578, 582, 639-640, 645, 657
|
||||
src/dashboards/sources/nut_sim.py 134 0 100%
|
||||
src/dashboards/store.py 178 2 99% 250-251
|
||||
------------------------------------------------------------------
|
||||
TOTAL 2336 39 98%
|
||||
Required test coverage of 94% reached. Total coverage: 98.33%
|
||||
793 passed, 11 skipped, 1 warning in 59.94s
|
||||
== 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 ==
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
**Implemented**
|
||||
- `deploy/agent-sudoers` (new, nut-sudoers house style): WHY block, canonical staging path `/home/ops/agent-install`, the single rule `ops ALL=(root) NOPASSWD: /home/ops/agent-install/deploy/install-agent.sh`, two-stage check-first install (`visudo -cf` → `install -m 0440 -o root -g root /etc/sudoers.d/dashboards-agent` → re-check), a "make the grant mean something" `chown -R root:root` note, and a "deliberately NOT granted" block (visudo/sh/tee, other `systemctl`, the token file, user*/groupmod, `upscmd`/`upsrw`).
|
||||
- `deploy/install-agent.sh`: added resolved `SCRIPT_PATH` (realpath + fallback) and `SUDOERS_SOURCE`; `need_root`'s non-root path now prints the exact NOPASSWD line **for its own resolved path**, the two install steps, the pointer to `deploy/agent-sudoers`, and a copy-pasteable re-run command; exit code 1 and root behaviour unchanged (call sites now pass flags so the hint is absolute). Header help names the grant; dropped the stale "ssh tunnel in" parenthetical and marked `--status` as not needing root.
|
||||
- `tests/test_deploy_scripts.py` (new, 16 tests): grant file is exactly one rule at the canonical path, sections/boundaries present, `visudo -cf` passes; installer exits 1 with empty stdout, prints a line identical to the file apart from the path (relative and absolute invocations), names the two-stage install, `--help` points at the file; `bash -n` over `deploy/*.sh` + `scripts/*.sh`.
|
||||
- `data/logs/NOTES-sudoers.md` (+ `phase10-*` artifacts, gitignored): the real diff — identical apart from the path (path-normalised diff is empty).
|
||||
|
||||
**Verification**
|
||||
- `visudo -cf deploy/agent-sudoers` → parsed OK; `bash -n deploy/install-agent.sh` → clean.
|
||||
- `bash deploy/install-agent.sh --bind 127.0.0.1` (as uid 1000) → exit 1, prints `ops ALL=(root) NOPASSWD: /var/home/.../deploy/install-agent.sh`.
|
||||
- `bash .agents/validate.sh` → rc 0: `785 passed, 11 skipped`, coverage **98.33 %** (gate 94 %), ruff clean, pyright 0 errors, `node --check` clean, hygiene clean.
|
||||
|
||||
**Docs**: no doc impact — README.md/AGENTS.md deliberately untouched (task 05 owns the dev-workflow rewrite). Noted in the NOTES file that AGENTS.md's "769 passed" is now 785 so the final pass refreshes it.
|
||||
|
||||
**Decisions/deviations**: grant names `ops` literally (only the path is substituted, so the printed line matches the canonical one); `--token` documented as the one flag never used (installer mints + prints once); added an honest note that the grant is only a boundary once the staged copy is root-owned. **No live-infrastructure changes** — the sudoers line needs root and is the phase's stated user precondition; nothing was touched on the fleet.
|
||||
|
||||
**Next pending task**: `10_dev_direct_dial/02_install_agents_on_fleet.md` (blocked on the user adding the `deploy/agent-sudoers` line as root on each host; task 02 must stop cleanly with instructions if it is missing).
|
||||
+1
@@ -0,0 +1 @@
|
||||
ok
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
== uv run pytest --cov (hermetic: no network, live tests skip) ==
|
||||
........................................................................ [ 9%]
|
||||
........................................................................ [ 18%]
|
||||
........................................................................ [ 27%]
|
||||
........................................................................ [ 36%]
|
||||
........................................................................ [ 45%]
|
||||
........................................................................ [ 54%]
|
||||
...........................sssssssssss.................................. [ 63%]
|
||||
........................................................................ [ 72%]
|
||||
........................................................................ [ 81%]
|
||||
........................................................................ [ 90%]
|
||||
........................................................................ [ 99%]
|
||||
.... [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.4-final-0 ________________
|
||||
|
||||
Name Stmts Miss Cover Missing
|
||||
------------------------------------------------------------------
|
||||
src/dashboards/__init__.py 3 0 100%
|
||||
src/dashboards/__main__.py 14 1 93% 28
|
||||
src/dashboards/alerts.py 176 0 100%
|
||||
src/dashboards/api.py 221 0 100%
|
||||
src/dashboards/app.py 101 0 100%
|
||||
src/dashboards/collectors.py 155 5 97% 141-142, 146-147, 164
|
||||
src/dashboards/config.py 115 0 100%
|
||||
src/dashboards/debug.py 14 0 100%
|
||||
src/dashboards/poller.py 165 3 98% 192-194, 279
|
||||
src/dashboards/prom.py 105 5 95% 107, 144, 158, 161, 164
|
||||
src/dashboards/rates.py 37 1 97% 51
|
||||
src/dashboards/severity.py 49 0 100%
|
||||
src/dashboards/sources/__init__.py 0 0 100%
|
||||
src/dashboards/sources/disks.py 130 0 100%
|
||||
src/dashboards/sources/host.py 199 1 99% 147
|
||||
src/dashboards/sources/llm.py 150 1 99% 228
|
||||
src/dashboards/sources/nut.py 389 20 95% 128, 142, 148, 160, 166, 178, 186, 199, 270-271, 273, 489, 501, 536, 569, 573, 630-631, 636, 648
|
||||
src/dashboards/sources/nut_sim.py 134 0 100%
|
||||
src/dashboards/store.py 178 2 99% 250-251
|
||||
------------------------------------------------------------------
|
||||
TOTAL 2335 39 98%
|
||||
Required test coverage of 94% reached. Total coverage: 98.33%
|
||||
785 passed, 11 skipped, 1 warning in 58.73s
|
||||
== 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 ==
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
**Implemented**
|
||||
- Tunnels stopped; ad-hoc agents gone on all three hosts (`:9120` empty everywhere); `deploy/`+`agent/` staged at the canonical `~/agent-install/` on all three (task file's `rsync deploy/ agent/` trailing slashes would have flattened the tree — used `rsync -a --delete deploy agent`).
|
||||
- **Fixed 2 defects that made `deploy/install-agent.sh` unable to install on any host**, found by the first live run: the install body was a bash function named `install`, so `install -d` recursed forever (1 700 repeats, nothing installed); and `DevicePolicy=strict` without `/dev/null` kills the unit at spawn (`status=208/STDIN`, systemd 257 — proven with two transient `systemd-run` probes). Plus a latent third: token file `0600 root:root` is unreadable by the service that opens the path itself → now `0640 root:dashboards-agent`.
|
||||
- **gateway: INSTALLED** — `dashboards-agent` active + enabled, bound `10.1.1.27:9120`, `/health` ok, `/snapshot` 401 without / 200 with bearer; deployed unit matches the repo template; token captured into `.env.dev` (0600, gitignored), not in NOTES.
|
||||
- **driveripper + deskwork: BLOCKED** — precondition unmet (`/etc/sudoers.d/dashboards-agent` absent, `sudo -n … --status` → "a password is required"). Stopped cleanly; exact `visudo`/`install` lines + the one `firewall-cmd` line per host are in `data/logs/NOTES-agent-install.md`. **No sudoers or firewall edit was attempted.**
|
||||
- Laptop-side reach is blocked on **all three** hosts' firewalld (9120/tcp closed; control: gateway:80 answers 301) — recorded with both the bare-port and `/21`-scoped rich-rule commands, not guessed.
|
||||
|
||||
**Tests / lint / coverage**
|
||||
- `uv run pytest` → **792 passed, 11 skipped**; `uv run pytest --cov --cov-fail-under=94` → **98.33%**; `uv run ruff check src tests agent` → passed; `uv run pyright` → 0 errors; `node --check` all page scripts ok; `bash -n` all scripts ok.
|
||||
- New in `tests/test_deploy_scripts.py`: install body run end-to-end in a `tmp_path` sandbox (paths rewritten, root boundary stubbed) asserting it returns, lands the agent, writes `DeviceAllow=/dev/null`, prints the token once, leaves it `0640`; plus a static "no shell function named after a coreutil" check.
|
||||
|
||||
**Docs**: README (`0640 root:dashboards-agent`, device-policy rationale, `deploy/agent-sudoers` pointer); AGENTS.md (deployment paragraph, fleet SMART paragraph — gateway's installed agent now answers `smartctl_available: false` — and a transport mid-transition note pointing at NOTES; task 05 still owns the full direct-dial rewrite).
|
||||
|
||||
**Deviations / notes**
|
||||
- `uv run pytest -m live --live` currently fails 6 — all transport (dead loopback tunnel ports + stopped `3494` forward), the state the phase expects for blocked hosts; green after the grant + task 04/06. Dev server is up and reports those sources red with real reasons.
|
||||
- Next pending: **re-run task 02 for driveripper/deskwork after the user adds the sudoers grant**, then `.agents/phases/todo/10_dev_direct_dial/03_upsd_acl_for_laptop.md`.
|
||||
+1
@@ -0,0 +1 @@
|
||||
ok
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
== uv run pytest --cov (hermetic: no network, live tests skip) ==
|
||||
........................................................................ [ 8%]
|
||||
........................................................................ [ 17%]
|
||||
........................................................................ [ 26%]
|
||||
........................................................................ [ 35%]
|
||||
........................................................................ [ 44%]
|
||||
........................................................................ [ 53%]
|
||||
..................................sssssssssss........................... [ 62%]
|
||||
........................................................................ [ 71%]
|
||||
........................................................................ [ 80%]
|
||||
........................................................................ [ 89%]
|
||||
........................................................................ [ 98%]
|
||||
........... [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.4-final-0 ________________
|
||||
|
||||
Name Stmts Miss Cover Missing
|
||||
------------------------------------------------------------------
|
||||
src/dashboards/__init__.py 3 0 100%
|
||||
src/dashboards/__main__.py 14 1 93% 28
|
||||
src/dashboards/alerts.py 176 0 100%
|
||||
src/dashboards/api.py 221 0 100%
|
||||
src/dashboards/app.py 101 0 100%
|
||||
src/dashboards/collectors.py 155 5 97% 141-142, 146-147, 164
|
||||
src/dashboards/config.py 115 0 100%
|
||||
src/dashboards/debug.py 14 0 100%
|
||||
src/dashboards/poller.py 165 3 98% 192-194, 279
|
||||
src/dashboards/prom.py 105 5 95% 107, 144, 158, 161, 164
|
||||
src/dashboards/rates.py 37 1 97% 51
|
||||
src/dashboards/severity.py 49 0 100%
|
||||
src/dashboards/sources/__init__.py 0 0 100%
|
||||
src/dashboards/sources/disks.py 130 0 100%
|
||||
src/dashboards/sources/host.py 199 1 99% 147
|
||||
src/dashboards/sources/llm.py 150 1 99% 228
|
||||
src/dashboards/sources/nut.py 389 20 95% 128, 142, 148, 160, 166, 178, 186, 199, 270-271, 273, 489, 501, 536, 569, 573, 630-631, 636, 648
|
||||
src/dashboards/sources/nut_sim.py 134 0 100%
|
||||
src/dashboards/store.py 178 2 99% 250-251
|
||||
------------------------------------------------------------------
|
||||
TOTAL 2335 39 98%
|
||||
Required test coverage of 94% reached. Total coverage: 98.33%
|
||||
792 passed, 11 skipped, 1 warning in 59.37s
|
||||
== 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 @@
|
||||
**Task 03 outcome: repo/records side complete and green; the prescribed live write was proven a no-op and the laptop leg is blocked off-host (details below).**
|
||||
|
||||
- Read-first done: `data/logs/upsd.conf.before` (369 B, `LISTEN 0.0.0.0/:: 3051`, no ACL lines); live file re-read + `diff` → **identical**; `upsd.users`/all `/etc/ups` mtimes unchanged; `nut-server` still `active`, `NRestarts=0`, `upsc … ups.status` = `OL` on both UPSes.
|
||||
- Ranges derived: hosts `10.1.0.0/21` (`bridge0 10.1.0.126/21`), laptop `10.2.1.113` in `10.2.0.0/22` (ssh source + `ip -o addr`); v6 `…:101::/64` vs `…:102::/64`.
|
||||
- **Did not append the two `ACL` lines — they enforce nothing on this upstream.** `nut-2.8.5-9.fc43` is built `--without-wrap`; `man 5 upsd.conf` has no ACL section; sandboxed `NUT_CONFPATH` runs (live service untouched) show upsd logging `ACL in upsd.conf is no longer supported - switch to LISTEN` per line and then starting anyway → a config that claims restriction, logs an error against itself on every restart, and changes no packet. On 2.8 the mechanism is `LISTEN` + firewall, both already correct here.
|
||||
- Firewall (task step 5): `firewalld` running, `FedoraServer` on `bridge0/enp3s0`, **`3051/tcp` already allowed** (runtime + permanent), no rich/direct rules → nothing to add; the optional `--remove-port` + 2 rich-rule scoping is recorded verbatim but deliberately not applied (no reachable behaviour changes, deny path unverifiable from here, prod + `upsmon` dial loopback).
|
||||
- **BLOCKER (operator, off-fleet):** from the laptop, `nc 10.1.0.126 3051` (v4 and v6) times out and **no SYN ever arrives** — proven by a 0.5 s `ss -tan sport = :3051` loop during 4 attempts (only pre-existing `[::1]` sessions) beside control probes where the same laptop's payload to `:1234` landed in an `ncat` log and `:8004` RST in 10 ms. The drop is the lab gateway `10.2.0.1` (`d8:b3:70:85:53:e6`, UniFi) between `10.2.0.0/22` and `10.1.0.0/21`: it forwards 22/1234/8000-8004, drops 1235/3051/3493/5901. No `ops` sudo on a fleet host touches it.
|
||||
- Objective half already true: from `deskwork` (host range) `printf 'LIST UPS\n' | nc 10.1.0.126 3051` → `BEGIN LIST UPS … END LIST UPS`.
|
||||
- All evidence, the operator's exact one rule (UniFi LAN→LAN allow 3051/tcp from `10.2.0.0/22` to `10.1.0.126`), the three interim options, and the two premise corrections are in `data/logs/NOTES-upsd-acl.md`. No Python changes, so no new tests were applicable.
|
||||
- Gate: `bash .agents/validate.sh` → **exit 0** (`uv run pytest --cov --cov-fail-under=94` → **792 passed, 11 skipped, 98.33%**; `ruff check src tests agent` passed; `pyright` 0 errors; `node --check` ok; hygiene gates ok); `uv pip list --outdated` empty; dev server still up (sources red with real reasons — task 02's in-between state, untouched).
|
||||
- Docs: **AGENTS.md** (NUT paragraph: 2.8 has no `ACL`, restriction is firewall-only, non-local reads are the norm, host range reaches 3051 / laptop range does not; transport bullet now points at `NOTES-upsd-acl.md`) and **README.md** (Battery-test section: `ACL`/`ACCEPT`/`REJECT` removed upstream, don't scope by client in `upsd.conf`).
|
||||
- Completion criteria met: no `upsd.users` write, nut-server healthy/unrestarted, suite green, docs accurate. **Not met, and not achievable from this repo:** "diff shows exactly the two ACL lines" (would enforce nothing) and "laptop `nc` answers `BEGIN LIST UPS`" (gateway port filter). Task 04 should keep `NUT_HOST` pending that operator rule; the derived ranges are ready in the NOTES file.
|
||||
- Next pending: operator rule on gateway `10.2.0.1` (+ task 02's still-missing sudoers grant on driveripper/deskwork), then `.agents/phases/todo/10_dev_direct_dial/04_env_dev_direct_dial.md`.
|
||||
+1
@@ -0,0 +1 @@
|
||||
ok
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
== uv run pytest --cov (hermetic: no network, live tests skip) ==
|
||||
........................................................................ [ 8%]
|
||||
........................................................................ [ 17%]
|
||||
........................................................................ [ 26%]
|
||||
........................................................................ [ 35%]
|
||||
........................................................................ [ 44%]
|
||||
........................................................................ [ 53%]
|
||||
..................................sssssssssss........................... [ 62%]
|
||||
........................................................................ [ 71%]
|
||||
........................................................................ [ 80%]
|
||||
........................................................................ [ 89%]
|
||||
........................................................................ [ 98%]
|
||||
........... [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.4-final-0 ________________
|
||||
|
||||
Name Stmts Miss Cover Missing
|
||||
------------------------------------------------------------------
|
||||
src/dashboards/__init__.py 3 0 100%
|
||||
src/dashboards/__main__.py 14 1 93% 28
|
||||
src/dashboards/alerts.py 176 0 100%
|
||||
src/dashboards/api.py 221 0 100%
|
||||
src/dashboards/app.py 101 0 100%
|
||||
src/dashboards/collectors.py 155 5 97% 141-142, 146-147, 164
|
||||
src/dashboards/config.py 115 0 100%
|
||||
src/dashboards/debug.py 14 0 100%
|
||||
src/dashboards/poller.py 165 3 98% 192-194, 279
|
||||
src/dashboards/prom.py 105 5 95% 107, 144, 158, 161, 164
|
||||
src/dashboards/rates.py 37 1 97% 51
|
||||
src/dashboards/severity.py 49 0 100%
|
||||
src/dashboards/sources/__init__.py 0 0 100%
|
||||
src/dashboards/sources/disks.py 130 0 100%
|
||||
src/dashboards/sources/host.py 199 1 99% 147
|
||||
src/dashboards/sources/llm.py 150 1 99% 228
|
||||
src/dashboards/sources/nut.py 389 20 95% 128, 142, 148, 160, 166, 178, 186, 199, 270-271, 273, 489, 501, 536, 569, 573, 630-631, 636, 648
|
||||
src/dashboards/sources/nut_sim.py 134 0 100%
|
||||
src/dashboards/store.py 178 2 99% 250-251
|
||||
------------------------------------------------------------------
|
||||
TOTAL 2335 39 98%
|
||||
Required test coverage of 94% reached. Total coverage: 98.33%
|
||||
792 passed, 11 skipped, 1 warning in 59.63s
|
||||
== 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,19 @@
|
||||
**Implemented**
|
||||
- `./scripts/dev-tunnels.sh stop` (no-op; already down), laptop has nothing on 9121/9122/9123/3494.
|
||||
- `.env.dev` rewritten direct-shaped, mode `0600` kept: `HOST_TARGETS="driveripper=http://10.1.0.126:9120 deskwork=http://10.1.0.51:9120 gateway=http://<token>@10.1.1.27:9120"`, `NUT_HOST=10.1.0.126`/`NUT_PORT=3051`, `DASH_PORT=8090` explicit, no `DEV_TUNNELS`/`DEV_FORWARDS`, comments naming where the upsd access rules actually live and why a laptop-IP change is the operator's gateway rule, not this file.
|
||||
- Verified the gateway credential against the live service without printing it (sha256 equal to `/etc/dashboards-agent.token`; agent answers 401 without bearer / 200 with it, host-side) and `NUT_PASSWORD` against `[dashboards]` in the live `upsd.users` (`instcmds = all` — corrected the file's stale "battery-test only" comment).
|
||||
- Restarted the dev server; `/api/config` redacts userinfo, `/api/system/snapshot` keeps all three hosts as `unreachable` stubs (`hosts_total 3 / hosts_live 0`), `/api/summary` alerts name the 4 dead sources, `/api/events` shows only the 7 switchover `unreachable` transitions. Token-hits = 0 across all 7 API surfaces.
|
||||
- Evidence + the 3 remaining operator actions: `data/logs/NOTES-env-dev-direct-dial.md` (+ correction appended to `NOTES-agent-install.md`).
|
||||
|
||||
**Tests / lint / coverage**
|
||||
- `bash .agents/validate.sh` → exit 0: `uv run pytest --cov --cov-fail-under=94` **792 passed, 11 skipped, coverage 98.33%**; `ruff check src tests agent` clean; `pyright` 0 errors; `node --check` clean. `uv pip list --outdated` empty.
|
||||
- `uv run pytest -m live --live` (with `.env.dev` sourced): 11 live tests, **5 pass (both engines), 6 fail on transport only** — 3 × `ConnectTimeout` to the `:9120`s, 3 × `cannot reach upsd at 10.1.0.126:3051`. Task 06 owns making these green.
|
||||
|
||||
**Docs** — README + AGENTS updated only where this change falsified them: tunnel-based dev quickstart → direct dial (`install-agent.sh` + userinfo tokens + `NUT_PORT=3051`); removed the now-false "`dev-tunnels.sh start` … temporary way to see live data" fallback; `DEV_FORWARDS="3494:…"` fleet fact → direct dial; NUT "bind upsd" advice no longer points at a forward; hermetic count 769 → 792. `git grep dev-tunnels|DEV_TUNNELS|DEV_FORWARDS|9121|9122|9123|3494` now matches neither file. Task 05's file list (`.env.dev.example`, `scripts/dev-tunnels.sh`, `capture-fixtures.sh`, live-suite strings, `ups_contract.py`, `alerts.py` docstring, `test_config.py`, `.env.example`) left untouched.
|
||||
|
||||
**Decisions / deviations**
|
||||
- **No live changes to any host, firewall or service.** `driveripper`/`deskwork` stay BLOCKED (root-only sudoers grant missing — re-checked: `sudo -n … --status` still `a password is required`); gateway's `9120/tcp` stays closed. Opening it means a firewall edit on the lab router whose benefit is unverifiable from here (its policy is port-selective and drops 3051/3493 today, so 9120 may be dropped too), and tasks 02/03 both explicitly left firewall policy to the operator. No `NUT_SIMULATE`, no tunnel fallback — the reds are real, with the reason.
|
||||
- Kept `driveripper`/`deskwork` in `HOST_TARGETS` **without** tokens (none exist) so the fleet still shows them as unreachable stubs instead of losing machines.
|
||||
- Current live result, stated plainly: engines `ok`; every `system`/`disks`/`ups` source `DOWN` with a real reason — nothing that was healthy before the switchover regressed. `.env.dev.example` is still tunnel-shaped (task 05), so the README `cp` line currently points at a file awaiting that rewrite.
|
||||
|
||||
**Next pending task:** `.agents/phases/todo/10_dev_direct_dial/05_retire_tunnels_update_scripts.md`
|
||||
+1
@@ -0,0 +1 @@
|
||||
ok
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
== uv run pytest --cov (hermetic: no network, live tests skip) ==
|
||||
........................................................................ [ 8%]
|
||||
........................................................................ [ 17%]
|
||||
........................................................................ [ 26%]
|
||||
........................................................................ [ 35%]
|
||||
........................................................................ [ 44%]
|
||||
........................................................................ [ 53%]
|
||||
..................................sssssssssss........................... [ 62%]
|
||||
........................................................................ [ 71%]
|
||||
........................................................................ [ 80%]
|
||||
........................................................................ [ 89%]
|
||||
........................................................................ [ 98%]
|
||||
........... [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.4-final-0 ________________
|
||||
|
||||
Name Stmts Miss Cover Missing
|
||||
------------------------------------------------------------------
|
||||
src/dashboards/__init__.py 3 0 100%
|
||||
src/dashboards/__main__.py 14 1 93% 28
|
||||
src/dashboards/alerts.py 176 0 100%
|
||||
src/dashboards/api.py 221 0 100%
|
||||
src/dashboards/app.py 101 0 100%
|
||||
src/dashboards/collectors.py 155 5 97% 141-142, 146-147, 164
|
||||
src/dashboards/config.py 115 0 100%
|
||||
src/dashboards/debug.py 14 0 100%
|
||||
src/dashboards/poller.py 165 3 98% 192-194, 279
|
||||
src/dashboards/prom.py 105 5 95% 107, 144, 158, 161, 164
|
||||
src/dashboards/rates.py 37 1 97% 51
|
||||
src/dashboards/severity.py 49 0 100%
|
||||
src/dashboards/sources/__init__.py 0 0 100%
|
||||
src/dashboards/sources/disks.py 130 0 100%
|
||||
src/dashboards/sources/host.py 199 1 99% 147
|
||||
src/dashboards/sources/llm.py 150 1 99% 228
|
||||
src/dashboards/sources/nut.py 389 20 95% 128, 142, 148, 160, 166, 178, 186, 199, 270-271, 273, 489, 501, 536, 569, 573, 630-631, 636, 648
|
||||
src/dashboards/sources/nut_sim.py 134 0 100%
|
||||
src/dashboards/store.py 178 2 99% 250-251
|
||||
------------------------------------------------------------------
|
||||
TOTAL 2335 39 98%
|
||||
Required test coverage of 94% reached. Total coverage: 98.33%
|
||||
792 passed, 11 skipped, 1 warning in 54.19s
|
||||
== 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 ==
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
All green. Final report:
|
||||
|
||||
**Implemented**
|
||||
- Deleted `scripts/dev-tunnels.sh` (working-tree `D`; harness stages) + removed its generated `data/tunnels/` leftovers.
|
||||
- `scripts/capture-fixtures.sh`: sources `.env.dev` first, derives agents by parsing `HOST_TARGETS` (`name=url`, space/comma, userinfo token used as-is, `/snapshot` appended); skip note when unset; failure lines name the host, never the token; all tunnel hints gone.
|
||||
- `tests/test_live_fleet.py` docstring → direct-dial loop; skips now `HOST_TARGETS is unset` / `no host agent answered`; `ups_contract.py`, `alerts.py` docstring, `test_config.py`, `test_alerts.py`, `test_api.py`, `test_disks_source.py` strings moved to the `:9120` direct shape; stale `tunnel-check` gone from `dev-server.sh` header.
|
||||
- `.env.dev.example` rewritten direct-shaped (installer + `agent-sudoers` flow, userinfo tokens, `NUT_HOST`/`NUT_PORT=3051`, port-discovery + firewall notes, RFC 2606 hosts, never-commit warning); verified it parses through the app's own `parse_targets` (3 targets, headers set, redacted display URLs).
|
||||
- New guards in `tests/test_deploy_scripts.py`: script stays deleted + no tunnel name/port in any operational file (scripts/deploy/src/agent/web/tests `.py`/README/AGENTS/example envs; fixtures & `.agents/` history excluded by design).
|
||||
- **Deviation (rule 7 / task-03 findings):** `.env.example`'s "third option" is written as *bind LAN + firewall-scope the dashboard's source ranges*, not "ACL in `upsd.conf`" — NUT 2.8.5 has no `ACL` directive (logs "switch to LISTEN", enforces nothing); same truth README already carries.
|
||||
|
||||
**Verification** — `bash .agents/validate.sh` → **EXIT 0**: `uv run pytest --cov --cov-fail-under=94` **792 passed, 11 skipped, 98.33 %**; `ruff` clean; `pyright` 0 errors; `node --check` clean. `bash -n` clean on both edited scripts; `uv pip list --outdated` empty; dev server restarted (`/api/health` sane: engines ok, hosts `DOWN` with real reasons — unchanged task-04 state; no live infra touched).
|
||||
|
||||
**Docs** — AGENTS.md transport bullet rewritten ("dev is the prod topology, repo-wide", systemd-agent-via-grant + firewall-scoped upsd); README was already direct-shaped by task 04, verified consistent.
|
||||
|
||||
**Grep criterion** — matches remain only in `.agents/` history (incl. locked `PLAN.md:94` "3494" — flagged for the final pass, untouched; `todo/11_admin_login/00_phase.md` names the vars only to forbid restoring them), `tests/fixtures/` verbatim captures, and `uv.lock` hashes.
|
||||
|
||||
**Next pending task:** `.agents/phases/todo/10_dev_direct_dial/06_verify_direct_dial_live.md`.
|
||||
+1
@@ -0,0 +1 @@
|
||||
ok
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
== uv run pytest --cov (hermetic: no network, live tests skip) ==
|
||||
........................................................................ [ 8%]
|
||||
........................................................................ [ 17%]
|
||||
........................................................................ [ 26%]
|
||||
........................................................................ [ 35%]
|
||||
........................................................................ [ 44%]
|
||||
........................................................................ [ 53%]
|
||||
..................................sssssssssss........................... [ 62%]
|
||||
........................................................................ [ 71%]
|
||||
........................................................................ [ 80%]
|
||||
........................................................................ [ 89%]
|
||||
........................................................................ [ 98%]
|
||||
........... [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.4-final-0 ________________
|
||||
|
||||
Name Stmts Miss Cover Missing
|
||||
------------------------------------------------------------------
|
||||
src/dashboards/__init__.py 3 0 100%
|
||||
src/dashboards/__main__.py 14 1 93% 28
|
||||
src/dashboards/alerts.py 176 0 100%
|
||||
src/dashboards/api.py 221 0 100%
|
||||
src/dashboards/app.py 101 0 100%
|
||||
src/dashboards/collectors.py 155 5 97% 141-142, 146-147, 164
|
||||
src/dashboards/config.py 115 0 100%
|
||||
src/dashboards/debug.py 14 0 100%
|
||||
src/dashboards/poller.py 165 3 98% 192-194, 279
|
||||
src/dashboards/prom.py 105 5 95% 107, 144, 158, 161, 164
|
||||
src/dashboards/rates.py 37 1 97% 51
|
||||
src/dashboards/severity.py 49 0 100%
|
||||
src/dashboards/sources/__init__.py 0 0 100%
|
||||
src/dashboards/sources/disks.py 130 0 100%
|
||||
src/dashboards/sources/host.py 199 1 99% 147
|
||||
src/dashboards/sources/llm.py 150 1 99% 228
|
||||
src/dashboards/sources/nut.py 389 20 95% 128, 142, 148, 160, 166, 178, 186, 199, 270-271, 273, 489, 501, 536, 569, 573, 630-631, 636, 648
|
||||
src/dashboards/sources/nut_sim.py 134 0 100%
|
||||
src/dashboards/store.py 178 2 99% 250-251
|
||||
------------------------------------------------------------------
|
||||
TOTAL 2335 39 98%
|
||||
Required test coverage of 94% reached. Total coverage: 98.33%
|
||||
792 passed, 11 skipped, 1 warning in 59.97s
|
||||
== 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 ==
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
**Implemented (task 06 — verify direct-dial live)**
|
||||
- Restarted the dev server on direct dial and recorded `/api/health`: `llm:gufo`/`llm:vllm` **ok** with live data; 3 `host:*` + 3 `disks:*` + `nut` DOWN with their real reasons (identical to the task-02/03/04 blockers); 0 `store-error`/`database is locked` lines; credential sweep proves the gateway bearer token appears in **no** API response.
|
||||
- Proved no tunnel port exists: `ss -ltn | grep -E ':(9121|9122|9123|3494)\s'` → empty; `git grep` for the tunnel names/ports → nothing outside `.agents/` (only digit substrings in `uv.lock` hashes and one SMART raw counter).
|
||||
- Prod-side proof (read-only, no writes): gateway's installed unit `active`, `10.1.1.27:9120` `/health` 200 / `/snapshot` 401-no-token / 200-with-token; that live snapshot through the **production** parsers passes `assert_card_contract` and yields the documented `/disks` degradation (`vda`, `smart: null`, `smartctl_available: false`); upsd answers `LIST UPS` for both UPSes from loopback **and from deskwork**.
|
||||
- Browser verification (visible Chromium): `data/logs/phase10-{index,system,disks,llm,ups}-live.png` — `/llm` fully live (fleet 2/2, KV bars, slot grid, TTFT/TPOT, history charts); `/ups` honest "none reporting", **no SIMULATED badge**.
|
||||
- Wrote `data/logs/NOTES-verify.md` (health table, live-suite tally, probe evidence localising the block to the lab gateway's port allow-list, the 3 operator actions, and the **PLAN.md L92–95 staleness flag** — "forwarded to `127.0.0.1:3494` in dev" is now false; file untouched).
|
||||
|
||||
**Tests/lint/coverage** — `bash .agents/validate.sh` → **EXIT 0**: `uv run pytest --cov --cov-fail-under=94` → **792 passed, 11 skipped, 98.33 %** (gate 94); `ruff check src tests agent` clean; `pyright` **0 errors**; `node --check` clean; `uv pip list --outdated` empty.
|
||||
|
||||
**Deviation / stopped short (live-infra)** — `uv run pytest -m live --live` is **4 passed, 1 skipped, 6 failed**; all six failures are connect timeouts to `:9120`/`:3051`, none a parse or contract. Making them green needs root on driveripper/deskwork (sudoers grant), host `9120/tcp`, and a rule on the lab gateway `10.2.0.1` — none reachable by `ops` sudo, and the laptop-side probes show the gateway drops even host-allowed ports (e.g. 3478), so opening gateway's `9120` has unknown payoff for at most 2 of 7 red sources. I therefore made **no live change** (no firewall, service, config write or UPS command) and left the reversible one-line experiment + exact operator commands in §7–§8 of the notes. `test_every_host_agent_answers_and_parses` is deliberately not turned into a skip — it is the reachability signal this phase exists to produce.
|
||||
|
||||
**Docs** — `AGENTS.md`: fleet bullet now also points at `data/logs/NOTES-verify.md` as the direct-dial verification record (re-verified facts unchanged). No README change needed (already direct-shaped; grep-clean).
|
||||
|
||||
**Next pending task** — `.agents/phases/todo/11_admin_login/01_auth_primitives.md` (phase 10's tasks are all complete).
|
||||
+1
@@ -0,0 +1 @@
|
||||
ok
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
== uv run pytest --cov (hermetic: no network, live tests skip) ==
|
||||
........................................................................ [ 8%]
|
||||
........................................................................ [ 17%]
|
||||
........................................................................ [ 26%]
|
||||
........................................................................ [ 35%]
|
||||
........................................................................ [ 44%]
|
||||
........................................................................ [ 53%]
|
||||
..................................sssssssssss........................... [ 62%]
|
||||
........................................................................ [ 71%]
|
||||
........................................................................ [ 80%]
|
||||
........................................................................ [ 89%]
|
||||
........................................................................ [ 98%]
|
||||
........... [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.4-final-0 ________________
|
||||
|
||||
Name Stmts Miss Cover Missing
|
||||
------------------------------------------------------------------
|
||||
src/dashboards/__init__.py 3 0 100%
|
||||
src/dashboards/__main__.py 14 1 93% 28
|
||||
src/dashboards/alerts.py 176 0 100%
|
||||
src/dashboards/api.py 221 0 100%
|
||||
src/dashboards/app.py 101 0 100%
|
||||
src/dashboards/collectors.py 155 5 97% 141-142, 146-147, 164
|
||||
src/dashboards/config.py 115 0 100%
|
||||
src/dashboards/debug.py 14 0 100%
|
||||
src/dashboards/poller.py 165 3 98% 192-194, 279
|
||||
src/dashboards/prom.py 105 5 95% 107, 144, 158, 161, 164
|
||||
src/dashboards/rates.py 37 1 97% 51
|
||||
src/dashboards/severity.py 49 0 100%
|
||||
src/dashboards/sources/__init__.py 0 0 100%
|
||||
src/dashboards/sources/disks.py 130 0 100%
|
||||
src/dashboards/sources/host.py 199 1 99% 147
|
||||
src/dashboards/sources/llm.py 150 1 99% 228
|
||||
src/dashboards/sources/nut.py 389 20 95% 128, 142, 148, 160, 166, 178, 186, 199, 270-271, 273, 489, 501, 536, 569, 573, 630-631, 636, 648
|
||||
src/dashboards/sources/nut_sim.py 134 0 100%
|
||||
src/dashboards/store.py 178 2 99% 250-251
|
||||
------------------------------------------------------------------
|
||||
TOTAL 2335 39 98%
|
||||
Required test coverage of 94% reached. Total coverage: 98.33%
|
||||
792 passed, 11 skipped, 1 warning in 59.49s
|
||||
== 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 ==
|
||||
+36
-28
@@ -1,18 +1,23 @@
|
||||
# Local development overrides. Copy to .env.dev and edit the host names to yours.
|
||||
# Local development overrides. Copy to .env.dev and edit the addresses and tokens to yours.
|
||||
# This file is gitignored and holds the fleet's bearer tokens: NEVER commit it.
|
||||
# cp .env.dev.example .env.dev
|
||||
# ./scripts/dev-tunnels.sh start # ssh tunnels to each host's agent
|
||||
# set -a; . .env.dev; set +a # then run the dashboard
|
||||
# chmod 0600 .env.dev
|
||||
#
|
||||
# DEV_TUNNELS entries are <local_port>:<ssh_host>:<remote_agent_port>.
|
||||
# DEV_FORWARDS entries are <local_port>:<ssh_host>:<remote_port> for a service already
|
||||
# running there — upsd is the usual one, so the UPS page can read a real battery without
|
||||
# 3493 being reachable from anywhere but this laptop.
|
||||
# scripts/dev-tunnels.sh forwards each one to 127.0.0.1:<local_port> so the dashboard on
|
||||
# the laptop reads live fleet data with nothing listening on the hosts.
|
||||
export DEV_TUNNELS="9121:driveripper-ops:9120 9122:deskwork-ops:9120 9123:gateway-ops:9120"
|
||||
# The remote port is whatever upsd actually listens on — 3493 is NUT's stock default, and
|
||||
# this fleet's driveripper answers on 3051, so check before copying the number.
|
||||
export DEV_FORWARDS="3494:driveripper-ops:3051"
|
||||
# Dev dials the fleet exactly the way the deployed dashboard does — no tunnels, no
|
||||
# forwarded ports, nothing listening on localhost. Per monitored host, once:
|
||||
#
|
||||
# sudo deploy/install-agent.sh --bind <address the dashboard dials> --port 9120
|
||||
#
|
||||
# It installs agent/host_agent.py as the dashboards-agent systemd service, bound to the
|
||||
# address you dial, and prints its bearer token exactly once. No root shell on the host?
|
||||
# deploy/agent-sudoers is the one-line grant that lets the fleet's `ops` account run the
|
||||
# installer (and running the installer without root prints that exact line). Put each
|
||||
# printed token into HOST_TARGETS below as URL userinfo; config.parse_targets moves it
|
||||
# into an Authorization header, so Target.url, /api/config and every error message stay
|
||||
# credential-free.
|
||||
#
|
||||
# The host names below are placeholders (RFC 2606) — use your own.
|
||||
export HOST_TARGETS="driveripper=http://TOKEN@driveripper.example.net:9120 deskwork=http://TOKEN@deskwork.example.net:9120 gateway=http://TOKEN@gateway.example.net:9120"
|
||||
|
||||
# Physical-disk health (phase 09): scripts/capture-fixtures.sh records one
|
||||
# `smartctl -i -A -H` per whole disk per host into tests/fixtures/smartctl_<host>_<dev>.txt.
|
||||
@@ -27,26 +32,29 @@ export DEV_FORWARDS="3494:driveripper-ops:3051"
|
||||
# export DISKS_INTERVAL_SECONDS=60
|
||||
# export DISKS_TIMEOUT_SECONDS=30
|
||||
|
||||
# Do not assume upsd is on 3493 — nonstandard ports are common and a refused connection then
|
||||
# reads as "upsd is broken" while the service is fine. Ask the host what actually speaks NUT:
|
||||
# The engines are dialled directly on their hostnames, like the agents.
|
||||
export GUFO_BASE_URL=http://deskwork.example.net:8731
|
||||
export VLLM_BASE_URL=http://driveripper.example.net:8000
|
||||
|
||||
# The UPS source: the real upsd, dialled directly. Do not assume NUT's stock 3493 —
|
||||
# nonstandard ports are common and a refused connection then reads as "upsd is broken"
|
||||
# while the service is fine (this fleet's driveripper answers on 3051). Ask the host what
|
||||
# actually speaks NUT:
|
||||
# ss -ltn # what is listening
|
||||
# printf 'LIST UPS\n' | nc 127.0.0.1 PORT # the one that answers "BEGIN LIST UPS"
|
||||
# upsc -L localhost:PORT # names + descriptions of every UPS
|
||||
|
||||
# Point the dashboard at those tunnels.
|
||||
export HOST_TARGETS="driveripper=http://127.0.0.1:9121 deskwork=http://127.0.0.1:9122 gateway=http://127.0.0.1:9123"
|
||||
# The engines are reached directly on their hostnames (nothing tunnels them here). If the
|
||||
# laptop cannot route to them, add them to DEV_FORWARDS above
|
||||
# (e.g. `8731:deskwork-ops:8731 8000:driveripper-ops:8000`) and point these at 127.0.0.1.
|
||||
# The host names below are placeholders (RFC 2606) — use your own.
|
||||
export GUFO_BASE_URL=http://deskwork.example.net:8731
|
||||
export VLLM_BASE_URL=http://driveripper.example.net:8000
|
||||
# The UPS source: the forwarded upsd above. NUT_PORT is the LOCAL port (3494), not 3493.
|
||||
# Leave NUT_SIMULATE=1 only while upsd is unreachable — it labels every panel SIMULATED.
|
||||
export NUT_HOST=127.0.0.1
|
||||
export NUT_PORT=3494
|
||||
# Who may connect is a FIREWALL question, not an upsd.conf one: NUT 2.8 has no `ACL`
|
||||
# directive (it logs "switch to LISTEN" per line and enforces nothing), so the dashboard's
|
||||
# source ranges are allowed in the firewall that covers the LISTEN address (a firewalld
|
||||
# rule on the NUT host — not a line under /etc/ups). Reads are
|
||||
# unauthenticated in stock NUT; SET/INSTCMD still need the upsd.users account below.
|
||||
# A changed laptop IP can mean re-scoping that firewall rule — it is not a line in this
|
||||
# file.
|
||||
export NUT_HOST=driveripper.example.net
|
||||
export NUT_PORT=3051
|
||||
export NUT_USER=dashboards
|
||||
export NUT_PASSWORD=replace-me-on-the-host
|
||||
export NUT_ALLOW_ACTIONS=1
|
||||
# Leave NUT_SIMULATE=1 only while upsd is unreachable — it labels every panel SIMULATED.
|
||||
# export NUT_SIMULATE=1
|
||||
export DASH_PORT=8090
|
||||
|
||||
+12
-5
@@ -52,16 +52,23 @@ RATE_WINDOW_SECONDS=10
|
||||
# one looks exactly like a dead service:
|
||||
# ss -ltnH # what the NUT host is listening on
|
||||
# printf 'LIST UPS\n' | nc 127.0.0.1 PORT # NUT answers: BEGIN LIST UPS
|
||||
# Keep it off the public interface: bind it to loopback (`LISTEN 127.0.0.1` in upsd.conf) and
|
||||
# reach it with an ssh forward or a tailnet address, or bind a LAN address only if a firewall
|
||||
# rule limits it to this host.
|
||||
# Keep it off the public interface. Options, from most to least exposed:
|
||||
# - bind loopback only (`LISTEN 127.0.0.1` in upsd.conf) and reach it over a tailnet address;
|
||||
# - bind a LAN address and limit the dashboard's source range with a FIREWALL rule — this is
|
||||
# the default here (upsd listens on the LAN, firewalld scopes 3051/tcp to the lab ranges).
|
||||
# NUT 2.8 removed the `ACL`/`ACCEPT`/`REJECT` directives: on a build without TCP Wrappers
|
||||
# an upsd.conf ACL line logs "ACL in upsd.conf is no longer supported - switch to LISTEN"
|
||||
# once per line and changes no packet, so per-client restriction is never an upsd.conf job.
|
||||
#
|
||||
# Reads need no user in stock NUT. For the battery-test buttons add one, scoped as narrowly
|
||||
# as the buttons need — `instcmds` beats `actions = -`, which grants load.off and shutdown.*
|
||||
# too:
|
||||
# too. One command per line: `man 5 upsd.users` says to grant several commands "use multiple
|
||||
# instances of the instcmds field", and a list on one line is not that.
|
||||
# [dashboards]
|
||||
# password = "..."
|
||||
# instcmds = test.battery.start.quick test.battery.start.deep test.battery.stop
|
||||
# instcmds = test.battery.start.quick
|
||||
# instcmds = test.battery.start.deep
|
||||
# instcmds = test.battery.stop
|
||||
# --------------------------------------------------------------------------- #
|
||||
NUT_HOST=
|
||||
NUT_PORT=3493
|
||||
|
||||
@@ -25,7 +25,7 @@ file — plus an overview page that says which of the four you need:
|
||||
| Route | Question it answers | Source |
|
||||
| --------- | ------------------------------------------------------------------------------ | ------------------------------------------------- |
|
||||
| `/` | Is anything wrong, and where do I look first? | all four, in one request |
|
||||
| `/system` | What are my machines doing — CPU, memory, disk, GPU, network, and the history | `agent/host_agent.py`, our own stdlib agent/host |
|
||||
| `/system` | What are my machines doing — CPU, memory, disk, GPU, network, and the history | `agent/host_agent.py`, our own stdlib agent per host |
|
||||
| `/disks` | Are any of my physical disks failing, and which one — health, temp, hours, wear | the same agents, `smartctl -i -A -H` per whole drive (never a partition) |
|
||||
| `/llm` | Tokens/sec, active sessions, queue depth, KV pressure, latency percentiles | gufo `/metrics` + `/slots`, vLLM `/metrics` |
|
||||
| `/ups` | Battery health, runtime left, power events, battery-test buttons | NUT `upsd` (our own wire-protocol client; 3051 here, not 3493) |
|
||||
@@ -311,14 +311,55 @@ That scheme is the history API's key — treat it as a public interface.
|
||||
| `deskwork` | `deskwork-ops` | Strix Halo (Radeon 8060S iGPU), gufo `:8731` |
|
||||
| `gateway` | `gateway-ops` | router, no GPU |
|
||||
|
||||
- Agents run from `~/lab-agent/host_agent.py` on each host, forwarded to `127.0.0.1:9121/9122/9123`
|
||||
by `scripts/dev-tunnels.sh` (`DEV_TUNNELS`). Nothing listens on the hosts in dev.
|
||||
- **Transport is direct dial: dev is the prod topology, repo-wide (phase 10 task 05).** The
|
||||
designed shape is one `dashboards-agent` **systemd service per host**, installed by `ops`
|
||||
through the `deploy/agent-sudoers` grant with `deploy/install-agent.sh`, bound to
|
||||
the address the dashboard dials, its bearer token carried in the `HOST_TARGETS` URL
|
||||
userinfo; driveripper's upsd serves the LAN on 3051 with the host and laptop ranges allowed
|
||||
by the **firewall**, never by a `upsd.conf` line. The old ssh-tunnel script is deleted and
|
||||
neither `.env.dev` nor `.env.dev.example` declares a forwarded port — dev dials the same
|
||||
addresses the deployed dashboard does, and `scripts/capture-fixtures.sh` reads
|
||||
`HOST_TARGETS` from `.env.dev` instead of naming hosts of its own
|
||||
(`tests/test_deploy_scripts.py` fails the day any tunnel name or port returns). Current state: `gateway` runs its installed service, bound to the address
|
||||
`gateway.reeselink.com` resolves to, and answers `/health` + `/snapshot` **on the host**
|
||||
(re-verified 2026-10-18, and the token in `.env.dev` matches `/etc/dashboards-agent.token`),
|
||||
but the laptop cannot reach its `:9120` — and that is now proven to be nobody's firewall here:
|
||||
opening `9120/tcp` in that host's own firewalld (a runtime rule, added and reverted in the
|
||||
phase-10 final pass; runtime and permanent port lists are back to their original set) changed
|
||||
nothing, because the SYN never arrives — `ss -tan sport = :9120` on the host shows only the
|
||||
`LISTEN` — while the same laptop reaches `:22` and `:80` on the same address in milliseconds.
|
||||
The drop is the port-selective inter-VLAN filter on the lab gateway `10.2.0.1`, the same one
|
||||
that eats 3051/3493. On `driveripper` and `deskwork` the ad-hoc `~/lab-agent/host_agent.py` agents
|
||||
are gone (stopped with the old tunnel sessions, nothing replaced them, `:9120` is empty on
|
||||
both) because their `deploy/agent-sudoers` grant is not installed yet, so the installer
|
||||
cannot run there. Every `system`/`disks` source is therefore `DOWN` with its real reason
|
||||
and renders as an `unreachable` stub row — the designed degradation, recorded rather than
|
||||
hidden — which is also why 6 of the `live` tests fail on transport while both engines pass.
|
||||
The per-host record, the exact root commands and the one firewall line each host still
|
||||
needs are in `data/logs/NOTES-agent-install.md`, how far the NUT wire reaches from each
|
||||
network is in `data/logs/NOTES-upsd-acl.md`, and the end-to-end direct-dial verification
|
||||
(health table, no-tunnel-port proof, live-suite tally, screenshots, and the probes that put
|
||||
the block on the lab gateway's port allow-list rather than in this app) is in
|
||||
`data/logs/NOTES-verify.md`.
|
||||
- Engines are reached directly: gufo `deskwork.reeselink.com:8731`, vLLM `driveripper.reeselink.com:8000`.
|
||||
- NUT 2.8.5 on driveripper: **configs live in `/etc/ups/`, not `/etc/nut/`**, and **upsd is
|
||||
on port 3051, not 3493**. Nothing listens on 3493 at all, so a default client reports
|
||||
*Connection refused* and it looks like a dead service — `ss -ltnH` plus `printf 'LIST UPS\n'
|
||||
| nc 127.0.0.1 3051` (answers `BEGIN LIST UPS`) is how that was pinned down. Two UPSes are
|
||||
exported over USB and read live through `DEV_FORWARDS="3494:driveripper-ops:3051"`:
|
||||
| nc 127.0.0.1 3051` (answers `BEGIN LIST UPS`) is how that was pinned down.
|
||||
**NUT 2.8 has no `ACL` directive** (this Fedora build is `--without-wrap`; upsd logs
|
||||
`ACL in upsd.conf is no longer supported - switch to LISTEN` once per line and then ignores
|
||||
it), so per-source restriction is a **firewall** rule and never an `upsd.conf` line — and
|
||||
2.8 does not refuse non-local clients the way older docs suggest: `LISTEN 0.0.0.0 3051` +
|
||||
`LISTEN :: 3051` with `3051/tcp` in the `FedoraServer` zone answers any client routed to it
|
||||
(reads are unauthenticated; `SET`/`INSTCMD` still need an `upsd.users` account). The host
|
||||
range `10.1.0.0/21` does reach it today (verified from `deskwork`); the laptop's
|
||||
`10.2.0.0/22` does **not** — the lab gateway `10.2.0.1` forwards 22/1234/8000-8004 and
|
||||
silently drops 3051/3493 in both families, which no sudo on a fleet host can change, so the
|
||||
laptop cannot yet dial `NUT_PORT=3051` (probe evidence and the operator's one rule:
|
||||
`data/logs/NOTES-upsd-acl.md`).
|
||||
Two UPSes are exported over USB and dialled directly as `NUT_HOST` + `NUT_PORT=3051` (from the
|
||||
laptop that is still the blocked path above; from the host itself, and from the deployed
|
||||
dashboard, it reads live):
|
||||
`apc` = APC Back-UPS RS 1500MS, `cyberpower` = CyberPower PR1500LCDRT2U (that is
|
||||
`ups.description`, the operator's label — the driver's own `identity.model` answers
|
||||
`GX1500U`, so the description is what identifies the box).
|
||||
@@ -331,19 +372,28 @@ That scheme is the history API's key — treat it as a public interface.
|
||||
Both UPSes are recorded verbatim (`LIST UPS`/`LIST VAR`/`LIST CMD`, serials scrubbed) as
|
||||
`tests/fixtures/nut_<ups>.vars` by `./scripts/capture-fixtures.sh`, and the wire quotes a
|
||||
field only when it must — `VAR apc ups.status "OL"`, not three quoted fields.
|
||||
- Host access is via ssh keys as `ops`. Passwordless sudo exists but is command-scoped; the
|
||||
set we rely on is documented in `deploy/nut-sudoers` (additions are listed there for the
|
||||
work that is still blocked).
|
||||
- Host access is via ssh keys as `ops`, and **passwordless sudo is different on every host** —
|
||||
that is what decides how much of the fleet an install can finish unattended. `gateway` grants
|
||||
`(ALL) NOPASSWD: ALL`, which is why its agent installed in phase 10 with no
|
||||
`deploy/agent-sudoers` line present; `driveripper` grants a command-scoped set (`smartctl`,
|
||||
`upsc`, `udevadm`, the `/etc/ups` reads, and the `tee /etc/ups/upsd.users|upsd.conf` +
|
||||
`systemctl restart|reload nut-server` pair documented in `deploy/nut-sudoers`); `deskwork`
|
||||
grants an unrelated set (`btrfs scrub`, `snapper`, `setfacl /srv/*`, `osbuild-auto.sh`).
|
||||
Neither `driveripper` nor `deskwork` can run the installer or `visudo`, so those two installs
|
||||
— and the `firewall-cmd` line each still needs — stay with the operator.
|
||||
- **SMART is captured from real hardware; the live agents still cannot read it.** `smartctl`
|
||||
7.5 is installed on deskwork and driveripper. The `ops` account (which runs the dev agents)
|
||||
is in no `disk` group; driveripper grants it `(root) NOPASSWD: /usr/sbin/smartctl*` and
|
||||
deskwork grants nothing, so the agent's own unprivileged `smartctl -i -A -H` — it shells the
|
||||
binary and never calls sudo — answers *Permission denied* (exit 2) on every host and `smart`
|
||||
stays `null` end to end: the designed degradation, not a bug. The three deployed agents also
|
||||
predate the SMART pass, so the `disks` source today yields one row per physical disk (16 on
|
||||
driveripper, 1 on deskwork, `vda` on gateway) with `smart: null`, `smart_errors: []` and
|
||||
`smartctl_available: null` — "nobody told us either way", which is why that flag is
|
||||
three-way and not a boolean.
|
||||
stays `null` end to end: the designed degradation, not a bug. The agents deployed on
|
||||
driveripper and deskwork also predate the SMART pass, so the `disks` source today yields one row
|
||||
per physical disk (16 on driveripper, 1 on deskwork, `vda` on gateway) with `smart: null`,
|
||||
`smart_errors: []` and `smartctl_available: null` — "nobody told us either way", which is why
|
||||
that flag is three-way and not a boolean. `gateway`'s agent is the one exception as of phase 10:
|
||||
it is the installed systemd service, which does know about the SMART pass and answers
|
||||
`smartctl_available: false` there because smartmontools is not installed — the *install it*
|
||||
half of the three-way flag, from a host the dashboard cannot yet reach (its firewall).
|
||||
**Real data is in the repo now:** `tests/fixtures/smartctl_driveripper_<dev>.txt` is all 16
|
||||
drives (8 NVMe, 8 spinning) read through that sudo rule, and `tests/test_agent.py` parses
|
||||
every one — which is what found the parser's one real bug: this binary annotates the
|
||||
@@ -376,14 +426,14 @@ That scheme is the history API's key — treat it as a public interface.
|
||||
```bash
|
||||
uv sync --python 3.14 # deps + editable install
|
||||
cp .env.example .env # production-shaped config
|
||||
cp .env.dev.example .env.dev # dev-only: ssh tunnels + live targets
|
||||
cp .env.dev.example .env.dev # dev-only: the real hosts, tokens in URL userinfo
|
||||
|
||||
./scripts/dev-tunnels.sh start # agents (9121-9123) + DEV_FORWARDS (upsd 3494)
|
||||
set -a; . ./.env.dev; set +a # dev dials the fleet the prod way, no tunnels
|
||||
./scripts/dev-server.sh restart # keep http://127.0.0.1:8090 up
|
||||
./scripts/dev-server.sh status # per-source ok/DOWN
|
||||
./scripts/dev-server.sh logs
|
||||
|
||||
uv run pytest # hermetic: 769 passed + 11 live-skipped, no network
|
||||
uv run pytest # hermetic: 793 passed + 11 live-skipped, no network
|
||||
uv run pytest --cov # coverage gate (98.33%; floor 94%)
|
||||
uv run pytest -m live --live # opt-in: real fleet / engines / upsd
|
||||
uv run ruff check src tests agent && uv run pyright
|
||||
@@ -402,8 +452,13 @@ bash .agents/validate.sh # the phase gate: the four checks abov
|
||||
Deployment: `Containerfile` (two-stage, `uv sync --frozen`, runs as uid 10001 on `0.0.0.0:8090`,
|
||||
SQLite under `/var/lib/dashboards` — put a volume there) and `deploy/install-agent.sh`
|
||||
per monitored host (systemd unit, own user, `--bind`/`--port`/`--token`/`--rotate-token`/
|
||||
`--status`/`--uninstall`; it stores the token in a root-owned 0600 file and hands the agent
|
||||
`--token-file`, never argv, and runs under `ProtectSystem=strict`).
|
||||
`--status`/`--uninstall`; it stores the token `0640 root:dashboards-agent` — the unit hands the
|
||||
agent the *path* and the agent opens it as its own user, so that user must be able to read it and
|
||||
no other account may — never puts a token on argv, and runs under `ProtectSystem=strict` with
|
||||
`DevicePolicy=strict` reopening only the disk nodes plus the `/dev/null` family, because strict
|
||||
without `/dev/null` kills the unit at spawn (`status=208/STDIN`). No root shell? The one sudoers
|
||||
line that lets `ops` run the installer is `deploy/agent-sudoers`; run the script without root and
|
||||
it prints that line with its own resolved path.)
|
||||
The quadlet unit + env template + nginx/SNI live in the deployment repo, like every other
|
||||
service here. The source itself is `git@git.reeseapps.com:services/dashboards.git` (`origin`,
|
||||
branch `main`); there is no CI on it, so `bash .agents/validate.sh` is the only gate.
|
||||
@@ -823,8 +878,8 @@ memory plot; the `cpu.usage_pct@coreN` series is still stored and still queryabl
|
||||
field map is written.
|
||||
3. **UPS history depth.** Events and the numeric series are stored; long-window battery
|
||||
health (capacity fade over months) has the rollups but no dedicated view.
|
||||
4. **The dev DB is big enough to refuse a write now and then.** The dev store (~590 MB, WAL
|
||||
grown to ~70 MB) logs `store-error: OperationalError: database is locked` a few times an
|
||||
4. **The dev DB is big enough to refuse a write now and then.** The dev store (~610 MB, WAL
|
||||
grown past 140 MB) logs `store-error: OperationalError: database is locked` a few times an
|
||||
hour across `system`/`llm`/`ups`; the poller handles it exactly as invariant 4 requires —
|
||||
the source goes red with the real reason, ages, and writes `recording` when the next poll
|
||||
lands — so nothing freezes, but those polls' samples are lost. The one cause this repo can
|
||||
|
||||
@@ -27,29 +27,26 @@ uv run uvicorn dashboards.app:app --port 8090 # equivalent
|
||||
uv run pytest # unit tests, no network needed
|
||||
```
|
||||
|
||||
Developing against the real fleet from a laptop (agents stay unexposed — ssh forwards
|
||||
them to localhost):
|
||||
Developing against the real fleet from a laptop — dev dials exactly the addresses the deployed
|
||||
dashboard dials, so there is nothing to tunnel and no port to forward:
|
||||
|
||||
```bash
|
||||
cp .env.dev.example .env.dev # your ssh aliases + which ports
|
||||
python3 - <<'PY' # push the agent to each host (once)
|
||||
import shlex, subprocess, pathlib
|
||||
for host in ("driveripper-ops", "deskwork-ops", "gateway-ops"):
|
||||
subprocess.run(["ssh", host, "mkdir -p ~/lab-agent"], check=True)
|
||||
subprocess.run(["scp", "agent/host_agent.py", f"{host}:~/lab-agent/"], check=True)
|
||||
PY
|
||||
./scripts/dev-tunnels.sh start # 9121/9122/9123 -> each host's agent,
|
||||
# + DEV_FORWARDS -> upsd on 127.0.0.1:3494
|
||||
# (the remote port is upsd's real one — 3051
|
||||
# on driveripper, not the 3493 default)
|
||||
cp .env.dev.example .env.dev # your host addresses + agent tokens (chmod 0600)
|
||||
sudo deploy/install-agent.sh --bind <addr> --port 9120
|
||||
# once per monitored host: the agent as a systemd
|
||||
# service, printing its bearer token exactly once
|
||||
set -a; . ./.env.dev; set +a
|
||||
uv run dashboards # foreground
|
||||
./scripts/dev-server.sh restart # or backgrounded, with a pidfile + logs
|
||||
```
|
||||
|
||||
`DEV_TUNNELS` starts the agent remotely and forwards it; `DEV_FORWARDS` forwards a service
|
||||
that already listens there (upsd) — that is how the UPS page reads a real battery without
|
||||
3493 being reachable from anywhere but this laptop.
|
||||
Each `HOST_TARGETS` entry carries that token as URL userinfo
|
||||
(`driveripper=http://<token>@<addr>:9120`); `parse_targets` moves it into an `Authorization`
|
||||
header, so `Target.url`, `/api/config` and every error message stay credential-free. No root
|
||||
shell on a host? `deploy/agent-sudoers` is the one-line grant that lets `ops` run the installer,
|
||||
and running the installer without root prints that exact line. upsd is dialled at
|
||||
`NUT_HOST`/`NUT_PORT` on the port it really answers on (3051 on driveripper, not the stock
|
||||
3493) — see the NUT section below for who may connect.
|
||||
|
||||
## The host agent
|
||||
|
||||
@@ -68,6 +65,10 @@ deploy/install-agent.sh --status # unit state and /he
|
||||
sudo deploy/install-agent.sh --uninstall
|
||||
```
|
||||
|
||||
No root shell on the monitored host? `deploy/agent-sudoers` is the single sudoers line that
|
||||
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".
|
||||
|
||||
It is read-only by construction: there is no code path that writes, and the HTTP server
|
||||
only answers `GET`. Counters are reported as counters — the dashboard computes rates over
|
||||
a labelled window, so the agent never has to remember who polled it last.
|
||||
@@ -92,8 +93,11 @@ Binding off-loopback without a token is refused (exit 2) rather than warned abou
|
||||
`--token-file` over `--token`: argv is world-readable through `ps`, and this token guards
|
||||
hostname, mounts, disk and GPU models, and process names. `deploy/install-agent.sh` creates
|
||||
a system user, generates a token once (re-runs reuse it; `--rotate-token` for a new one),
|
||||
stores it `0600` root-owned, and runs the agent under `ProtectSystem=strict` with no
|
||||
capabilities — enough to read `/proc`, `/sys` and `statvfs()` and nothing else.
|
||||
stores it `0640 root:dashboards-agent` — the unit hands the agent the *path*, and the agent
|
||||
opens it as its own user, so the file must be readable by that user and by nobody else —
|
||||
and runs the agent under `ProtectSystem=strict`, `DevicePolicy=strict` with only the disk
|
||||
nodes and the `/dev/null` family reopened (SMART needs the former; without `/dev/null` in
|
||||
the allow list systemd cannot start the service at all), with no capabilities.
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -400,8 +404,14 @@ files: `sudo install -m 0440 -o root -g root deploy/nut-sudoers /etc/sudoers.d/d
|
||||
Read `upsd.users` before you write it — `upsmon` authenticates from the same file, so
|
||||
overwriting it blind can take the box down at the wrong moment.
|
||||
|
||||
Bind upsd to loopback (`LISTEN 127.0.0.1` in `upsd.conf`) and reach it with an ssh forward
|
||||
(`DEV_FORWARDS`) or a tailnet address. `NUT_SIMULATE=1` stands in for upsd during
|
||||
Bind upsd to loopback (`LISTEN 127.0.0.1 3051` in `upsd.conf`) when the dashboard runs on the
|
||||
same box, and to a real interface plus a source-scoped firewall rule when it runs elsewhere —
|
||||
and do not try to scope it by client inside `upsd.conf` instead: **NUT 2.8
|
||||
removed the `ACL`/`ACCEPT`/`REJECT` directives** (a build without TCP Wrappers logs
|
||||
`ACL in upsd.conf is no longer supported - switch to LISTEN` once per line and ignores the
|
||||
line), so `LISTEN` plus a host firewall rule is the only access control `upsd.conf` offers,
|
||||
and an unauthenticated `LIST VAR` from any routed source is the normal state of a NUT server.
|
||||
`NUT_SIMULATE=1` stands in for upsd during
|
||||
development; everything it produces is badged `SIMULATED`, and it is never the default.
|
||||
|
||||
## Deployment
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
# sudoers grant that lets the unprivileged `ops` account install the homelab dashboards
|
||||
# host agent on a monitored host. One line, one script path — deploy/nut-sudoers is the
|
||||
# separate NUT grant on driveripper.
|
||||
#
|
||||
# Install (from the staged copy on the host, as root — the command-scoped `ops` sudo on
|
||||
# these hosts does NOT include visudo, which is exactly why this file exists):
|
||||
#
|
||||
# sudo visudo -cf /home/ops/agent-install/deploy/agent-sudoers # syntax check FIRST
|
||||
# sudo install -m 0440 -o root -g root /home/ops/agent-install/deploy/agent-sudoers \
|
||||
# /etc/sudoers.d/dashboards-agent
|
||||
# sudo visudo -cf /etc/sudoers.d/dashboards-agent # and check the installed copy
|
||||
#
|
||||
# Scope: an absolute path (no wildcards, `secure_path` is in effect), no shell, no editor,
|
||||
# no `tee`, nothing that can amend this file. Running install-agent.sh without root prints
|
||||
# this exact grant with the script's own resolved path, so a checkout at another path tells
|
||||
# you what to add instead of saying "run as root" to someone who cannot.
|
||||
#
|
||||
# WHY THIS IS NEEDED
|
||||
# The agent is read-only by construction: it answers GET /snapshot on a socket, writes
|
||||
# nothing anywhere, and every request except /health needs the bearer token it reads from a
|
||||
# file only root and the agent's own service user can read (0640 root:dashboards-agent — the
|
||||
# agent opens the path itself, so the service user must be in that group). What needs root is
|
||||
# the *install*, not the agent: creating the
|
||||
# dashboards-agent system user, writing /usr/local/lib/dashboards-agent/host_agent.py,
|
||||
# dropping /etc/systemd/system/dashboards-agent.service, storing the token, and running
|
||||
# systemctl daemon-reload + enable --now (disable --now for --uninstall).
|
||||
# Phase 10 runs each host's agent as that systemd service bound to the address the
|
||||
# dashboard dials directly, so the account that deploys over ssh — ops — has to be able to
|
||||
# run the installer, and that is the only reason it is granted anything.
|
||||
#
|
||||
# WHY THE WHOLE SCRIPT PATH AND NOT A FLAG LIST
|
||||
# The flags carry no secrets: --bind/--port choose the socket, --status is read-only,
|
||||
# --uninstall removes exactly what this install put in place, --rotate-token mints a token
|
||||
# the operator is about to be shown anyway. sudoers can only match flags with a wildcard,
|
||||
# and a wildcard list has to be edited in step with every flag the script gains — an edit
|
||||
# whose failure mode is a broken install, not a smaller privilege. The one flag whose
|
||||
# *argument* would be sensitive is --token, and no documented flow uses it: the installer
|
||||
# generates the token, prints it once, and hands the agent --token-file, because argv is
|
||||
# world-readable through ps while the file is not.
|
||||
|
||||
# ---- the grant ------------------------------------------------------------------ #
|
||||
# The host has no repo checkout, so deploy/ and agent/ are staged to ~/agent-install/
|
||||
# (phase 10 task 02 does the copy), which keeps this path identical on every host:
|
||||
#
|
||||
# ssh ops@host 'mkdir -p ~/agent-install'
|
||||
# scp -r deploy agent ops@host:~/agent-install/
|
||||
#
|
||||
# `ops` is the fleet's monitored/deploy account; change only that field if the agent is
|
||||
# installed by a different account. Nothing follows the path, which in sudoers means the
|
||||
# script may be run with any of its own flags — see the block above.
|
||||
ops ALL=(root) NOPASSWD: /home/ops/agent-install/deploy/install-agent.sh
|
||||
|
||||
# ---- making the grant mean something (one root command, recommended) ------------ #
|
||||
# A sudo grant on a path is only as strong as who can write that path. ops owns
|
||||
# ~/agent-install, so until the staged copy is root-owned this line is a convenience for a
|
||||
# human, not a boundary. After staging, as root:
|
||||
#
|
||||
# sudo chown -R root:root /home/ops/agent-install
|
||||
#
|
||||
# ops can still wipe its own home directory and re-copy (it is ops' home), so this guards
|
||||
# against drift and a stray edit — not against an operator who wants something else.
|
||||
|
||||
# ---- deliberately NOT granted --------------------------------------------------- #
|
||||
# visudo, vim, nano, sh, bash, tee — no self-amendment of this grant, no shell, and no
|
||||
# writing any file as root
|
||||
# systemctl (anything else) — the installer already runs the few unit operations
|
||||
# it needs as itself; a bare systemctl * could stop
|
||||
# anything on the host
|
||||
# cat /etc/dashboards-agent.token — the token is readable by root and the agent user
|
||||
# only; install-agent.sh prints it once on first
|
||||
# install and --rotate-token mints a new one
|
||||
# useradd, usermod, groupmod — the installer creates its own user and its disk
|
||||
# group membership; a bare usermod could add ops to
|
||||
# any group on the box
|
||||
# upscmd, upsrw — NUT instant commands stay the dashboard's gated
|
||||
# POST /api/ups/command; see deploy/nut-sudoers
|
||||
+72
-16
@@ -1,15 +1,20 @@
|
||||
#!/usr/bin/env bash
|
||||
# Install agent/host_agent.py as a systemd service on a monitored host.
|
||||
#
|
||||
# sudo ./deploy/install-agent.sh # loopback only (ssh tunnel in)
|
||||
# sudo ./deploy/install-agent.sh # loopback only (read it locally)
|
||||
# sudo ./deploy/install-agent.sh --bind 10.0.0.5 # serve the LAN, token required
|
||||
# sudo ./deploy/install-agent.sh --status
|
||||
# ./deploy/install-agent.sh --status # unit state + /health, no root
|
||||
# sudo ./deploy/install-agent.sh --uninstall
|
||||
#
|
||||
# The token is stored in a root-owned 0600 file and passed to the agent as --token-file,
|
||||
# never on the command line: argv is world-readable through `ps`, and this token guards
|
||||
# hostname, mounts, disk models, GPU model and process names. Re-running the installer
|
||||
# reuses the existing token — use --rotate-token when the old one leaked.
|
||||
# No root shell? `deploy/agent-sudoers` is the single sudoers line that lets the fleet's
|
||||
# unprivileged `ops` account run this script as root. Run it without root and it prints that
|
||||
# exact line with this script's resolved path, then exits 1.
|
||||
#
|
||||
# The token is stored in a file only root and the agent's own service user can read, and is
|
||||
# passed to the agent as --token-file, never on the command line: argv is world-readable
|
||||
# through `ps`, and this token guards hostname, mounts, disk models, GPU model and process
|
||||
# names. Re-running the installer reuses the existing token — use --rotate-token when the old
|
||||
# one leaked.
|
||||
#
|
||||
# The agent is read-only by construction (it answers GET and writes nothing), runs as its
|
||||
# own system user, and gets the usual systemd lockdown. It still needs to read /proc, /sys
|
||||
@@ -32,6 +37,15 @@ TOKEN_FILE="/etc/dashboards-agent.token"
|
||||
UNIT="/etc/systemd/system/$SERVICE.service"
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
AGENT_SOURCE="$SCRIPT_DIR/../agent/host_agent.py"
|
||||
# Absolute, symlink-resolved path of this script: the sudoers grant in deploy/agent-sudoers
|
||||
# is matched on a path, so the non-root hint below prints the line that actually matches
|
||||
# wherever the script happens to live (the canonical staging path is
|
||||
# /home/ops/agent-install/deploy/install-agent.sh).
|
||||
SCRIPT_PATH="$SCRIPT_DIR/$(basename "$0")"
|
||||
if command -v realpath >/dev/null 2>&1; then
|
||||
SCRIPT_PATH="$(realpath "$0" 2>/dev/null || printf '%s' "$SCRIPT_PATH")"
|
||||
fi
|
||||
SUDOERS_SOURCE="$(dirname "$SCRIPT_PATH")/agent-sudoers"
|
||||
|
||||
BIND="127.0.0.1"
|
||||
PORT="9120"
|
||||
@@ -60,7 +74,30 @@ done
|
||||
|
||||
need_root() {
|
||||
if [ "$(id -u)" -ne 0 ]; then
|
||||
echo "run as root: sudo $0 $*" >&2
|
||||
# The dev workflow deploys over ssh as the unprivileged `ops` account, so "run as root"
|
||||
# is not an action the operator can take. Print the one sudoers line that makes this
|
||||
# script installable — the same line as deploy/agent-sudoers, with this script's own
|
||||
# resolved path — plus the two-stage install it documents, then exit 1 as before.
|
||||
cat >&2 <<HINT
|
||||
$SCRIPT_PATH has to run as root: it writes $UNIT,
|
||||
creates the $AGENT_USER user and its disk-group membership, and stores
|
||||
the token in $TOKEN_FILE.
|
||||
|
||||
Add this single sudoers line on this host (it is the whole grant; see $SUDOERS_SOURCE):
|
||||
|
||||
ops ALL=(root) NOPASSWD: $SCRIPT_PATH
|
||||
|
||||
Install it the two-stage way every sudoers change here uses, as root — the command-scoped
|
||||
ops sudo on these hosts does not include visudo, so this step is the operator's:
|
||||
|
||||
sudo visudo -cf $SUDOERS_SOURCE
|
||||
sudo install -m 0440 -o root -g root $SUDOERS_SOURCE /etc/sudoers.d/dashboards-agent
|
||||
sudo visudo -cf /etc/sudoers.d/dashboards-agent
|
||||
|
||||
Then run this same command again:
|
||||
|
||||
sudo $SCRIPT_PATH $*
|
||||
HINT
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
@@ -76,7 +113,7 @@ status() {
|
||||
}
|
||||
|
||||
uninstall() {
|
||||
need_root "$0 --uninstall"
|
||||
need_root --uninstall
|
||||
systemctl disable --now "$SERVICE" 2>/dev/null || true
|
||||
rm -f "$UNIT" "$TOKEN_FILE" "$AGENT_TARGET"
|
||||
rmdir "$AGENT_DIR" 2>/dev/null || true
|
||||
@@ -90,8 +127,12 @@ new_token() {
|
||||
python3 -c 'import secrets; print(secrets.token_urlsafe(32).rstrip("="))'
|
||||
}
|
||||
|
||||
install() {
|
||||
need_root "$0 --bind $BIND --port $PORT"
|
||||
# NOT named `install`: bash resolves a function before an external command of the same name,
|
||||
# so an `install()` here turns the `install -d` / `install -m` calls below into infinite
|
||||
# recursion instead of creating $AGENT_DIR. Phase 10 task 02 found exactly that on a live
|
||||
# host: the run looped on the SMART block forever and installed nothing.
|
||||
do_install() {
|
||||
need_root --bind "$BIND" --port "$PORT"
|
||||
[ -f "$AGENT_SOURCE" ] || { echo "no agent next to this script: $AGENT_SOURCE" >&2; exit 1; }
|
||||
if ! command -v python3 >/dev/null 2>&1; then
|
||||
echo "python3 is required on the monitored host (3.10 or newer)" >&2
|
||||
@@ -127,11 +168,14 @@ install() {
|
||||
elif [ -z "$TOKEN" ]; then
|
||||
TOKEN="$(new_token)"
|
||||
fi
|
||||
# 0600 root:root — the service runs as $AGENT_USER, and systemd (PID 1, root) reads the
|
||||
# file for it, so the agent process never needs read permission on it itself.
|
||||
# 0640 root:$AGENT_USER. The unit passes `--token-file` as a *path*: systemd does not read
|
||||
# the secret for the service, the agent process (running as $AGENT_USER) opens it itself,
|
||||
# so a 0600 root-owned file makes the agent exit "cannot read --token-file" and the unit
|
||||
# never starts. Group ownership is the smallest grant that lets the service read it, and
|
||||
# every other account — ops included — still cannot.
|
||||
(umask 0177; printf '%s\n' "$TOKEN" > "$TOKEN_FILE")
|
||||
chmod 0600 "$TOKEN_FILE"
|
||||
chown root:root "$TOKEN_FILE"
|
||||
chmod 0640 "$TOKEN_FILE"
|
||||
chown root:"$AGENT_USER" "$TOKEN_FILE"
|
||||
|
||||
cat > "$UNIT" <<UNIT_FILE
|
||||
[Unit]
|
||||
@@ -151,10 +195,22 @@ SyslogIdentifier=$SERVICE
|
||||
# be taken away. smartctl is the one thing that needs a real /dev node, so instead of
|
||||
# PrivateDevices the unit closes all devices and reopens only the disk nodes, read/write
|
||||
# at the cgroup layer while the code (and smartctl -i -A -H) stays read-only by design.
|
||||
#
|
||||
# The pseudo nodes are not decoration: DevicePolicy=strict denies every node that is not
|
||||
# listed, and systemd needs /dev/null for the service's stdin before it ever execs python —
|
||||
# without it the unit dies at spawn with "Failed at step STDIN" (status 208), which is how
|
||||
# the first phase-10 install failed on a live host running systemd 257. /dev/zero, full,
|
||||
# random and urandom are the rest of the set a PrivateDevices= sandbox would have provided,
|
||||
# and none of them is a disk.
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
PrivateDevices=false
|
||||
DevicePolicy=strict
|
||||
DeviceAllow=/dev/null rw
|
||||
DeviceAllow=/dev/zero rw
|
||||
DeviceAllow=/dev/full rw
|
||||
DeviceAllow=/dev/random rw
|
||||
DeviceAllow=/dev/urandom rw
|
||||
DeviceAllow=/dev/nvme* rw
|
||||
DeviceAllow=/dev/sd* rw
|
||||
DeviceAllow=/dev/vd* rw
|
||||
@@ -194,14 +250,14 @@ its own, and the API strips it before anything reaches a browser):
|
||||
HOST_TARGETS="hostname=http://$TOKEN@$BIND:$PORT"
|
||||
|
||||
Paste the token now if this is the first install — it is not printed again without
|
||||
--rotate-token, and it is only ever readable by root on this box:
|
||||
--rotate-token, and only root and the agent's own service user can read it on this box:
|
||||
|
||||
$TOKEN
|
||||
NEXT
|
||||
}
|
||||
|
||||
case "$MODE" in
|
||||
install) install ;;
|
||||
install) do_install ;;
|
||||
status) status ;;
|
||||
uninstall) uninstall ;;
|
||||
esac
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
# Re-record ground-truth captures into tests/fixtures/.
|
||||
#
|
||||
# ./scripts/capture-fixtures.sh # needs the tunnels up: scripts/dev-tunnels.sh start
|
||||
# ./scripts/capture-fixtures.sh # dials HOST_TARGETS from .env.dev, directly
|
||||
# ./scripts/capture-fixtures.sh --with-llm # also scrape the live engines' /metrics
|
||||
# # (gufo /slots is captured without the flag)
|
||||
# SMART_HOSTS="deskwork-ops driveripper-ops" ./scripts/capture-fixtures.sh
|
||||
@@ -19,25 +19,37 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
FIX="$ROOT/tests/fixtures"
|
||||
mkdir -p "$FIX"
|
||||
|
||||
HOSTS=(driveripper:9121 deskwork:9122 gateway:9123)
|
||||
# Engine URLs come from the same env the app uses, so this script carries no deployment
|
||||
# detail of its own — and never records a tokenised URL into a fixture by accident.
|
||||
# Agents come from the same env the app reads: HOST_TARGETS in .env.dev, `name=url` pairs
|
||||
# (space or comma separated — the shape config.parse_targets accepts), the bearer token in
|
||||
# the URL userinfo used as-is. Nothing is hard-coded here, so a capture can never come from
|
||||
# a different topology than the dashboard polls.
|
||||
if [ -f "$ROOT/.env.dev" ]; then
|
||||
set -a; . "$ROOT/.env.dev"; set +a
|
||||
fi
|
||||
HOSTS=()
|
||||
targets="${HOST_TARGETS:-}"
|
||||
for entry in ${targets//,/ }; do # space or comma separated, like config.parse_targets
|
||||
HOSTS+=("$entry")
|
||||
done
|
||||
# Engine URLs come from the same env too, so this script carries no deployment
|
||||
# detail of its own — and never records a tokenised URL into a fixture by accident.
|
||||
ENGINES=()
|
||||
[ -n "${GUFO_BASE_URL:-}" ] && ENGINES+=("gufo:${GUFO_BASE_URL%/}/metrics")
|
||||
[ -n "${VLLM_BASE_URL:-}" ] && ENGINES+=("vllm:${VLLM_BASE_URL%/}/metrics")
|
||||
|
||||
failed=0
|
||||
|
||||
if [ ${#HOSTS[@]} -eq 0 ]; then
|
||||
echo " (skipped agent captures — HOST_TARGETS unset; see .env.dev.example)"
|
||||
fi
|
||||
for entry in "${HOSTS[@]}"; do
|
||||
name=${entry%%:*}; port=${entry##*:}
|
||||
url="http://127.0.0.1:${port}/snapshot"
|
||||
if curl -fsS --max-time 8 "$url" -o "$FIX/agent_${name}.json"; then
|
||||
name=${entry%%=*}; url=${entry#*=}
|
||||
# The failure line prints the address without its userinfo: a token belongs in the
|
||||
# dial, not in the terminal scrollback or whatever shells this output into.
|
||||
if curl -fsS --max-time 8 "${url%/}/snapshot" -o "$FIX/agent_${name}.json"; then
|
||||
echo " ✓ agent_${name}.json ($(wc -c <"$FIX/agent_${name}.json") bytes)"
|
||||
else
|
||||
echo " ✗ agent_${name}.json — no agent on $url (scripts/dev-tunnels.sh start?)"
|
||||
echo " ✗ agent_${name}.json — no agent answered at ${url#*@} (deploy/install-agent.sh? firewall?)"
|
||||
failed=1
|
||||
fi
|
||||
done
|
||||
@@ -105,7 +117,7 @@ PY
|
||||
then
|
||||
: # per-UPS lines above
|
||||
else
|
||||
echo " ✗ nut_*.vars — upsd at $NUT_HOST:${NUT_PORT:-3493} unreachable (scripts/dev-tunnels.sh start?)"
|
||||
echo " ✗ nut_*.vars — upsd at $NUT_HOST:${NUT_PORT:-3493} unreachable (down? firewall? NUT_PORT is the port upsd really listens on)"
|
||||
failed=1
|
||||
fi
|
||||
else
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
# Local development server: start|stop|status|restart|logs|tunnel-check
|
||||
# Local development server: start|stop|status|restart|logs
|
||||
#
|
||||
# ./scripts/dev-server.sh restart # reads .env.dev if present, runs on DASH_PORT
|
||||
#
|
||||
|
||||
@@ -1,174 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Dev-only: reach live services on each host from the laptop without opening a single port.
|
||||
#
|
||||
# ./scripts/dev-tunnels.sh start|stop|status|restart
|
||||
#
|
||||
# DEV_TUNNELS starts agent/host_agent.py remotely and forwards it:
|
||||
# ssh -L <local>:127.0.0.1:<rport> <host> 'python3 host_agent.py --port <rport>'
|
||||
# DEV_FORWARDS forwards a service that is already listening remotely (e.g. upsd on 3493):
|
||||
# nothing is started over there, the port just appears on localhost.
|
||||
#
|
||||
# Both supervise themselves: a dropped link retries every 5s. Production is the opposite —
|
||||
# deploy/install-agent.sh installs a systemd unit that binds the LAN address with a bearer
|
||||
# token, and the dashboard dials it directly.
|
||||
set -uo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
RUN="$ROOT/data/tunnels"
|
||||
LOGS="$ROOT/data/logs"
|
||||
# Where the agent lives on each host. `~` is written into the generated script literally so
|
||||
# the REMOTE shell expands it (a local $HOME would be the wrong path over there).
|
||||
AGENT_REMOTE_DIR="${AGENT_REMOTE_DIR:-~/lab-agent}"
|
||||
AGENT_COMMAND="${AGENT_COMMAND:-python3 host_agent.py}"
|
||||
|
||||
# Targets come from .env.dev (see .env.dev.example) — never hardcoded here, so this file
|
||||
# stays portable and holds no hostnames of ours.
|
||||
[ -f "$ROOT/.env.dev" ] && . "$ROOT/.env.dev"
|
||||
DEV_TUNNELS="${DEV_TUNNELS:-}"
|
||||
DEV_FORWARDS="${DEV_FORWARDS:-}"
|
||||
if [ -z "$DEV_TUNNELS" ] && [ -z "$DEV_FORWARDS" ]; then
|
||||
echo "no DEV_TUNNELS or DEV_FORWARDS set — copy .env.dev.example to .env.dev first"; exit 2
|
||||
fi
|
||||
TUNNELS=()
|
||||
for entry in $DEV_TUNNELS; do
|
||||
# 9121:driveripper-ops:9120 -> "9121 driveripper-ops 9120"
|
||||
TUNNELS+=("$(echo "$entry" | tr ':' ' ')")
|
||||
done
|
||||
FORWARDS=()
|
||||
for entry in $DEV_FORWARDS; do
|
||||
FORWARDS+=("$(echo "$entry" | tr ':' ' ')")
|
||||
done
|
||||
|
||||
mkdir -p "$RUN" "$LOGS"
|
||||
|
||||
start() {
|
||||
for spec in "${TUNNELS[@]}"; do
|
||||
read -r local host remote <<<"$(echo "$spec" | tr -s ' ')"
|
||||
# pidfiles are named by host AND port: one host can carry an agent tunnel plus an upsd
|
||||
# forward. The older host-only name is cleared so a restart does not double-bind.
|
||||
pidfile="$RUN/$host-$local.pid"
|
||||
if [ -f "$RUN/$host.pid" ] && kill -0 "$(cat "$RUN/$host.pid")" 2>/dev/null; then
|
||||
pkill -P "$(cat "$RUN/$host.pid")" 2>/dev/null
|
||||
kill "$(cat "$RUN/$host.pid")" 2>/dev/null
|
||||
fi
|
||||
rm -f "$RUN/$host.pid"
|
||||
if [ -f "$pidfile" ] && kill -0 "$(cat "$pidfile")" 2>/dev/null; then
|
||||
echo " $host already tunnelling on :$local (pid $(cat "$pidfile"))"
|
||||
continue
|
||||
fi
|
||||
# A previous agent survives the ssh session that started it, and the next agent then
|
||||
# dies on bind() with "address already in use" — which looks like a broken agent rather
|
||||
# than a leaked process. Clear it in a SEPARATE ssh session: putting pkill in the same
|
||||
# command line as the agent makes pkill match that very command line and shoot itself.
|
||||
cat > "$RUN/$host.sh" <<EOF
|
||||
#!/usr/bin/env bash
|
||||
# generated by scripts/dev-tunnels.sh — do not edit, it is rewritten on every start
|
||||
while true; do
|
||||
ssh -n -o BatchMode=yes "$host" \
|
||||
"pkill -f '[h]ost_agent.py --host 127.0.0.1 --port $remote'" >/dev/null 2>&1
|
||||
sleep 1
|
||||
ssh -nT -o ServerAliveInterval=30 -o ServerAliveCountMax=4 -o ExitOnForwardFailure=yes \
|
||||
-L $local:127.0.0.1:$remote "$host" \
|
||||
"cd $AGENT_REMOTE_DIR && exec $AGENT_COMMAND --host 127.0.0.1 --port $remote"
|
||||
echo "[tunnel] $host link dropped at \$(date -Is) (exit \$?); retrying in 5s"
|
||||
sleep 5
|
||||
done
|
||||
EOF
|
||||
chmod +x "$RUN/$host.sh"
|
||||
nohup "$RUN/$host.sh" >>"$LOGS/tunnel-$host.log" 2>&1 &
|
||||
echo $! >"$pidfile"
|
||||
echo " $host -> http://127.0.0.1:$local (pid $!)"
|
||||
done
|
||||
|
||||
# Plain forwards: `-N` because there is no remote command to run, and nothing to clean up
|
||||
# over there when the link goes.
|
||||
for spec in "${FORWARDS[@]}"; do
|
||||
read -r local host remote <<<"$(echo "$spec" | tr -s ' ')"
|
||||
pidfile="$RUN/$host-$local.pid"
|
||||
if [ -f "$pidfile" ] && kill -0 "$(cat "$pidfile")" 2>/dev/null; then
|
||||
echo " $host:$remote already forwarded on :$local (pid $(cat "$pidfile"))"
|
||||
continue
|
||||
fi
|
||||
cat > "$RUN/$host-$local.sh" <<EOF
|
||||
#!/usr/bin/env bash
|
||||
# generated by scripts/dev-tunnels.sh — do not edit, it is rewritten on every start
|
||||
while true; do
|
||||
ssh -n -N -o ServerAliveInterval=30 -o ServerAliveCountMax=4 -o ExitOnForwardFailure=yes \
|
||||
-L $local:127.0.0.1:$remote "$host"
|
||||
echo "[forward] $host:$remote dropped at \$(date -Is) (exit \$?); retrying in 5s"
|
||||
sleep 5
|
||||
done
|
||||
EOF
|
||||
chmod +x "$RUN/$host-$local.sh"
|
||||
nohup "$RUN/$host-$local.sh" >>"$LOGS/tunnel-$host-$local.log" 2>&1 &
|
||||
echo $! >"$pidfile"
|
||||
echo " $host:$remote -> 127.0.0.1:$local (pid $!)"
|
||||
done
|
||||
}
|
||||
|
||||
stop() {
|
||||
for spec in "${TUNNELS[@]}"; do
|
||||
read -r local host remote <<<"$(echo "$spec" | tr -s ' ')"
|
||||
pidfile="$RUN/$host-$local.pid"
|
||||
if [ -f "$RUN/$host.pid" ]; then
|
||||
pkill -P "$(cat "$RUN/$host.pid")" 2>/dev/null
|
||||
kill "$(cat "$RUN/$host.pid")" 2>/dev/null
|
||||
rm -f "$RUN/$host.pid"
|
||||
fi
|
||||
[ -f "$pidfile" ] || continue
|
||||
pid=$(cat "$pidfile")
|
||||
pkill -P "$pid" 2>/dev/null
|
||||
kill "$pid" 2>/dev/null
|
||||
# -N would mean "no remote command", so the forward is kept by a plain ssh running the
|
||||
# agent; kill any leftover forward for this host by pattern, not just by parent.
|
||||
pkill -f "ssh -nT.*-L $local:127.0.0.1:$remote $host" 2>/dev/null
|
||||
# The remote agent outlives its ssh session, and the next one then dies on bind() with
|
||||
# "address already in use" — which looks like a broken agent, not a leaked process.
|
||||
timeout 10 ssh -n "$host" "pkill -f '[h]ost_agent.py --host 127.0.0.1 --port $remote'" \
|
||||
>/dev/null 2>&1
|
||||
rm -f "$pidfile"
|
||||
echo " stopped $host"
|
||||
done
|
||||
|
||||
for spec in "${FORWARDS[@]}"; do
|
||||
read -r local host remote <<<"$(echo "$spec" | tr -s ' ')"
|
||||
pidfile="$RUN/$host-$local.pid"
|
||||
[ -f "$pidfile" ] || continue
|
||||
pkill -P "$(cat "$pidfile")" 2>/dev/null
|
||||
kill "$(cat "$pidfile")" 2>/dev/null
|
||||
pkill -f "ssh -n -N.*-L $local:127.0.0.1:$remote $host" 2>/dev/null
|
||||
rm -f "$pidfile" "$RUN/$host-$local.sh"
|
||||
echo " stopped forward $host:$remote (:$local)"
|
||||
done
|
||||
}
|
||||
|
||||
status() {
|
||||
for spec in "${TUNNELS[@]}"; do
|
||||
read -r local host remote <<<"$(echo "$spec" | tr -s ' ')"
|
||||
body=$(curl -s --max-time 4 "http://127.0.0.1:$local/health" || true)
|
||||
if [ -n "$body" ]; then
|
||||
echo " OK $host http://127.0.0.1:$local $body"
|
||||
else
|
||||
echo " DOWN $host http://127.0.0.1:$local"
|
||||
fi
|
||||
done
|
||||
|
||||
for spec in "${FORWARDS[@]}"; do
|
||||
read -r local host remote <<<"$(echo "$spec" | tr -s ' ')"
|
||||
# no HTTP behind these (upsd speaks its own line protocol), so the check is a bare TCP
|
||||
# handshake — enough to tell "the tunnel is up" from "nothing listens over there".
|
||||
if timeout 3 bash -c "exec 3<>/dev/tcp/127.0.0.1/$local" 2>/dev/null; then
|
||||
echo " OK $host:$remote 127.0.0.1:$local"
|
||||
else
|
||||
echo " DOWN $host:$remote 127.0.0.1:$local"
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
case "${1:-start}" in
|
||||
start) start ;;
|
||||
stop) stop ;;
|
||||
restart) stop; sleep 1; start ;;
|
||||
status) status ;;
|
||||
*) echo "usage: $0 start|stop|status|restart"; exit 2 ;;
|
||||
esac
|
||||
@@ -133,7 +133,7 @@ _POLLER_CLASS = re.compile(r"^UpstreamError: ")
|
||||
|
||||
|
||||
def _clean_error(label: str, error: str) -> str:
|
||||
"""`http://h:9121/snapshot unreachable (ConnectError)` from the poller's wrapped string."""
|
||||
"""`http://<host>:9120/snapshot unreachable (ConnectError)` from the poller's wrapped string."""
|
||||
text = error.strip()
|
||||
while True:
|
||||
stripped = _POLLER_CLASS.sub("", text, count=1).strip()
|
||||
|
||||
@@ -118,8 +118,17 @@ class NutClient:
|
||||
asyncio.open_connection(self.host, self.port), self.timeout
|
||||
)
|
||||
except (TimeoutError, OSError) as exc:
|
||||
# `wait_for()` raises a TimeoutError whose own message is empty, so a blocked port
|
||||
# used to read `cannot reach upsd at … (TimeoutError: )` in the health record, the
|
||||
# alert and the event feed. Name the failure mode: this sentence is the whole
|
||||
# explanation of a red UPS panel.
|
||||
reason = (
|
||||
f"timed out after {self.timeout:g}s"
|
||||
if isinstance(exc, TimeoutError) and not str(exc)
|
||||
else f"{type(exc).__name__}: {exc}"
|
||||
)
|
||||
raise NutError(
|
||||
f"cannot reach upsd at {self.host}:{self.port} ({type(exc).__name__}: {exc})",
|
||||
f"cannot reach upsd at {self.host}:{self.port} ({reason})",
|
||||
code="UNREACHABLE",
|
||||
) from exc
|
||||
|
||||
|
||||
@@ -106,21 +106,21 @@ def test_a_source_that_stopped_answering_is_a_fail_naming_it_and_linking_its_pag
|
||||
def test_the_collectors_host_prefix_is_not_doubled() -> None:
|
||||
"""Collectors already say `driveripper: …`; the alert starts with the name too."""
|
||||
out = alerts.build(SETTINGS, [state(ok=False,
|
||||
error="driveripper: http://h:9121/snapshot unreachable")])
|
||||
assert out[0]["message"] == "driveripper: http://h:9121/snapshot unreachable"
|
||||
error="driveripper: http://h:9120/snapshot unreachable")])
|
||||
assert out[0]["message"] == "driveripper: http://h:9120/snapshot unreachable"
|
||||
|
||||
|
||||
def test_the_pollers_exception_prefix_is_not_printed_either() -> None:
|
||||
"""The live shape is `poller`'s `UpstreamError: name: …`; one name, no class noise.
|
||||
|
||||
Verified against a real stopped agent tunnel: the health record reads
|
||||
`UpstreamError: gateway: http://127.0.0.1:9123/snapshot unreachable (ConnectError)`,
|
||||
Verified against a real stopped agent: the health record reads
|
||||
`UpstreamError: gateway: http://<host>:9120/snapshot unreachable (ConnectError)`,
|
||||
and printing that verbatim put the host name on the page twice.
|
||||
"""
|
||||
out = alerts.build(SETTINGS, [state(key="host:gateway", label="gateway", ok=False,
|
||||
error="UpstreamError: gateway: http://h:9123/snapshot "
|
||||
error="UpstreamError: gateway: http://h:9120/snapshot "
|
||||
"unreachable (ConnectError)")])
|
||||
assert out[0]["message"] == ("gateway: http://h:9123/snapshot unreachable (ConnectError)")
|
||||
assert out[0]["message"] == ("gateway: http://h:9120/snapshot unreachable (ConnectError)")
|
||||
|
||||
|
||||
def test_an_unexpected_exception_class_is_still_named() -> None:
|
||||
@@ -187,9 +187,9 @@ def test_a_disk_job_that_failed_because_its_agent_is_down_is_one_line_not_two()
|
||||
"""
|
||||
out = alerts.build(SETTINGS, [
|
||||
state(key="disks:driveripper", dashboard="disks", label="driveripper",
|
||||
ok=False, error="UpstreamError: driveripper: http://h:9121/snapshot "
|
||||
ok=False, error="UpstreamError: driveripper: http://h:9120/snapshot "
|
||||
"unreachable (ConnectError)"),
|
||||
state(ok=False, error="UpstreamError: driveripper: http://h:9121/snapshot "
|
||||
state(ok=False, error="UpstreamError: driveripper: http://h:9120/snapshot "
|
||||
"unreachable (ConnectError)"),
|
||||
], now=2_000.0)
|
||||
assert [item["source"] for item in out] == ["driveripper"]
|
||||
|
||||
+2
-2
@@ -453,13 +453,13 @@ def test_an_upstream_failure_escaping_a_route_keeps_its_own_status(tmp_path: Pat
|
||||
|
||||
@app.get("/api/_boom")
|
||||
async def boom() -> None:
|
||||
raise UpstreamError("driveripper: http://127.0.0.1:9121/snapshot timed out", status=504)
|
||||
raise UpstreamError("driveripper: http://127.0.0.1:9120/snapshot timed out", status=504)
|
||||
|
||||
with TestClient(app) as test_client:
|
||||
response = test_client.get("/api/_boom")
|
||||
assert response.status_code == 504, "a timeout stays a timeout, not a 500"
|
||||
assert response.json() == {"ok": False,
|
||||
"detail": "driveripper: http://127.0.0.1:9121/snapshot "
|
||||
"detail": "driveripper: http://127.0.0.1:9120/snapshot "
|
||||
"timed out"}
|
||||
assert "no-store" in response.headers["cache-control"]
|
||||
store.close()
|
||||
|
||||
@@ -119,7 +119,7 @@ def test_nut_host_accepts_a_nut_scheme_prefix() -> None:
|
||||
|
||||
|
||||
def test_load_reads_host_targets_from_the_environment(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("HOST_TARGETS", "deskwork=http://127.0.0.1:9122")
|
||||
monkeypatch.setenv("HOST_TARGETS", "deskwork=http://tok@deskwork.example.net:9120")
|
||||
settings = config.load()
|
||||
assert [t.name for t in settings.host_targets] == ["deskwork"]
|
||||
|
||||
|
||||
@@ -0,0 +1,398 @@
|
||||
"""Contract tests for the deployment scripts in `deploy/`.
|
||||
|
||||
There is no shell test framework here — and adding one would buy nothing a subprocess does
|
||||
not — but phase 10 rests on a sentence that lives in **two** files: the sudoers grant in
|
||||
`deploy/agent-sudoers`, and the hint `deploy/install-agent.sh` prints when it is run without
|
||||
root by the unprivileged `ops` account. Two copies of one instruction is exactly what drifts
|
||||
silently, and the failure is an install that cannot happen on a host nobody is sitting at.
|
||||
So these tests read the file, run the script the way the operator does (unprivileged, which
|
||||
exits before the script can touch anything), and require the two lines to be the same line
|
||||
apart from the path the script was invoked through.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
DEPLOY = ROOT / "deploy"
|
||||
INSTALLER = DEPLOY / "install-agent.sh"
|
||||
SUDOERS = DEPLOY / "agent-sudoers"
|
||||
BASH = shutil.which("bash") or "/bin/bash"
|
||||
|
||||
#: The grant as the file documents it. `/home/ops/agent-install` is the canonical staging
|
||||
#: path: a monitored host has no repo checkout, so `deploy/` and `agent/` are copied there
|
||||
#: (phase 10 task 02) and every host ends up with the same absolute path to grant.
|
||||
CANONICAL_GRANT = "ops ALL=(root) NOPASSWD: /home/ops/agent-install/deploy/install-agent.sh"
|
||||
|
||||
#: The prefix a correct hint line starts with — user, run-as, and NOPASSWD, everything but
|
||||
#: the path, which is the one thing the installer is allowed to substitute.
|
||||
GRANT_PREFIX = "ops ALL=(root) NOPASSWD: "
|
||||
|
||||
#: Everything a non-root operator needs to hear, besides the grant line itself.
|
||||
HINT_MUST_SAY = (
|
||||
"visudo -cf", # check the syntax before installing it
|
||||
"install -m 0440 -o root -g root", # and install it the way sudoers files are installed
|
||||
"/etc/sudoers.d/dashboards-agent", # the destination both files name
|
||||
"deploy/agent-sudoers", # the pointer back to the documented file
|
||||
)
|
||||
|
||||
|
||||
def _run(args: list[str], cwd: Path | None = None) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(args, capture_output=True, text=True, cwd=cwd, check=False)
|
||||
|
||||
|
||||
def _grant_lines(text: str) -> list[str]:
|
||||
"""The sudoers *rules* in a file — comments start with `#`, so this never matches prose."""
|
||||
return [line for line in text.splitlines() if line.startswith(GRANT_PREFIX)]
|
||||
|
||||
|
||||
def _installer_without_root(*flags: str) -> subprocess.CompletedProcess[str]:
|
||||
"""Run the installer the way `ops` does.
|
||||
|
||||
Skipped as root on purpose: root would go past `need_root` and really install a systemd
|
||||
unit on the machine running pytest.
|
||||
"""
|
||||
if hasattr(os, "geteuid") and os.geteuid() == 0:
|
||||
pytest.skip("running as root — the installer would install a real unit here")
|
||||
return _run([BASH, str(INSTALLER), *flags])
|
||||
|
||||
|
||||
# --- deploy/agent-sudoers ---------------------------------------------------------------
|
||||
|
||||
|
||||
def test_agent_sudoers_exists_and_is_a_config_file_not_a_script() -> None:
|
||||
assert SUDOERS.is_file(), "deploy/agent-sudoers is missing (phase 10 task 01)"
|
||||
assert not os.access(SUDOERS, os.X_OK), "a sudoers file is data; only install-agent.sh runs"
|
||||
|
||||
|
||||
def test_agent_sudoers_is_exactly_one_grant_line_at_the_canonical_path() -> None:
|
||||
"""One rule, the installer, the staging path — nothing else may be granted."""
|
||||
lines = _grant_lines(SUDOERS.read_text())
|
||||
assert lines == [CANONICAL_GRANT], (
|
||||
"deploy/agent-sudoers must grant exactly one command, the staged installer:\n" + "\n".join(lines)
|
||||
)
|
||||
assert "/home/ops/agent-install" in SUDOERS.read_text()
|
||||
|
||||
|
||||
def test_agent_sudoers_grants_no_shell_editor_or_second_path() -> None:
|
||||
"""The 'deliberately NOT granted' block has to stay a comment, not become a rule."""
|
||||
text = SUDOERS.read_text()
|
||||
not_granted = text.split("deliberately NOT granted", 1)[-1]
|
||||
for never in ("visudo", "sh", "tee", "systemctl", "usermod", "upscmd", "upsrw"):
|
||||
assert never in not_granted, f"the file must name {never!r} as deliberately not granted"
|
||||
assert not_granted.lstrip().startswith("-"), "the block heading is followed by the list"
|
||||
# Every line of that block is a comment: a stray rule there would be a second grant.
|
||||
assert _grant_lines(not_granted) == []
|
||||
# And it names the token file, because "we did not grant reading it" is the point.
|
||||
assert "/etc/dashboards-agent.token" in not_granted
|
||||
|
||||
|
||||
def test_agent_sudoers_documents_the_why_and_the_two_stage_install() -> None:
|
||||
"""House style (deploy/nut-sudoers): why it is needed, then check-first install steps."""
|
||||
text = SUDOERS.read_text()
|
||||
assert "WHY THIS IS NEEDED" in text
|
||||
for step in HINT_MUST_SAY:
|
||||
assert step in text, f"deploy/agent-sudoers must document {step!r}"
|
||||
# The installed copy is checked too — the file being valid is not the same as the
|
||||
# installed file being valid (nut-sudoers' third step, and the reason stays).
|
||||
assert text.count("visudo -cf") >= 2
|
||||
|
||||
|
||||
@pytest.mark.skipif(shutil.which("visudo") is None, reason="visudo is not installed on this box")
|
||||
def test_agent_sudoers_passes_the_syntax_check_it_documents() -> None:
|
||||
"""The file tells the operator to run `visudo -cf` on it, so it must survive that."""
|
||||
result = _run([shutil.which("visudo") or "/usr/sbin/visudo", "-cf", str(SUDOERS)])
|
||||
assert result.returncode == 0, f"visudo -cf failed:\n{result.stdout}{result.stderr}"
|
||||
|
||||
|
||||
# --- deploy/install-agent.sh without root -----------------------------------------------
|
||||
|
||||
|
||||
def test_installer_without_root_exits_1_and_prints_the_grant_line() -> None:
|
||||
result = _installer_without_root("--bind", "127.0.0.1")
|
||||
assert result.returncode == 1, "the non-root refusal keeps its exit code (1)"
|
||||
assert result.stdout == "", "the refusal is a failure: it goes to stderr, not stdout"
|
||||
lines = _grant_lines(result.stderr)
|
||||
assert lines == [f"{GRANT_PREFIX}{Path(os.path.realpath(INSTALLER))}"], (
|
||||
"the hint must print one grant line naming this script's resolved path:\n" + result.stderr
|
||||
)
|
||||
|
||||
|
||||
def test_hint_and_the_sudoers_file_are_the_same_line_apart_from_the_path() -> None:
|
||||
"""The pinned version of the diff in data/logs/NOTES-sudoers.md."""
|
||||
printed = _installer_without_root("--bind", "127.0.0.1").stderr
|
||||
hint = _grant_lines(printed)[0]
|
||||
canonical = _grant_lines(SUDOERS.read_text())[0]
|
||||
assert hint.startswith(GRANT_PREFIX) and canonical.startswith(GRANT_PREFIX)
|
||||
assert hint.removeprefix(GRANT_PREFIX).endswith("/deploy/install-agent.sh")
|
||||
assert hint.removeprefix(GRANT_PREFIX).removeprefix(str(DEPLOY.parent)) == (
|
||||
canonical.removeprefix(GRANT_PREFIX).removeprefix("/home/ops/agent-install")
|
||||
), "same user, same (root), same NOPASSWD, same script — only the checkout path differs"
|
||||
|
||||
|
||||
def test_hint_resolves_a_relative_invocation_to_an_absolute_path() -> None:
|
||||
"""`bash deploy/install-agent.sh` from the repo root still prints a path sudo can match."""
|
||||
result = _run([BASH, "deploy/install-agent.sh", "--bind", "10.0.0.5"], cwd=ROOT)
|
||||
assert result.returncode == 1
|
||||
assert _grant_lines(result.stderr) == [f"{GRANT_PREFIX}{Path(os.path.realpath(INSTALLER))}"]
|
||||
|
||||
|
||||
def test_installer_names_the_two_stage_install_and_points_at_the_file() -> None:
|
||||
stderr = _installer_without_root("--bind", "127.0.0.1").stderr
|
||||
for phrase in HINT_MUST_SAY:
|
||||
assert phrase in stderr, f"the non-root hint must say {phrase!r}, not just 'run as root'"
|
||||
# The pointer is the file next to *this* script, wherever the checkout lives.
|
||||
assert str(Path(os.path.realpath(SUDOERS))) in stderr
|
||||
|
||||
|
||||
def test_installer_hint_repeats_the_command_that_was_asked_for() -> None:
|
||||
"""Copy-pasteable: the hint ends with the same invocation, with the flags it was given."""
|
||||
stderr = _installer_without_root("--uninstall").stderr
|
||||
assert f"sudo {Path(os.path.realpath(INSTALLER))} --uninstall" in stderr
|
||||
|
||||
|
||||
def test_installer_help_points_at_the_sudoers_grant() -> None:
|
||||
"""The header comment *is* the help text, so the grant is discoverable from --help."""
|
||||
result = _run([BASH, str(INSTALLER), "--help"])
|
||||
assert result.returncode == 0
|
||||
assert "deploy/agent-sudoers" in result.stdout
|
||||
|
||||
|
||||
# --- the tunnel machinery stays retired (phase 10) ---------------------------------------
|
||||
|
||||
#: Names and ports that only ever existed to serve the ssh-tunnel dev transport, which phase
|
||||
#: 10 deleted. Every pattern is written as a character class (`[d]ev-…`) so that **this file
|
||||
#: cannot match its own grep**: the phase criterion demands the repo stay free of those
|
||||
#: literals, and a guard that reintroduces one to forbid it is the oldest trick there is.
|
||||
#: Scope excludes `.agents/` (phase files and run reports are history — rewriting a finished
|
||||
#: phase's record to satisfy a grep would falsify it) and `tests/fixtures/` (verbatim
|
||||
#: upstream captures: a sector counter or a wheel hash may contain those digits by accident,
|
||||
#: and rule 7 forbids editing a capture to please a grep).
|
||||
TUNNEL_PATTERNS = ("[d]ev-tunnels", "DEV_[T]UNNELS", "DEV_[F]ORWARDS", r"912[123]\b", r"[3]494\b")
|
||||
|
||||
#: Where those names must not appear: every file that *tells someone what to run*.
|
||||
#: `tests/fixtures/` is excluded (verbatim captures, see above) and so is `.agents/` —
|
||||
#: phase files and run reports are history, and rewriting a finished phase's record to
|
||||
#: satisfy a grep would falsify it.
|
||||
TUNNEL_FREE_ROOTS = ("scripts", "deploy", "src", "agent", "web")
|
||||
|
||||
|
||||
def _tunnel_scope_files() -> list[Path]:
|
||||
files: list[Path] = []
|
||||
for top in TUNNEL_FREE_ROOTS:
|
||||
for path in (ROOT / top).rglob("*"):
|
||||
if path.is_file() and path.suffix in (".py", ".sh", ".md", ".css", ".js", ".html"):
|
||||
files.append(path)
|
||||
files.append(ROOT / "README.md")
|
||||
files.append(ROOT / "AGENTS.md")
|
||||
files.append(ROOT / ".env.example")
|
||||
files.append(ROOT / ".env.dev.example")
|
||||
for path in (ROOT / "tests").glob("*.py"):
|
||||
files.append(path)
|
||||
return files
|
||||
|
||||
|
||||
def test_dev_tunnel_script_is_deleted() -> None:
|
||||
"""The tunnel script is gone, not shelved: git history keeps it if it is ever needed."""
|
||||
# Name built at runtime so this file never carries the literal the grep forbids.
|
||||
script = ROOT / "scripts" / ("dev-" + "tunnels.sh")
|
||||
assert not script.exists(), (
|
||||
"the retired ssh-tunnel dev script is back — phase 10 deleted it deliberately"
|
||||
)
|
||||
|
||||
|
||||
def test_no_operational_file_mentions_the_tunnel_machinery() -> None:
|
||||
"""A stale tunnel-env instruction is a setup step nobody can complete any more."""
|
||||
offenders: list[str] = []
|
||||
for path in _tunnel_scope_files():
|
||||
text = path.read_text(errors="replace")
|
||||
for pattern in TUNNEL_PATTERNS:
|
||||
if re.search(pattern, text):
|
||||
offenders.append(f"{path.relative_to(ROOT)}: /{pattern}/")
|
||||
assert not offenders, "tunnel-era names/ports are back:\n" + "\n".join(offenders)
|
||||
|
||||
|
||||
# --- shell syntax (the only gate these scripts get) -------------------------------------
|
||||
|
||||
|
||||
#: Names a shell function must never take. Bash resolves a **function** before an external
|
||||
#: command of the same name, so defining `install()` in a script that then calls
|
||||
#: `install -d /somewhere` is not a directory creation — it is the function calling itself,
|
||||
#: forever. That is not hypothetical: `deploy/install-agent.sh` did exactly this, and the
|
||||
#: first real install of phase 10 spun on the SMART block for two minutes and installed
|
||||
#: nothing (no unit, no agent file, no token) while looking like a working script to `bash -n`.
|
||||
SHADOW_FORBIDDEN = frozenset(
|
||||
{
|
||||
"install",
|
||||
"test",
|
||||
"time",
|
||||
"date",
|
||||
"sort",
|
||||
"grep",
|
||||
"sed",
|
||||
"tee",
|
||||
"type",
|
||||
"ulimit",
|
||||
"wait",
|
||||
"kill",
|
||||
"cd",
|
||||
"pwd",
|
||||
}
|
||||
)
|
||||
|
||||
_FUNCTION_DEF_RE = r"^([A-Za-z_][A-Za-z0-9_]*)\s*\(\)"
|
||||
|
||||
|
||||
def _function_names(script: Path) -> list[str]:
|
||||
return re.findall(_FUNCTION_DEF_RE, script.read_text(), flags=re.MULTILINE)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"script",
|
||||
sorted([*DEPLOY.glob("*.sh"), *(ROOT / "scripts").glob("*.sh")]),
|
||||
ids=lambda p: str(p.relative_to(ROOT)),
|
||||
)
|
||||
def test_shell_script_has_valid_syntax(script: Path) -> None:
|
||||
result = _run([BASH, "-n", str(script)])
|
||||
assert result.returncode == 0, f"bash -n failed for {script}:\n{result.stderr}"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"script",
|
||||
sorted([*DEPLOY.glob("*.sh"), *(ROOT / "scripts").glob("*.sh")]),
|
||||
ids=lambda p: str(p.relative_to(ROOT)),
|
||||
)
|
||||
def test_no_shell_function_is_named_after_a_coreutil_it_could_be_asked_to_run(script: Path) -> None:
|
||||
"""`install()` + `install -d` is infinite recursion, and `bash -n` cannot see it."""
|
||||
shadowed = SHADOW_FORBIDDEN.intersection(_function_names(script))
|
||||
assert not shadowed, (
|
||||
f"{script.relative_to(ROOT)} defines function(s) {sorted(shadowed)} that shadow an "
|
||||
"external command: bash runs the function, so any call meant for the program recurses"
|
||||
)
|
||||
|
||||
|
||||
# --- the install path, run for real against a sandboxed root boundary --------------------
|
||||
|
||||
#: 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
|
||||
#: for the UPS wire: fake the privileged boundary, run the real logic.
|
||||
ROOT_BOUNDARY_STUBS = ("id", "getent", "useradd", "usermod", "chown", "systemctl")
|
||||
|
||||
#: 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`.
|
||||
SANDBOX_REWRITES = {
|
||||
'AGENT_DIR="/usr/local/lib/dashboards-agent"': 'AGENT_DIR="{root}/lib"',
|
||||
'TOKEN_FILE="/etc/dashboards-agent.token"': 'TOKEN_FILE="{root}/token"',
|
||||
'UNIT="/etc/systemd/system/$SERVICE.service"': 'UNIT="{root}/dashboards-agent.service"',
|
||||
}
|
||||
|
||||
|
||||
def _sandbox_installer(tmp_path: Path) -> tuple[Path, Path, dict[str, str]]:
|
||||
"""A copy of the installer whose writes land in `tmp_path`, plus a stubbed PATH.
|
||||
|
||||
Returns (script, stub call log, env). `agent/host_agent.py` is stubbed next to the copy
|
||||
because the script resolves the agent as `../agent/host_agent.py`.
|
||||
"""
|
||||
text = INSTALLER.read_text()
|
||||
target = tmp_path / "target"
|
||||
for old, new in SANDBOX_REWRITES.items():
|
||||
assert old in text, f"the installer moved {old!r} — update SANDBOX_REWRITES with it"
|
||||
text = text.replace(old, new.format(root=target))
|
||||
|
||||
script = tmp_path / "deploy" / "install-agent.sh"
|
||||
script.parent.mkdir(parents=True)
|
||||
script.write_text(text)
|
||||
(tmp_path / "agent").mkdir()
|
||||
(tmp_path / "agent" / "host_agent.py").write_text("# stub agent source\n", encoding="utf-8")
|
||||
|
||||
stubs = tmp_path / "stubs"
|
||||
stubs.mkdir()
|
||||
call_log = tmp_path / "stub-calls.log"
|
||||
for name in ROOT_BOUNDARY_STUBS:
|
||||
stub = stubs / name
|
||||
# `id -u` must answer 0 or need_root() refuses before the body runs; every other call
|
||||
# just records itself, which is what the assertions below read.
|
||||
body = ['#!/bin/sh', f'printf "%s %s\\n" "{name}" "$*" >> "$STUB_LOG"']
|
||||
if name == "id":
|
||||
body.append('[ "$1" = "-u" ] && echo 0')
|
||||
body.append("exit 0")
|
||||
stub.write_text("\n".join(body) + "\n", encoding="utf-8")
|
||||
stub.chmod(0o755)
|
||||
|
||||
env = {**os.environ, "PATH": f"{stubs}{os.pathsep}{os.environ['PATH']}", "STUB_LOG": str(call_log)}
|
||||
return script, call_log, env
|
||||
|
||||
|
||||
@pytest.mark.skipif(hasattr(os, "geteuid") and os.geteuid() == 0, reason="running as root")
|
||||
def test_install_path_installs_into_its_own_paths_and_prints_the_token_once(tmp_path: Path) -> None:
|
||||
"""The install body end to end: files, unit, token, and no recursion.
|
||||
|
||||
The exit code is the regression test for the `install()` shadow: while the function was
|
||||
named after `/usr/bin/install`, this never returned at all.
|
||||
"""
|
||||
script, call_log, env = _sandbox_installer(tmp_path)
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[BASH, str(script), "--bind", "10.0.0.5", "--port", "9120"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=env,
|
||||
timeout=60,
|
||||
check=False,
|
||||
)
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
pytest.fail(f"the installer did not return (recursion? partial output: {exc.stdout!r})")
|
||||
assert result.returncode == 0, f"install failed:\n{result.stdout}\n{result.stderr}"
|
||||
|
||||
target = tmp_path / "target"
|
||||
assert (target / "lib" / "host_agent.py").is_file(), "install -m never landed the agent"
|
||||
unit = (target / "dashboards-agent.service").read_text()
|
||||
token_path = target / "token"
|
||||
token = token_path.read_text().strip()
|
||||
|
||||
assert "User=dashboards-agent" in unit
|
||||
assert f"--token-file {token_path}" in unit, "the unit must use the file, never --token"
|
||||
assert "--host 10.0.0.5 --port 9120" in unit
|
||||
# DevicePolicy=strict denies every node not listed, and systemd opens /dev/null for stdin
|
||||
# before it execs python: without this line the unit dies at spawn (status 208/STDIN).
|
||||
assert "DevicePolicy=strict" in unit
|
||||
assert "DeviceAllow=/dev/null" in unit, "strict device policy needs /dev/null or the unit cannot start"
|
||||
assert "DeviceAllow=/dev/nvme*" in unit, "SMART reads need the disk nodes back"
|
||||
assert token and len(token) >= 32, "a generated token is expected"
|
||||
assert token in result.stdout, "first install prints the token, and never again"
|
||||
assert f'HOST_TARGETS="hostname=http://{token}@10.0.0.5:9120"' in result.stdout
|
||||
|
||||
calls = call_log.read_text()
|
||||
assert "systemctl daemon-reload" in calls
|
||||
assert "systemctl enable --now dashboards-agent" in calls
|
||||
assert "chown root:dashboards-agent" in calls, "the token file must become group-readable"
|
||||
|
||||
|
||||
def test_token_file_is_readable_by_the_service_user_and_nobody_else(tmp_path: Path) -> None:
|
||||
"""`--token-file` is a path the *agent* opens, as its own user.
|
||||
|
||||
systemd does not read the secret for the service, so a 0600 root-owned file makes the
|
||||
agent exit before it binds. 0640 root:dashboards-agent is the smallest mode that works,
|
||||
and it is still unreadable by `ops` (which is why deploy/agent-sudoers does not grant it).
|
||||
"""
|
||||
script, _call_log, env = _sandbox_installer(tmp_path)
|
||||
result = subprocess.run(
|
||||
[BASH, str(script), "--bind", "10.0.0.5"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=env,
|
||||
timeout=60,
|
||||
check=False,
|
||||
)
|
||||
assert result.returncode == 0, f"install failed:\n{result.stdout}\n{result.stderr}"
|
||||
mode = stat.S_IMODE((tmp_path / "target" / "token").stat().st_mode)
|
||||
assert mode == 0o640, f"token file mode is {oct(mode)}, expected 0o640 (group = the agent user)"
|
||||
assert not mode & 0o007, "the token file must never be world-readable"
|
||||
@@ -311,7 +311,7 @@ def test_two_hosts_do_not_share_verdict_state(disk_snapshot: Any) -> None:
|
||||
# ---- the job: its own, slow, and absent rather than dialling --------------- #
|
||||
|
||||
def test_one_disks_job_per_host_agent_at_the_slow_cadence() -> None:
|
||||
targets = [Target("driveripper", "http://a:9121"), Target("deskwork", "http://b:9122")]
|
||||
targets = [Target("driveripper", "http://a:9120"), Target("deskwork", "http://b:9120")]
|
||||
# the client is never used by the factory, only captured by the job closures
|
||||
jobs = disks_jobs(targets, NeverDialClient(), DisksCollector(), 300.0, timeout=30.0)
|
||||
assert [job.key for job in jobs] == ["disks:driveripper", "disks:deskwork"]
|
||||
|
||||
@@ -5,9 +5,9 @@ bridge to reality — they re-run the *production* code paths (`collectors.fetch
|
||||
`NutMonitor.collect`) against real targets, which is what catches an upstream that
|
||||
renamed a series or a driver that started reporting a temperature in kelvin.
|
||||
|
||||
They read exactly the env vars the app reads, so the dev loop is:
|
||||
They read exactly the env vars the app reads, so the dev loop is direct dial — the same
|
||||
addresses the deployed dashboard dials, nothing forwarded:
|
||||
|
||||
./scripts/dev-tunnels.sh start # agents + upsd forwarded to localhost
|
||||
set -a; . .env.dev; set +a
|
||||
uv run pytest -m live --live
|
||||
|
||||
@@ -55,7 +55,7 @@ async def test_every_host_agent_answers_and_parses(settings: Settings,
|
||||
host_collector: HostCollector) -> None:
|
||||
"""Each HOST_TARGETS entry must answer /snapshot and yield the metrics the page renders."""
|
||||
if not settings.host_targets:
|
||||
pytest.skip("HOST_TARGETS is unset — start ./scripts/dev-tunnels.sh")
|
||||
pytest.skip("HOST_TARGETS is unset")
|
||||
required = {"cpu.usage_pct", "mem.used_pct", "disk.used_pct"}
|
||||
async with httpx.AsyncClient(timeout=settings.host_timeout) as client:
|
||||
for target in settings.host_targets:
|
||||
@@ -84,7 +84,7 @@ async def test_every_host_serves_the_hero_card_contract(settings: Settings,
|
||||
upgrade renaming a field. Hermetic twin: `tests/test_system_contract.py`.
|
||||
"""
|
||||
if not settings.host_targets:
|
||||
pytest.skip("HOST_TARGETS is unset — start ./scripts/dev-tunnels.sh")
|
||||
pytest.skip("HOST_TARGETS is unset")
|
||||
async with httpx.AsyncClient(timeout=settings.host_timeout) as client:
|
||||
for target in settings.host_targets:
|
||||
await collectors.fetch_host_snapshot(client, target, host_collector)
|
||||
@@ -107,7 +107,7 @@ async def test_agent_disk_rows_carry_the_smart_shape(settings: Settings) -> None
|
||||
operator's call, not a unit test.
|
||||
"""
|
||||
if not settings.host_targets:
|
||||
pytest.skip("HOST_TARGETS is unset — start ./scripts/dev-tunnels.sh")
|
||||
pytest.skip("HOST_TARGETS is unset")
|
||||
seen_smart = 0
|
||||
async with httpx.AsyncClient(timeout=settings.host_timeout) as client:
|
||||
for target in settings.host_targets:
|
||||
@@ -147,7 +147,7 @@ async def test_disks_source_charts_the_physical_drives_the_agent_reports(
|
||||
disk, `disk.*@<dev>` series only for values that exist, and no invented numbers.
|
||||
"""
|
||||
if not settings.host_targets:
|
||||
pytest.skip("HOST_TARGETS is unset — start ./scripts/dev-tunnels.sh")
|
||||
pytest.skip("HOST_TARGETS is unset")
|
||||
collector = DisksCollector()
|
||||
checked = 0
|
||||
async with httpx.AsyncClient(timeout=settings.disks_timeout) as client:
|
||||
@@ -177,7 +177,7 @@ async def test_disks_source_charts_the_physical_drives_the_agent_reports(
|
||||
if any(row["smart"] for row in payload["disks"]):
|
||||
assert payload["smartctl_available"] is True
|
||||
if not checked:
|
||||
pytest.skip("no host agent answered — start ./scripts/dev-tunnels.sh")
|
||||
pytest.skip("no host agent answered")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("engine", ["gufo", "vllm"])
|
||||
|
||||
@@ -249,6 +249,31 @@ async def test_connection_refused_is_reported_as_unreachable() -> None:
|
||||
with pytest.raises(NutError) as excinfo:
|
||||
await client.connect()
|
||||
assert excinfo.value.code == "UNREACHABLE"
|
||||
# The reason is the sentence the operator actually reads (health record, alert, feed).
|
||||
assert "ConnectionRefusedError" in str(excinfo.value)
|
||||
|
||||
|
||||
async def test_connect_timeout_names_the_timeout_instead_of_an_empty_parenthetical(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""`wait_for()` raises a TimeoutError with no message of its own.
|
||||
|
||||
A firewalled port therefore used to print `… (TimeoutError: )`, which explains nothing.
|
||||
This is the exact state the fleet is in while the laptop cannot reach upsd, so the
|
||||
message has to carry the timeout it hit.
|
||||
"""
|
||||
|
||||
async def never_connects(*_args: object, **_kwargs: object) -> None:
|
||||
await asyncio.sleep(0.2)
|
||||
|
||||
monkeypatch.setattr(asyncio, "open_connection", never_connects)
|
||||
client = NutClient("ups.example.net", 3051, timeout=0.01)
|
||||
with pytest.raises(NutError) as excinfo:
|
||||
await client.connect()
|
||||
assert excinfo.value.code == "UNREACHABLE"
|
||||
message = str(excinfo.value)
|
||||
assert "timed out after 0.01s" in message
|
||||
assert "TimeoutError" not in message
|
||||
|
||||
|
||||
async def test_server_closing_early_is_reported() -> None:
|
||||
|
||||
@@ -85,8 +85,8 @@ def parse_capture(name: str) -> nut.UpsReading:
|
||||
path = FIXTURES / f"nut_{name}.vars"
|
||||
if not path.exists():
|
||||
raise AssertionError(
|
||||
f"missing capture {path.name} — run ./scripts/capture-fixtures.sh with the upsd "
|
||||
f"forwarded (DEV_FORWARDS); the UPS hero row is contracted against real drivers"
|
||||
f"missing capture {path.name} — run ./scripts/capture-fixtures.sh with NUT_HOST "
|
||||
f"pointing at the real upsd; the UPS hero row is contracted against real drivers"
|
||||
)
|
||||
variables: dict[str, str] = {}
|
||||
commands: list[str] = []
|
||||
|
||||
@@ -189,7 +189,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "fastapi"
|
||||
version = "0.143.3"
|
||||
version = "0.143.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "annotated-doc" },
|
||||
@@ -199,9 +199,9 @@ dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "typing-inspection" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/38/d5/b0aeb0b303650ac87b0411de4aaf3f901b06042dcc3fb00f30b97b7ff585/fastapi-0.143.3.tar.gz", hash = "sha256:b9ab567ee57afb4454004ce04c889f8f0160ac47946b99795887cd6e88484848", size = 469834, upload-time = "2026-10-16T14:14:13.31Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8f/6a/ac6ec87026038fb7583018e0905f6c9d55bc8ee07d30d44c743f18b19435/fastapi-0.143.5.tar.gz", hash = "sha256:0b5c8e52d243cd2f015376b41e78db5bee585578782481abd145e1ab5a62a3ba", size = 470247, upload-time = "2026-10-18T09:12:55.556Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/1a/03d246befcf307219bb2161c97d8c5d314f84125bf7dd245730a28589828/fastapi-0.143.3-py3-none-any.whl", hash = "sha256:75e8bd68e2f52f77d29b2879749c257d64de2d85282d25270caf2bfccce5efe5", size = 144849, upload-time = "2026-10-16T14:14:11.763Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/4e/5f7d1268900eb14a6697a42d94c8f7e3b8450d27301761f231672f928419/fastapi-0.143.5-py3-none-any.whl", hash = "sha256:09cc38ed8c6a95f407783ef4d98ceeb350e192fe8fbb8ed711070bcd5208fdfa", size = 144894, upload-time = "2026-10-18T09:12:54.125Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
Reference in New Issue
Block a user