phase: 17_installer_device_filter
**Phase 17 — final verification pass: complete, all green.** Verified (criteria): - `grep -n "DevicePolicy\|DeviceAllow" deploy/install-agent.sh` → no match; `PrivateDevices=false` retained (line 472) ✓ - `install-agent.sh --help` names `--as-root`/`--caps` with the kernel-gate sentence; `--force` documented ✓ - Fresh fake-root install = pre-phase unit minus the filter lines: `test_a_default_install_unit_is_the_pre_phase_unit_minus_the_filter_lines` + `test_the_template_unit_has_no_device_filter_lines` pass ✓ - Re-run that differs → exit 1 with diff on stdout, `--force` applies, identical re-run silent: `test_a_re_run_that_differs_refuses_without_force_and_applies_with_it` (+ 3 sibling guard pins) pass ✓ - driveripper reproduction — verified **read-only**, no live change: `systemctl cat` shows `User=root`, `CapabilityBoundingSet=CAP_SYS_RAWIO CAP_SYS_ADMIN`, no filter, same `ExecStart`; `active`, `CapEff 0000000000220000`; `/api/disks/snapshot` → 16/16 drives with temperatures ✓ - `uv run pytest` → **1451 passed, 15 skipped**; `bash .agents/validate.sh` → **exit 0** (coverage **98.94%** ≥ 94, `ruff` clean, `pyright` 0 errors, `node --check` + hygiene clean); `uv run pytest -m live --live` → **11 passed**; dev server 9/9 sources `ok` (one initial live failure was a transient gufo timeout, clean on re-run) Docs (inconsistencies found + fixed, incl. pre-existing): - `AGENTS.md` fleet record said driveripper's unit runs `User=dashboards-agent` while the same bullet (and the live host) says `User=root` — rewritten to separate the installed user from the running one. - `AGENTS.md` gap 10 header still claimed "the installer template is what is left" → now records phase 17 as the fix. - `README.md` "with no capabilities" → "with an empty `CapabilityBoundingSet` (unless `--caps`…)", since this phase added the capability flag. - `README.md` battery-test "gated four times" now notes the two session gates in front, matching AGENTS invariant 1. - `.env.example` was missing three README-documented settings (`DASH_ENV_FILE`, `DASH_RELOAD`, `NUT_SIMULATE`) and `AGENT_CERT_BUNDLE` in the agent-variables note — added, annotated; all template values re-checked against `config.py` defaults. Deviations: no live `--force` run on driveripper (needs the Oct-18 sudo-staged installer re-staged — it lacks the flags — and would push agent 1.1.0 mid phase-15 rollout); command + expected diff + post-checks recorded in AGENTS/gap 10. `uv pip list --outdated` still advisory (fastapi 0.153→0.156), untouched as in tasks 02/04 — this phase moved no dependency. Next pending phase: `.agents/phases/todo/18_nav_hamburger/`.
This commit is contained in:
@@ -0,0 +1,20 @@
|
|||||||
|
**Phase 17 — final verification pass: complete, all green.**
|
||||||
|
|
||||||
|
Verified (criteria):
|
||||||
|
- `grep -n "DevicePolicy\|DeviceAllow" deploy/install-agent.sh` → no match; `PrivateDevices=false` retained (line 472) ✓
|
||||||
|
- `install-agent.sh --help` names `--as-root`/`--caps` with the kernel-gate sentence; `--force` documented ✓
|
||||||
|
- Fresh fake-root install = pre-phase unit minus the filter lines: `test_a_default_install_unit_is_the_pre_phase_unit_minus_the_filter_lines` + `test_the_template_unit_has_no_device_filter_lines` pass ✓
|
||||||
|
- Re-run that differs → exit 1 with diff on stdout, `--force` applies, identical re-run silent: `test_a_re_run_that_differs_refuses_without_force_and_applies_with_it` (+ 3 sibling guard pins) pass ✓
|
||||||
|
- driveripper reproduction — verified **read-only**, no live change: `systemctl cat` shows `User=root`, `CapabilityBoundingSet=CAP_SYS_RAWIO CAP_SYS_ADMIN`, no filter, same `ExecStart`; `active`, `CapEff 0000000000220000`; `/api/disks/snapshot` → 16/16 drives with temperatures ✓
|
||||||
|
- `uv run pytest` → **1451 passed, 15 skipped**; `bash .agents/validate.sh` → **exit 0** (coverage **98.94%** ≥ 94, `ruff` clean, `pyright` 0 errors, `node --check` + hygiene clean); `uv run pytest -m live --live` → **11 passed**; dev server 9/9 sources `ok` (one initial live failure was a transient gufo timeout, clean on re-run)
|
||||||
|
|
||||||
|
Docs (inconsistencies found + fixed, incl. pre-existing):
|
||||||
|
- `AGENTS.md` fleet record said driveripper's unit runs `User=dashboards-agent` while the same bullet (and the live host) says `User=root` — rewritten to separate the installed user from the running one.
|
||||||
|
- `AGENTS.md` gap 10 header still claimed "the installer template is what is left" → now records phase 17 as the fix.
|
||||||
|
- `README.md` "with no capabilities" → "with an empty `CapabilityBoundingSet` (unless `--caps`…)", since this phase added the capability flag.
|
||||||
|
- `README.md` battery-test "gated four times" now notes the two session gates in front, matching AGENTS invariant 1.
|
||||||
|
- `.env.example` was missing three README-documented settings (`DASH_ENV_FILE`, `DASH_RELOAD`, `NUT_SIMULATE`) and `AGENT_CERT_BUNDLE` in the agent-variables note — added, annotated; all template values re-checked against `config.py` defaults.
|
||||||
|
|
||||||
|
Deviations: no live `--force` run on driveripper (needs the Oct-18 sudo-staged installer re-staged — it lacks the flags — and would push agent 1.1.0 mid phase-15 rollout); command + expected diff + post-checks recorded in AGENTS/gap 10. `uv pip list --outdated` still advisory (fastapi 0.153→0.156), untouched as in tasks 02/04 — this phase moved no dependency.
|
||||||
|
|
||||||
|
Next pending phase: `.agents/phases/todo/18_nav_hamburger/`.
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
ok
|
||||||
+65
@@ -0,0 +1,65 @@
|
|||||||
|
== uv run pytest --cov (hermetic: no network, live tests skip) ==
|
||||||
|
........................................................................ [ 4%]
|
||||||
|
........................................................................ [ 9%]
|
||||||
|
........................................................................ [ 14%]
|
||||||
|
........................................................................ [ 19%]
|
||||||
|
........................................................................ [ 24%]
|
||||||
|
...........................s............................................ [ 29%]
|
||||||
|
........................................................................ [ 34%]
|
||||||
|
........................................................................ [ 39%]
|
||||||
|
........................................................................ [ 44%]
|
||||||
|
........................................................................ [ 49%]
|
||||||
|
........................................................................ [ 54%]
|
||||||
|
........................................................................ [ 58%]
|
||||||
|
........................................................................ [ 63%]
|
||||||
|
................................................................ssssssss [ 68%]
|
||||||
|
sss..................................................................... [ 73%]
|
||||||
|
........................................................................ [ 78%]
|
||||||
|
........................................................................ [ 83%]
|
||||||
|
........................................................................ [ 88%]
|
||||||
|
........................................................................ [ 93%]
|
||||||
|
........................................sss............................. [ 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% 32
|
||||||
|
src/dashboards/alerts.py 176 0 100%
|
||||||
|
src/dashboards/api.py 436 0 100%
|
||||||
|
src/dashboards/app.py 185 0 100%
|
||||||
|
src/dashboards/auth.py 265 0 100%
|
||||||
|
src/dashboards/certs.py 292 0 100%
|
||||||
|
src/dashboards/collectors.py 205 5 98% 284-285, 289-290, 307
|
||||||
|
src/dashboards/config.py 120 0 100%
|
||||||
|
src/dashboards/debug.py 14 0 100%
|
||||||
|
src/dashboards/poller.py 165 3 98% 192-194, 279
|
||||||
|
src/dashboards/prom.py 112 5 96% 126, 163, 177, 180, 183
|
||||||
|
src/dashboards/rates.py 73 0 100%
|
||||||
|
src/dashboards/severity.py 49 0 100%
|
||||||
|
src/dashboards/sources/__init__.py 0 0 100%
|
||||||
|
src/dashboards/sources/disks.py 134 0 100%
|
||||||
|
src/dashboards/sources/host.py 209 0 100%
|
||||||
|
src/dashboards/sources/llm.py 178 1 99% 285
|
||||||
|
src/dashboards/sources/nut.py 413 20 95% 153, 172, 186, 198, 204, 217, 225, 241, 319-320, 322, 579, 591, 626, 657, 661, 718-719, 724, 736
|
||||||
|
src/dashboards/sources/nut_sim.py 134 0 100%
|
||||||
|
src/dashboards/store.py 313 2 99% 553-554
|
||||||
|
------------------------------------------------------------------
|
||||||
|
TOTAL 3490 37 99%
|
||||||
|
Required test coverage of 94% reached. Total coverage: 98.94%
|
||||||
|
1451 passed, 15 skipped, 1 warning in 153.26s (0:02:33)
|
||||||
|
== uv run ruff check src tests agent ==
|
||||||
|
All checks passed!
|
||||||
|
== uv run pyright ==
|
||||||
|
0 errors, 0 warnings, 0 informations
|
||||||
|
== node --check src/dashboards/web/static/*.js ==
|
||||||
|
advisory: dependencies are out of date (AGENTS.md rule 8 — update on purpose):
|
||||||
|
fastapi 0.153.0 0.156.0 wheel
|
||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
**Implemented (phase 17 task 01 — `deploy/install-agent.sh`)**
|
||||||
|
- Deleted `DevicePolicy=strict` + all nine `DeviceAllow=…` lines from the `do_install` unit heredoc; `PrivateDevices=false` stays explicit.
|
||||||
|
- Rewrote the rationale comment block (and the top-of-file/help paragraph) to the new truth: no device filter on purpose — systemd matches a device-filter glob by *name* against `/proc/devices`, kernel 7.2 lists NVMe as `blkext` (major 259), so `/dev/nvme*` matched nothing and a strict policy denied the whole namespace (driveripper, root included); device access is DAC via the `disk` group + SELinux. Kept the `status=208/STDIN` warning as the reason `PrivateDevices=false` is written out.
|
||||||
|
- `tests/test_deploy_scripts.py`: the sandbox-install test now pins the *absence* of the filter plus `PrivateDevices=false`; added `test_the_written_unit_is_only_comments_and_directives` — a defect found while verifying this task: the unit heredoc is unquoted (so phase 15's `$TLS_UNIT_LINES` expands), which **executes backticked prose** (`disk`, `blkext`, `/dev/nvme*` became `command not found` + a smartctl banner pasted into the unit). Mutation-checked: the pin catches it.
|
||||||
|
- Docs: README install paragraph and four AGENTS.md places (fleet divergence bullet, driveripper sudoers bullet, Deployment paragraph, gap 10) now say the template applies no device filter; the obsolete "missing `/dev/ng*n*`/`/dev/bsg/*` in the allow-list" warning is retired; gap 10 records `--as-root`/`--caps` + the re-run guard as the phase's remaining tasks.
|
||||||
|
|
||||||
|
**Tests / lint / coverage**
|
||||||
|
- `uv run pytest` → **1428 passed, 15 skipped** (was 1427; +1 new test), exit 0.
|
||||||
|
- `bash .agents/validate.sh` → exit 0: `pytest --cov --cov-fail-under=94` **98.94%** total, `ruff check src tests agent` clean, `pyright` 0 errors, `node --check` clean, repo hygiene clean. `uv pip list --outdated` empty (no deps moved).
|
||||||
|
- Task criteria: `grep -n "DevicePolicy\|DeviceAllow" deploy/install-agent.sh` → no match; `bash -n` clean; manual fake-root sandbox install (`--bind 127.0.0.1 --port 9199`) diffed against a HEAD-version sandbox install: directive lines differ **only** by the ten removed filter lines (comments rewritten per the task).
|
||||||
|
|
||||||
|
**README/AGENTS.md**: updated as above (docs stayed in sync mid-phase).
|
||||||
|
|
||||||
|
**Decisions/deviations**: the phase's grep criterion forbids the literal directive names in the script, so the comment explains them in prose ("device-filter glob", "strict device policy") instead of naming `DevicePolicy`/`DeviceAllow`. Added the heredoc-expansion pin (not in the task text) because it is a real pre-existing hazard this task's own comment nearly shipped. **No live-infrastructure changes** — the driveripper reproduction (`--as-root --caps … --force`, `systemctl diff`, 16/16 drives) is task 04's operator-gated step and needs the task 02 flags first.
|
||||||
|
|
||||||
|
**Next pending task**: `.agents/phases/todo/17_installer_device_filter/02_root_and_caps_flags.md`
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
ok
|
||||||
+63
@@ -0,0 +1,63 @@
|
|||||||
|
== uv run pytest --cov (hermetic: no network, live tests skip) ==
|
||||||
|
........................................................................ [ 4%]
|
||||||
|
........................................................................ [ 9%]
|
||||||
|
........................................................................ [ 14%]
|
||||||
|
........................................................................ [ 19%]
|
||||||
|
........................................................................ [ 24%]
|
||||||
|
...........................s............................................ [ 29%]
|
||||||
|
........................................................................ [ 34%]
|
||||||
|
........................................................................ [ 39%]
|
||||||
|
........................................................................ [ 44%]
|
||||||
|
........................................................................ [ 49%]
|
||||||
|
........................................................................ [ 54%]
|
||||||
|
........................................................................ [ 59%]
|
||||||
|
........................................................................ [ 64%]
|
||||||
|
.........................................sssssssssss.................... [ 69%]
|
||||||
|
........................................................................ [ 74%]
|
||||||
|
........................................................................ [ 79%]
|
||||||
|
........................................................................ [ 84%]
|
||||||
|
........................................................................ [ 89%]
|
||||||
|
........................................................................ [ 94%]
|
||||||
|
.................sss.................................................... [ 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% 32
|
||||||
|
src/dashboards/alerts.py 176 0 100%
|
||||||
|
src/dashboards/api.py 436 0 100%
|
||||||
|
src/dashboards/app.py 185 0 100%
|
||||||
|
src/dashboards/auth.py 265 0 100%
|
||||||
|
src/dashboards/certs.py 292 0 100%
|
||||||
|
src/dashboards/collectors.py 205 5 98% 284-285, 289-290, 307
|
||||||
|
src/dashboards/config.py 120 0 100%
|
||||||
|
src/dashboards/debug.py 14 0 100%
|
||||||
|
src/dashboards/poller.py 165 3 98% 192-194, 279
|
||||||
|
src/dashboards/prom.py 112 5 96% 126, 163, 177, 180, 183
|
||||||
|
src/dashboards/rates.py 73 0 100%
|
||||||
|
src/dashboards/severity.py 49 0 100%
|
||||||
|
src/dashboards/sources/__init__.py 0 0 100%
|
||||||
|
src/dashboards/sources/disks.py 134 0 100%
|
||||||
|
src/dashboards/sources/host.py 209 0 100%
|
||||||
|
src/dashboards/sources/llm.py 178 1 99% 285
|
||||||
|
src/dashboards/sources/nut.py 413 20 95% 153, 172, 186, 198, 204, 217, 225, 241, 319-320, 322, 579, 591, 626, 657, 661, 718-719, 724, 736
|
||||||
|
src/dashboards/sources/nut_sim.py 134 0 100%
|
||||||
|
src/dashboards/store.py 313 2 99% 553-554
|
||||||
|
------------------------------------------------------------------
|
||||||
|
TOTAL 3490 37 99%
|
||||||
|
Required test coverage of 94% reached. Total coverage: 98.94%
|
||||||
|
1428 passed, 15 skipped, 1 warning in 127.90s (0:02:07)
|
||||||
|
== 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 ==
|
||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
**Task 02 — `--as-root` / `--caps` (phase 17) — done**
|
||||||
|
|
||||||
|
- `deploy/install-agent.sh`: added `--as-root` (`User=root`) and `--caps LIST` (`CapabilityBoundingSet=<space-separated>`), both off by default → default unit byte-identical to before; comma→space normalisation drops empty entries and runs with globbing off (`--caps '*'` stays literal).
|
||||||
|
- Validation: `--caps` without `--as-root` → stderr refusal + exit 1 **before** `need_root` and any write (same reasoning as the bundle validator); `--caps` with no argument / empty / punctuation-only → exit 1 with its own sentence; `--as-root` alone proceeds with the exact CapEff-0 note on stdout.
|
||||||
|
- Unit heredoc gained the one `--caps` comment above `CapabilityBoundingSet=`; `--status` and the token-file `0640 root:dashboards-agent` logic deliberately untouched; `--help` (the header comment) now names both flags with the kernel-gate sentence and the copy-pasteable recipe line.
|
||||||
|
- Tests (`tests/test_deploy_scripts.py`, 8 new / 12 new cases): exact `User=`/`CapabilityBoundingSet=` lines for default, `--as-root --caps …`, `--as-root` alone; parametrized refusals asserting nothing was written and no privileged stub ran; missing-argument refusal; normalisation + no-glob; `--help` recipe; `status()` blind to `AS_ROOT`/`CAPS`.
|
||||||
|
- `bash .agents/validate.sh` → **1439 passed, 15 skipped**, coverage **98.94%** (floor 94), `ruff check src tests agent` clean, `pyright` 0 errors, `node --check` clean, `bash -n deploy/install-agent.sh` clean; `uv run pytest -m live --live` → **11 passed** (unchanged).
|
||||||
|
- Docs: README (install flag block + a new kernel-gate paragraph) and AGENTS.md (fleet record, sudoers/grant bullet, deployment flag list, gap 10 tail) now state the flags exist, the `driveripper` recipe `--as-root --caps CAP_SYS_RAWIO,CAP_SYS_ADMIN`, and that the unconditional unit rewrite is task 03's remaining guard.
|
||||||
|
- **No live changes made**: the driveripper re-run (`systemctl diff`, `CapEff`, 16/16 drives) is task 04's operator-verified criterion, and gap 10 warns the sudo-granted Oct-18 *staged* snapshot would clobber the working unit — it must be re-staged from the repo first (now noted in AGENTS.md).
|
||||||
|
- Advisory only (not touched, deps not in scope): `uv pip list --outdated` reports `fastapi 0.153.0 → 0.154.0`.
|
||||||
|
- Next pending task: `.agents/phases/todo/17_installer_device_filter/03_no_silent_clobber.md`.
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
ok
|
||||||
+65
@@ -0,0 +1,65 @@
|
|||||||
|
== uv run pytest --cov (hermetic: no network, live tests skip) ==
|
||||||
|
........................................................................ [ 4%]
|
||||||
|
........................................................................ [ 9%]
|
||||||
|
........................................................................ [ 14%]
|
||||||
|
........................................................................ [ 19%]
|
||||||
|
........................................................................ [ 24%]
|
||||||
|
...........................s............................................ [ 29%]
|
||||||
|
........................................................................ [ 34%]
|
||||||
|
........................................................................ [ 39%]
|
||||||
|
........................................................................ [ 44%]
|
||||||
|
........................................................................ [ 49%]
|
||||||
|
........................................................................ [ 54%]
|
||||||
|
........................................................................ [ 59%]
|
||||||
|
........................................................................ [ 64%]
|
||||||
|
....................................................sssssssssss......... [ 69%]
|
||||||
|
........................................................................ [ 74%]
|
||||||
|
........................................................................ [ 79%]
|
||||||
|
........................................................................ [ 84%]
|
||||||
|
........................................................................ [ 89%]
|
||||||
|
........................................................................ [ 94%]
|
||||||
|
............................sss......................................... [ 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% 32
|
||||||
|
src/dashboards/alerts.py 176 0 100%
|
||||||
|
src/dashboards/api.py 436 0 100%
|
||||||
|
src/dashboards/app.py 185 0 100%
|
||||||
|
src/dashboards/auth.py 265 0 100%
|
||||||
|
src/dashboards/certs.py 292 0 100%
|
||||||
|
src/dashboards/collectors.py 205 5 98% 284-285, 289-290, 307
|
||||||
|
src/dashboards/config.py 120 0 100%
|
||||||
|
src/dashboards/debug.py 14 0 100%
|
||||||
|
src/dashboards/poller.py 165 3 98% 192-194, 279
|
||||||
|
src/dashboards/prom.py 112 5 96% 126, 163, 177, 180, 183
|
||||||
|
src/dashboards/rates.py 73 0 100%
|
||||||
|
src/dashboards/severity.py 49 0 100%
|
||||||
|
src/dashboards/sources/__init__.py 0 0 100%
|
||||||
|
src/dashboards/sources/disks.py 134 0 100%
|
||||||
|
src/dashboards/sources/host.py 209 0 100%
|
||||||
|
src/dashboards/sources/llm.py 178 1 99% 285
|
||||||
|
src/dashboards/sources/nut.py 413 20 95% 153, 172, 186, 198, 204, 217, 225, 241, 319-320, 322, 579, 591, 626, 657, 661, 718-719, 724, 736
|
||||||
|
src/dashboards/sources/nut_sim.py 134 0 100%
|
||||||
|
src/dashboards/store.py 313 2 99% 553-554
|
||||||
|
------------------------------------------------------------------
|
||||||
|
TOTAL 3490 37 99%
|
||||||
|
Required test coverage of 94% reached. Total coverage: 98.94%
|
||||||
|
1439 passed, 15 skipped, 1 warning in 135.34s (0:02:15)
|
||||||
|
== uv run ruff check src tests agent ==
|
||||||
|
All checks passed!
|
||||||
|
== uv run pyright ==
|
||||||
|
0 errors, 0 warnings, 0 informations
|
||||||
|
== node --check src/dashboards/web/static/*.js ==
|
||||||
|
advisory: dependencies are out of date (AGENTS.md rule 8 — update on purpose):
|
||||||
|
fastapi 0.153.0 0.154.0 wheel
|
||||||
+18
@@ -0,0 +1,18 @@
|
|||||||
|
All green. Report:
|
||||||
|
|
||||||
|
**Implemented**
|
||||||
|
- `deploy/install-agent.sh`: new `--force` flag (`FORCE=0` default) + `--help` text (`--force overwrite an existing unit even if the generated unit differs (prints the diff first)`, aligned to the `--as-root`/`--caps` column).
|
||||||
|
- `do_install` now renders the unit heredoc to `mktemp "${TMPDIR:-/tmp}/dashboards-agent-unit.XXXXXX"`; if `$UNIT` exists and `! cmp -s`, it prints `refusing to overwrite $UNIT (generated unit differs); run with --force to apply:` + `diff -u` and `exit 1` **before** the unit write, `daemon-reload` and `enable --now`; with `--force` it prints the same diff then overwrites; identical unit → silent proceed. `--status`/`--uninstall` untouched.
|
||||||
|
- 8 new pins in `tests/test_deploy_scripts.py`: refuse (exit 1, diff on stdout, unit byte-identical to the hand-edit, token value+`0640` unchanged, **zero new `systemctl` calls**), force (diff then write + re-enable), silent idempotent re-run, fresh install unaffected (plain vs `--force` render identical), the `driveripper` kernel-gate line refuses once → applies with `--force` → silent afterwards, uninstall never asks, source-ordering pin (render < cmp < write < reload < enable, no `cat > "$UNIT" <<` remains), and a `--help` alignment pin.
|
||||||
|
- Docs: README install block + a re-run-guard paragraph, and TLS-rollout step 2 now runs `--cert-bundle … --force` with the reason (installing a bundle *is* a unit change); AGENTS.md fleet bullet, the Commands flag list + guard paragraph, gap 10's task-03 status, and gap 11's staged-snapshot warnings (the Oct-18 copy predates the guard, so it still clobbers silently).
|
||||||
|
|
||||||
|
**Tests / lint / coverage**
|
||||||
|
- `uv run pytest` → **1447 passed, 15 skipped**; `bash .agents/validate.sh` → green (coverage **98.94%**, floor 94%), `ruff check src tests agent` clean, `pyright` 0 errors, `node --check` ok.
|
||||||
|
- `uv run pytest -m live --live` → 11 passed (read-only; dev server still 9/9 `ok`). **No live infra change** — the `driveripper` `--force` reproduction is task 04's operator-verified step.
|
||||||
|
|
||||||
|
**Decisions / deviations**
|
||||||
|
- Guard sits immediately before the unit write (the task's own parenthetical), so the earlier idempotent writes still run; the refusal path never re-writes the unit nor touches the service, and the reused token's value/mode are pinned unchanged (mtime isn't — noted in the test and script comment).
|
||||||
|
- Refusal sentence *and* diff both go to stdout (the diff is the answer's payload; task-04 pins "diff on stdout"). Temp file gets a readable prefix so the diff's `+++` names the generated unit.
|
||||||
|
- Wrote the formal pins now rather than leaving the guard untested until task 04; task 04's remaining doc/live items are untouched except where my change made prose stale.
|
||||||
|
|
||||||
|
**Next pending task:** `.agents/phases/todo/17_installer_device_filter/04_tests_docs_gate.md`
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
ok
|
||||||
+65
@@ -0,0 +1,65 @@
|
|||||||
|
== uv run pytest --cov (hermetic: no network, live tests skip) ==
|
||||||
|
........................................................................ [ 4%]
|
||||||
|
........................................................................ [ 9%]
|
||||||
|
........................................................................ [ 14%]
|
||||||
|
........................................................................ [ 19%]
|
||||||
|
........................................................................ [ 24%]
|
||||||
|
...........................s............................................ [ 29%]
|
||||||
|
........................................................................ [ 34%]
|
||||||
|
........................................................................ [ 39%]
|
||||||
|
........................................................................ [ 44%]
|
||||||
|
........................................................................ [ 49%]
|
||||||
|
........................................................................ [ 54%]
|
||||||
|
........................................................................ [ 59%]
|
||||||
|
........................................................................ [ 64%]
|
||||||
|
............................................................sssssssssss. [ 68%]
|
||||||
|
........................................................................ [ 73%]
|
||||||
|
........................................................................ [ 78%]
|
||||||
|
........................................................................ [ 83%]
|
||||||
|
........................................................................ [ 88%]
|
||||||
|
........................................................................ [ 93%]
|
||||||
|
....................................sss................................. [ 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% 32
|
||||||
|
src/dashboards/alerts.py 176 0 100%
|
||||||
|
src/dashboards/api.py 436 0 100%
|
||||||
|
src/dashboards/app.py 185 0 100%
|
||||||
|
src/dashboards/auth.py 265 0 100%
|
||||||
|
src/dashboards/certs.py 292 0 100%
|
||||||
|
src/dashboards/collectors.py 205 5 98% 284-285, 289-290, 307
|
||||||
|
src/dashboards/config.py 120 0 100%
|
||||||
|
src/dashboards/debug.py 14 0 100%
|
||||||
|
src/dashboards/poller.py 165 3 98% 192-194, 279
|
||||||
|
src/dashboards/prom.py 112 5 96% 126, 163, 177, 180, 183
|
||||||
|
src/dashboards/rates.py 73 0 100%
|
||||||
|
src/dashboards/severity.py 49 0 100%
|
||||||
|
src/dashboards/sources/__init__.py 0 0 100%
|
||||||
|
src/dashboards/sources/disks.py 134 0 100%
|
||||||
|
src/dashboards/sources/host.py 209 0 100%
|
||||||
|
src/dashboards/sources/llm.py 178 1 99% 285
|
||||||
|
src/dashboards/sources/nut.py 413 20 95% 153, 172, 186, 198, 204, 217, 225, 241, 319-320, 322, 579, 591, 626, 657, 661, 718-719, 724, 736
|
||||||
|
src/dashboards/sources/nut_sim.py 134 0 100%
|
||||||
|
src/dashboards/store.py 313 2 99% 553-554
|
||||||
|
------------------------------------------------------------------
|
||||||
|
TOTAL 3490 37 99%
|
||||||
|
Required test coverage of 94% reached. Total coverage: 98.94%
|
||||||
|
1447 passed, 15 skipped, 1 warning in 146.51s (0:02:26)
|
||||||
|
== uv run ruff check src tests agent ==
|
||||||
|
All checks passed!
|
||||||
|
== uv run pyright ==
|
||||||
|
0 errors, 0 warnings, 0 informations
|
||||||
|
== node --check src/dashboards/web/static/*.js ==
|
||||||
|
advisory: dependencies are out of date (AGENTS.md rule 8 — update on purpose):
|
||||||
|
fastapi 0.153.0 0.154.0 wheel
|
||||||
+15
@@ -0,0 +1,15 @@
|
|||||||
|
**Implemented**
|
||||||
|
- `tests/test_deploy_scripts.py`: added the four task-named pins — `test_the_template_unit_has_no_device_filter_lines` (heredoc region + the whole-file `DevicePolicy`/`DeviceAllow` absence, `PrivateDevices=false` kept, rationale must name `/proc/devices`/`blkext`/status 208), `test_a_default_install_unit_is_the_pre_phase_unit_minus_the_filter_lines` (whole pre-phase unit recorded verbatim as `PRE_PHASE_DEFAULT_UNIT` + `DEVICE_FILTER_LINES`, unified-diff failure output), `test_as_root_and_caps_write_the_kernel_gate_unit_lines` (recipe changes **exactly** two directives vs a default install; `--caps` alone refused before any privileged call; `--as-root` alone proceeds with the CapEff note), `test_a_re_run_that_differs_refuses_without_force_and_applies_with_it` (refuse→force→silent on one host).
|
||||||
|
- Mutation-checked both new filter pins (re-adding `DevicePolicy=strict`+`DeviceAllow=/dev/nvme*`, dropping `RestrictRealtime`, flipping `ProtectHome`) fail; script restored and `bash -n` clean.
|
||||||
|
- Docs: README's TLS rollout now restarts the service after `--force` and says why (installer uses `systemctl enable --now`, which only *starts*; `--status` picks http/https by reading the unit file); AGENTS.md gap 10 + the Deployment paragraph record task 04's read-only reproduction, the Oct-18 staged snapshot refusing the recipe with `unknown argument: --as-root`/exit 2, and the test count 1427→1451.
|
||||||
|
|
||||||
|
**Tests / lint** (`uv run`, exact): `bash .agents/validate.sh` → **exit 0** — `pytest --cov --cov-fail-under=94` **1451 passed, 15 skipped, coverage 98.94%**; `ruff check src tests agent` clean; `pyright` 0 errors; `node --check` + repo hygiene clean. `uv run pytest tests/test_deploy_scripts.py` → 63 passed, 0 failed. `uv run pytest -m live --live` → **11 passed, 0 skipped**; dev server all 9 sources `ok`.
|
||||||
|
|
||||||
|
**README/AGENTS changes:** as above (plus `data/logs/NOTES-17-installer-repro.md`, local/gitignored evidence).
|
||||||
|
|
||||||
|
**Decisions / deviations**
|
||||||
|
- "Byte-identical minus the ten filter lines" is compared on **directives** (comments are the one thing task 01 rewrote deliberately); the new prose is pinned separately for its mechanism sentences. Documented in the test.
|
||||||
|
- **No live change made.** `driveripper`'s installed unit was diffed (read-only, `systemctl cat`) against the sandbox render of `--bind 10.1.0.126 --port 9120 --as-root --caps CAP_SYS_RAWIO,CAP_SYS_ADMIN`: **every directive identical**, only comments differ; host `active`, `CapEff 0000000000220000`, 16/16 drives with temps. Running the real `--force` needs re-staging the sudo-granted Oct-18 snapshot (which lacks the flags, so it can't run them) and would push agent 1.1.0 to a prod host mid phase-15 rollout — left to the operator with the exact command, expected diff and post-checks in the note.
|
||||||
|
- `uv pip list --outdated` advisory (fastapi 0.153→0.155) left untouched, same handling as task 02; deps are not in this task's scope.
|
||||||
|
|
||||||
|
**Next pending task:** `.agents/phases/todo/18_nav_hamburger/01_hamburger_markup.md` (phase 17 has no other todo task).
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
ok
|
||||||
+65
@@ -0,0 +1,65 @@
|
|||||||
|
== uv run pytest --cov (hermetic: no network, live tests skip) ==
|
||||||
|
........................................................................ [ 4%]
|
||||||
|
........................................................................ [ 9%]
|
||||||
|
........................................................................ [ 14%]
|
||||||
|
........................................................................ [ 19%]
|
||||||
|
........................................................................ [ 24%]
|
||||||
|
...........................s............................................ [ 29%]
|
||||||
|
........................................................................ [ 34%]
|
||||||
|
........................................................................ [ 39%]
|
||||||
|
........................................................................ [ 44%]
|
||||||
|
........................................................................ [ 49%]
|
||||||
|
........................................................................ [ 54%]
|
||||||
|
........................................................................ [ 58%]
|
||||||
|
........................................................................ [ 63%]
|
||||||
|
................................................................ssssssss [ 68%]
|
||||||
|
sss..................................................................... [ 73%]
|
||||||
|
........................................................................ [ 78%]
|
||||||
|
........................................................................ [ 83%]
|
||||||
|
........................................................................ [ 88%]
|
||||||
|
........................................................................ [ 93%]
|
||||||
|
........................................sss............................. [ 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% 32
|
||||||
|
src/dashboards/alerts.py 176 0 100%
|
||||||
|
src/dashboards/api.py 436 0 100%
|
||||||
|
src/dashboards/app.py 185 0 100%
|
||||||
|
src/dashboards/auth.py 265 0 100%
|
||||||
|
src/dashboards/certs.py 292 0 100%
|
||||||
|
src/dashboards/collectors.py 205 5 98% 284-285, 289-290, 307
|
||||||
|
src/dashboards/config.py 120 0 100%
|
||||||
|
src/dashboards/debug.py 14 0 100%
|
||||||
|
src/dashboards/poller.py 165 3 98% 192-194, 279
|
||||||
|
src/dashboards/prom.py 112 5 96% 126, 163, 177, 180, 183
|
||||||
|
src/dashboards/rates.py 73 0 100%
|
||||||
|
src/dashboards/severity.py 49 0 100%
|
||||||
|
src/dashboards/sources/__init__.py 0 0 100%
|
||||||
|
src/dashboards/sources/disks.py 134 0 100%
|
||||||
|
src/dashboards/sources/host.py 209 0 100%
|
||||||
|
src/dashboards/sources/llm.py 178 1 99% 285
|
||||||
|
src/dashboards/sources/nut.py 413 20 95% 153, 172, 186, 198, 204, 217, 225, 241, 319-320, 322, 579, 591, 626, 657, 661, 718-719, 724, 736
|
||||||
|
src/dashboards/sources/nut_sim.py 134 0 100%
|
||||||
|
src/dashboards/store.py 313 2 99% 553-554
|
||||||
|
------------------------------------------------------------------
|
||||||
|
TOTAL 3490 37 99%
|
||||||
|
Required test coverage of 94% reached. Total coverage: 98.94%
|
||||||
|
1451 passed, 15 skipped, 1 warning in 154.14s (0:02:34)
|
||||||
|
== uv run ruff check src tests agent ==
|
||||||
|
All checks passed!
|
||||||
|
== uv run pyright ==
|
||||||
|
0 errors, 0 warnings, 0 informations
|
||||||
|
== node --check src/dashboards/web/static/*.js ==
|
||||||
|
advisory: dependencies are out of date (AGENTS.md rule 8 — update on purpose):
|
||||||
|
fastapi 0.153.0 0.155.0 wheel
|
||||||
+15
-4
@@ -10,6 +10,13 @@ DASH_PORT=8090
|
|||||||
DASH_DB=data/dashboards.sqlite3
|
DASH_DB=data/dashboards.sqlite3
|
||||||
# DASH_READ_ONLY=1 kills every mutating endpoint regardless of other flags
|
# DASH_READ_ONLY=1 kills every mutating endpoint regardless of other flags
|
||||||
DASH_READ_ONLY=0
|
DASH_READ_ONLY=0
|
||||||
|
# Where the seed file lives (default: .env next to the checkout). Set it when several installs
|
||||||
|
# share one box. A real environment variable always beats anything the file declares — the file
|
||||||
|
# seeds, it never overrides.
|
||||||
|
# DASH_ENV_FILE=/etc/dashboards/.env
|
||||||
|
# DASH_RELOAD=1 makes `uv run dashboards` watch the source and restart on a change — development
|
||||||
|
# only: every poll loop restarts with the process, so the charts stop for a second on each save.
|
||||||
|
# DASH_RELOAD=1
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
# Admin login (phase 11)
|
# Admin login (phase 11)
|
||||||
@@ -110,6 +117,10 @@ NUT_TIMEOUT_SECONDS=4
|
|||||||
# Instant commands (battery test, beeper, calibration) are the only write path in the
|
# Instant commands (battery test, beeper, calibration) are the only write path in the
|
||||||
# app. Requires NUT_USER plus this flag; the UI still confirms every command.
|
# app. Requires NUT_USER plus this flag; the UI still confirms every command.
|
||||||
NUT_ALLOW_ACTIONS=0
|
NUT_ALLOW_ACTIONS=0
|
||||||
|
# NUT_SIMULATE=1 replaces upsd with the labelled simulator (sources/nut_sim.py) so the UPS page
|
||||||
|
# is developable with no battery in the house. Every panel it produces says SIMULATED, and it is
|
||||||
|
# never the default: leave these two lines alone against a real upsd.
|
||||||
|
# NUT_SIMULATE=1
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
# History retention (raw -> 1m rollup -> 1h rollup)
|
# History retention (raw -> 1m rollup -> 1h rollup)
|
||||||
@@ -129,7 +140,7 @@ POLL_MS_UPS=5000
|
|||||||
DEBUGPY=0
|
DEBUGPY=0
|
||||||
DEBUGPY_PORT=5678
|
DEBUGPY_PORT=5678
|
||||||
|
|
||||||
# The host agent reads its OWN variables (AGENT_HOST, AGENT_PORT, AGENT_TOKEN,
|
# The host agent reads its OWN variables (AGENT_HOST, AGENT_PORT, AGENT_TOKEN, AGENT_TOKEN_FILE,
|
||||||
# AGENT_TOKEN_FILE). They belong on the monitored host — in the root-owned env/token files
|
# and AGENT_CERT_BUNDLE for the TLS bundle `--cert-bundle` installs). They belong on the monitored
|
||||||
# that deploy/install-agent.sh writes — not in this file: a dashboard config that everyone
|
# host — in the root-owned env/token/bundle files that deploy/install-agent.sh writes — not in this
|
||||||
# copies tends to end up with the fleet's tokens in it.
|
# file: a dashboard config that everyone copies tends to end up with the fleet's tokens in it.
|
||||||
|
|||||||
@@ -782,13 +782,18 @@ That scheme is the history API's key — treat it as a public interface.
|
|||||||
**`ok`** and the `live` suite is **11 passed / 0 failed** (it was 9/2 on the missing token that
|
**`ok`** and the `live` suite is **11 passed / 0 failed** (it was 9/2 on the missing token that
|
||||||
morning, and 8/3 on the ConnectError before that). The unit reads
|
morning, and 8/3 on the ConnectError before that). The unit reads
|
||||||
`--host 10.1.0.126 --port 9120 --token-file /etc/dashboards-agent.token` (`640
|
`--host 10.1.0.126 --port 9120 --token-file /etc/dashboards-agent.token` (`640
|
||||||
root:dashboards-agent`, the mode `deploy/install-agent.sh` writes) with `User=dashboards-agent`,
|
root:dashboards-agent`, the mode `deploy/install-agent.sh` writes) and is still in the `disk`
|
||||||
and `getent group disk` → `disk:x:6:dashboards-agent`.
|
group (`getent group disk` → `disk:x:6:dashboards-agent`) — but it does **not** run as that
|
||||||
**The deployed unit has since diverged from the template in the three ways the SMART
|
user: gap 10's hand-fix set `User=root`, and re-read from the host on 2026-10-20 the installed
|
||||||
block below records** (the `DevicePolicy`/`DeviceAllow` lines removed, `User=root`,
|
unit is `User=root` + `CapabilityBoundingSet=CAP_SYS_RAWIO CAP_SYS_ADMIN` with no device filter.
|
||||||
`CapabilityBoundingSet=CAP_SYS_RAWIO CAP_SYS_ADMIN`) — re-running `deploy/install-agent.sh`
|
**Those two extra lines are now template output, and the unit's rewrite is no longer
|
||||||
on driveripper would clobber the working unit, because it rewrites the unit unconditionally
|
silent**: phase 17 took the
|
||||||
(gap 10).
|
`DevicePolicy`/`DeviceAllow` lines out of the template (task 01), gave it `--as-root` and
|
||||||
|
`--caps` (task 02), and made a re-run whose generated unit differs from the installed one print
|
||||||
|
the diff and exit 1 unless `--force` was passed (task 03), so
|
||||||
|
`sudo deploy/install-agent.sh --bind 10.1.0.126 --port 9120 --as-root --caps CAP_SYS_RAWIO,CAP_SYS_ADMIN --force`
|
||||||
|
reproduces it and a re-run that would quietly replace a hand-fixed unit is now a refusal with a
|
||||||
|
diff attached rather than a clobber (gap 10).
|
||||||
`/api/health` today reads **all nine sources `ok`**: `host:driveripper`, `host:deskwork`,
|
`/api/health` today reads **all nine sources `ok`**: `host:driveripper`, `host:deskwork`,
|
||||||
`host:gateway`, `disks:driveripper`, `disks:deskwork`, `disks:gateway`, `nut`, `llm:gufo`,
|
`host:gateway`, `disks:driveripper`, `disks:deskwork`, `disks:gateway`, `nut`, `llm:gufo`,
|
||||||
`llm:vllm`.
|
`llm:vllm`.
|
||||||
@@ -857,17 +862,19 @@ That scheme is the history API's key — treat it as a public interface.
|
|||||||
`deskwork` grants `ops` neither the installer nor `visudo`, so its install went through root by
|
`deskwork` grants `ops` neither the installer nor `visudo`, so its install went through root by
|
||||||
the operator. **`driveripper` does grant the installer** — `(root) NOPASSWD:
|
the operator. **`driveripper` does grant the installer** — `(root) NOPASSWD:
|
||||||
/home/ops/agent-install/deploy/install-agent.sh`, an Oct-18 snapshot of the script staged under
|
/home/ops/agent-install/deploy/install-agent.sh`, an Oct-18 snapshot of the script staged under
|
||||||
the operator's home (`sudo -n -l`, re-checked 2026-10-20) — and that grant is the trap, not the
|
the operator's home (`sudo -n -l`, re-checked 2026-10-20) — and that grant is still the trap,
|
||||||
shortcut: the installer rewrites the unit unconditionally, so running it there today replaces the
|
not the shortcut: it points at that Oct-18 **snapshot**, which carries neither this phase's
|
||||||
`User=root` + `CapabilityBoundingSet` unit that gap 10 resolved with this repo's
|
flags, nor the filter removal, nor the re-run guard, so running the granted path replaces the
|
||||||
`User=dashboards-agent` + `DevicePolicy=strict` template and re-breaks all 16 SMART reads. The
|
`User=root` + `CapabilityBoundingSet` unit that gap 10 resolved with the snapshot's own
|
||||||
live unit is not the one this repo writes (no `DevicePolicy`/`DeviceAllow` lines at all —
|
`User=dashboards-agent`, capability-less unit and re-breaks all 16 SMART reads — **silently**,
|
||||||
systemd's default `auto` — `PrivateDevices=false`, and the two named capabilities).
|
because the guard that refuses a differing re-run (task 03) lives only in this repo's script,
|
||||||
Worth knowing before the next install: the allow-list `install-agent.sh`
|
which therefore has to be re-staged before any of it means anything there. The template now writes the live unit's shape: no device filter at all
|
||||||
does write (`/dev/nvme*`, `/dev/sd*`, `/dev/vd*`, `/dev/sg*`) has **no `/dev/ng*n*` and no
|
(phase 17 task 01 — systemd's default `auto`, with `PrivateDevices=false`) and `--as-root`
|
||||||
`/dev/bsg/*`**, the nodes smartctl 7.5 reaches for NVMe admin and SATA pass-through, so a
|
plus `--caps CAP_SYS_RAWIO,CAP_SYS_ADMIN` for the `User=root` and
|
||||||
`strict` unit there would deny exactly what a SMART read needs — whatever phase takes
|
`CapabilityBoundingSet=CAP_SYS_RAWIO CAP_SYS_ADMIN` lines (task 02). The old allow-list's
|
||||||
driveripper's SMART on has to settle the node list and the grant together.
|
missing nodes are no longer a concern for the next install: with no filter, `/dev/ng*n*` and
|
||||||
|
`/dev/bsg/*` are reachable the same way `/dev/nvme*` and `/dev/sd*` are, subject to DAC and
|
||||||
|
SELinux.
|
||||||
- **SMART: real captures, and one live host finally reads them.** `smartctl` 7.5 is installed on
|
- **SMART: real captures, and one live host finally reads them.** `smartctl` 7.5 is installed on
|
||||||
deskwork and driveripper. The `ops` account (which is who runs a hand-started dev agent) is in
|
deskwork and driveripper. The `ops` account (which is who runs a hand-started dev agent) is in
|
||||||
no `disk` group, driveripper grants it `(root) NOPASSWD: /usr/sbin/smartctl*` and deskwork
|
no `disk` group, driveripper grants it `(root) NOPASSWD: /usr/sbin/smartctl*` and deskwork
|
||||||
@@ -953,7 +960,7 @@ set -a; . ./.env.dev; set +a # dev dials the fleet the prod way, no
|
|||||||
./scripts/dev-server.sh status # per-source ok/DOWN — unauthenticated: /api/health
|
./scripts/dev-server.sh status # per-source ok/DOWN — unauthenticated: /api/health
|
||||||
./scripts/dev-server.sh logs
|
./scripts/dev-server.sh logs
|
||||||
|
|
||||||
uv run pytest # hermetic: 1427 passed + 15 skipped (11 live,
|
uv run pytest # hermetic: 1451 passed + 15 skipped (11 live,
|
||||||
# 4 store_perf), no network
|
# 4 store_perf), no network
|
||||||
uv run pytest --cov # coverage gate (98.94%; floor 94%)
|
uv run pytest --cov # coverage gate (98.94%; floor 94%)
|
||||||
uv run pytest -m live --live # opt-in: real fleet / engines / upsd
|
uv run pytest -m live --live # opt-in: real fleet / engines / upsd
|
||||||
@@ -993,23 +1000,42 @@ 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`,
|
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`
|
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`/
|
per monitored host (systemd unit, own user, `--bind`/`--port`/`--token`/`--rotate-token`/
|
||||||
`--cert-bundle`/`--status`/`--uninstall`; it stores the token `0640 root:dashboards-agent` — the
|
`--cert-bundle`/`--as-root`/`--caps`/`--force`/`--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
|
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, and a `--cert-bundle` file is installed to
|
to read it and no other account may, and a `--cert-bundle` file is installed to
|
||||||
`/etc/dashboards-agent.certs` at that same mode after a shape check that runs before anything is
|
`/etc/dashboards-agent.certs` at that same mode after a shape check that runs before anything is
|
||||||
touched and never dials the dashboard (the agent re-reads the bundle live for the denylist, so
|
touched and never dials the dashboard (the agent re-reads the bundle live for the denylist, so
|
||||||
0600 would fail the unit exactly like it fails the token file; the task file's `0600` is the
|
0600 would fail the unit exactly like it fails the token file; the task file's `0600` is the
|
||||||
intent — *root and the agent's own user only* — and 0640 is the mode that achieves it) — never
|
intent — *root and the agent's own user only* — and 0640 is the mode that achieves it) — never
|
||||||
puts a token on argv, and runs under `ProtectSystem=strict` with
|
puts a token on argv, and runs under `ProtectSystem=strict` with **no device
|
||||||
`DevicePolicy=strict` reopening only the disk nodes plus the `/dev/null` family, because strict
|
filter** — SMART device access is DAC via the `disk` group + SELinux, because kernel 7.2 matches
|
||||||
without `/dev/null` kills the unit at spawn (`status=208/STDIN`); a bundle-installed unit also
|
a device-filter glob by name against `/proc/devices` (where NVMe is `blkext`), so the old
|
||||||
gets `--cert-bundle` plus `RuntimeDirectory=dashboards-agent` (mode 0700 — the bundle unpacks its
|
allow-list denied the whole namespace; `PrivateDevices=false` stays explicit because a private
|
||||||
|
`/dev` with no device list kills the unit at spawn (`status=208/STDIN`). Where DAC is not enough,
|
||||||
|
`--as-root` writes `User=root` and `--caps CAP1,CAP2` writes that `CapabilityBoundingSet` line
|
||||||
|
(space-separated, as systemd spells it): kernel 7.2's unprivileged-passthrough gate checks
|
||||||
|
capabilities rather than uid, so `--as-root` alone is capability-less (`CapEff 0`) and prints a
|
||||||
|
note saying so, while `--caps` without `--as-root` is refused with exit 1 before `need_root` and
|
||||||
|
before any write. Both default off, and neither touches the token file's mode or the `--status`
|
||||||
|
probe. A bundle-installed
|
||||||
|
unit also gets `--cert-bundle` plus `RuntimeDirectory=dashboards-agent` (mode 0700 — the bundle unpacks its
|
||||||
PEM blocks there and the agent deletes the key once the SSL context holds it), keeps the
|
PEM blocks there and the agent deletes the key once the SSL context holds it), keeps the
|
||||||
`--token-file` line for a host-by-host rollout, and `--status` then probes `https` on the
|
`--token-file` line for a host-by-host rollout, and `--status` then probes `https` on the
|
||||||
bundle's CA with **no** client certificate via a `python3` `ssl` one-liner — task 02's
|
bundle's CA with **no** client certificate via a `python3` `ssl` one-liner — task 02's
|
||||||
`CERT_OPTIONAL` + `/health` exemption is what makes that probeable, and there is no external TLS
|
`CERT_OPTIONAL` + `/health` exemption is what makes that probeable, and there is no external TLS
|
||||||
binary. A re-run without the flag leaves an installed bundle in place, and `--uninstall` removes
|
binary. A re-run without the flag leaves an installed bundle in place, and `--uninstall` removes
|
||||||
it with the token. No root shell? The one sudoers
|
it with the token — and neither is guarded, because both are deliberate. What is guarded is the
|
||||||
|
unit itself (phase 17 task 03): it is rendered to a temp file first, and if an installed `$UNIT`
|
||||||
|
differs the run prints `refusing to overwrite …` plus the `diff -u` on stdout and exits 1 before
|
||||||
|
the unit write, the `daemon-reload` and the `enable`, so a redeploy cannot undo a hand-fix and an
|
||||||
|
identical re-run stays the silent zero-noise path. `--force` prints the same diff and applies it —
|
||||||
|
which is what the TLS rollout's `--cert-bundle` step needs on a token-mode host, because that step
|
||||||
|
*is* a unit change. What it applies is the **file**: `systemctl enable --now` only *starts* a unit,
|
||||||
|
so a run that rewrote `ExecStart` leaves the already-running agent on its old settings until
|
||||||
|
`systemctl restart dashboards-agent` — and `--status` chooses http from https by reading the unit
|
||||||
|
file, so probing before that restart reports "no answer" for a change that landed correctly (the
|
||||||
|
rollout in README restarts for exactly this reason). No root shell? The one sudoers
|
||||||
line that lets `ops` run the installer is `deploy/agent-sudoers`; run the script without root and
|
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.)
|
it prints that line with its own resolved path.)
|
||||||
The quadlet unit + env template + nginx/SNI live in the deployment repo, like every other
|
The quadlet unit + env template + nginx/SNI live in the deployment repo, like every other
|
||||||
@@ -1712,7 +1738,8 @@ memory plot; the `cpu.usage_pct@coreN` series is still stored and still queryabl
|
|||||||
would be a re-decision of that phase, not a bug fix. Do not "fix" the residual by hiding the
|
would be a re-decision of that phase, not a bug fix. Do not "fix" the residual by hiding the
|
||||||
short window or its label.
|
short window or its label.
|
||||||
10. **`driveripper`'s 16 drives read `n/a`: kernel 7.2's unprivileged-passthrough gate
|
10. **`driveripper`'s 16 drives read `n/a`: kernel 7.2's unprivileged-passthrough gate
|
||||||
(2026-10-19, resolved the same day — the installer template is what is left).**
|
(2026-10-19, resolved the same day; the installer template that shipped the broken device
|
||||||
|
filter was cleared in phase 17).**
|
||||||
The agent is installed, the `disk` group grant is in place, the block nodes open, and the
|
The agent is installed, the `disk` group grant is in place, the block nodes open, and the
|
||||||
page renders all 16 real drive rows — but kernel 7.2.11 blocks the *agent user's* `smartctl`
|
page renders all 16 real drive rows — but kernel 7.2.11 blocks the *agent user's* `smartctl`
|
||||||
at the command level (not the open; the probe matrix is in
|
at the command level (not the open; the probe matrix is in
|
||||||
@@ -1745,11 +1772,39 @@ memory plot; the `cpu.usage_pct@coreN` series is still stored and still queryabl
|
|||||||
`test_the_pass_budget_floor_is_twenty_seconds`,
|
`test_the_pass_budget_floor_is_twenty_seconds`,
|
||||||
`test_the_pass_budget_error_line_names_the_scaled_figure` — which demands the line read `80s`
|
`test_the_pass_budget_error_line_names_the_scaled_figure` — which demands the line read `80s`
|
||||||
for 16 disks, not the floor; the override test stays). Re-read live on 2026-10-20: the installed
|
for 16 disks, not the floor; the override test stays). Re-read live on 2026-10-20: the installed
|
||||||
agent answers 16/16 drives with temperatures and no `pass budget … exhausted` line. The
|
agent answers 16/16 drives with temperatures and no `pass budget … exhausted` line.
|
||||||
`deploy/install-agent.sh` template still ships the `DevicePolicy`/`DeviceAllow` lines that
|
The `deploy/install-agent.sh` template no longer ships the device-filter lines that
|
||||||
misbehaved on this host
|
misbehaved on this host — phase 17 task 01 removed them (the `nvme*` glob matches nothing
|
||||||
(a deny-all filter; the `nvme*` glob matches nothing on this kernel because
|
on this kernel because `/proc/devices` lists NVMe as `blkext`, and a strict device policy
|
||||||
`/proc/devices` lists NVMe as `blkext`) — the template change is its own phase.
|
denies everything a list fails to match), and the unit pins in `tests/test_deploy_scripts.py`
|
||||||
|
now assert their absence. Task 02 added the two flags that reproduce this host's remaining
|
||||||
|
lines — `--as-root` → `User=root`, `--caps CAP_SYS_RAWIO,CAP_SYS_ADMIN` →
|
||||||
|
`CapabilityBoundingSet=CAP_SYS_RAWIO CAP_SYS_ADMIN`, both off by default, `--caps` refused
|
||||||
|
without `--as-root`, `--as-root` alone accepted with the CapEff-0 note — pinned by
|
||||||
|
`tests/test_deploy_scripts.py` (exact `User=` / `CapabilityBoundingSet=` lines in a fake-root
|
||||||
|
sandbox, the refusal before any write, the `--help` recipe). Task 03 removed the unconditional
|
||||||
|
rewrite: the unit is rendered to a temp file, and a re-run whose render differs from the
|
||||||
|
installed unit prints the diff on stdout and exits 1 unless `--force` is passed, so the flag
|
||||||
|
line above now needs `--force` to land and a routine re-run can no longer clobber this host's
|
||||||
|
unit (pinned by the refuse/force/silent sequence in `tests/test_deploy_scripts.py`, including
|
||||||
|
the `--force` leg of this host's own recipe — the same
|
||||||
|
`--bind 10.1.0.126 --port 9120 --as-root --caps CAP_SYS_RAWIO,CAP_SYS_ADMIN --force` line the
|
||||||
|
fleet record above prints). Task 04 verified the reproduction **read-only** rather
|
||||||
|
than by running it: the unit installed on `driveripper` (`systemctl cat`, 2026-10-20) differs
|
||||||
|
from what this template renders with that exact flag line in **comment lines only** — every
|
||||||
|
directive matches, `User=root`, `CapabilityBoundingSet=CAP_SYS_RAWIO CAP_SYS_ADMIN`, no device
|
||||||
|
filter, `PrivateDevices=false`, the same `ExecStart` on the token file — and the host still
|
||||||
|
answers `active` with `CapEff: 0000000000220000` and 16/16 drives with temperatures in
|
||||||
|
`/snapshot`. So the operator's `--force` run lands the new rationale prose and changes nothing
|
||||||
|
systemd acts on (the three captures and the exact probe list are in
|
||||||
|
`data/logs/NOTES-17-installer-repro.md`; no live change was made while pinning this phase). Two facts that came out of reading the live host, both load-bearing for the
|
||||||
|
next install: the sudo-granted `/home/ops/agent-install` snapshot is the Oct-18 file (no
|
||||||
|
`--as-root`, no `--caps`, no `--force`, no `--cert-bundle`, still nine `DeviceAllow` lines) and
|
||||||
|
it **refuses** the recipe with `unknown argument: --as-root` and exit 2 before `need_root`, so
|
||||||
|
that path cannot apply this recipe at all until the repo's script is re-staged — while a
|
||||||
|
re-run of it *without* the flags is still the silent clobber the guard exists for. (The other
|
||||||
|
fact from that read — an applied change reaches the file but not the running process until
|
||||||
|
something restarts it — is in the Deployment paragraph, and README's rollout now restarts.)
|
||||||
`tests/fixtures/smartctl_driveripper_*.txt` (captured via `sudo`) remain the ground truth
|
`tests/fixtures/smartctl_driveripper_*.txt` (captured via `sudo`) remain the ground truth
|
||||||
for the drives' shape.
|
for the drives' shape.
|
||||||
11. **The CA is durable, the collector dials with it, and no bundle is installed with one**
|
11. **The CA is durable, the collector dials with it, and no bundle is installed with one**
|
||||||
@@ -1814,8 +1869,9 @@ memory plot; the `cpu.usage_pct@coreN` series is still stored and still queryabl
|
|||||||
place (`data/logs/verify-15-tls-e2e.py`, transcript `verify-15-tls-e2e.out`). What is left of
|
place (`data/logs/verify-15-tls-e2e.py`, transcript `verify-15-tls-e2e.out`). What is left of
|
||||||
the phase is the live rollout (07): the fleet's agents still run token mode, so `HOST_TARGETS`
|
the phase is the live rollout (07): the fleet's agents still run token mode, so `HOST_TARGETS`
|
||||||
stays `http://` here until somebody installs a bundle on a host — which needs root on that host
|
stays `http://` here until somebody installs a bundle on a host — which needs root on that host
|
||||||
(`ops` has no installer grant on `deskwork`, and `driveripper`'s unit must not be rewritten, gap
|
(`ops` has no installer grant on `deskwork`, and `driveripper`'s unit is reproducible only with
|
||||||
10), and flipping the URL before the bundle is installed turns that host red by construction.
|
phase 17's `--as-root --caps CAP_SYS_RAWIO,CAP_SYS_ADMIN --force` line, gap 10), and flipping
|
||||||
|
the URL before the bundle is installed turns that host red by construction.
|
||||||
Re-checked from the dev box the same day, and worth knowing before that rollout: all three
|
Re-checked from the dev box the same day, and worth knowing before that rollout: all three
|
||||||
agents answer `/health` with `version` **1.0.0** (`agent_version` is the `/snapshot` spelling),
|
agents answer `/health` with `version` **1.0.0** (`agent_version` is the `/snapshot` spelling),
|
||||||
so none of them carries task 02's TLS code either — the rollout ships the repo's
|
so none of them carries task 02's TLS code either — the rollout ships the repo's
|
||||||
@@ -1824,8 +1880,9 @@ memory plot; the `cpu.usage_pct@coreN` series is still stored and still queryabl
|
|||||||
exactly that TLS block; its `_smart_pass` and the phase-16 budget scale-up are already there.
|
exactly that TLS block; its `_smart_pass` and the phase-16 budget scale-up are already there.
|
||||||
Copying 1.1.0 into place is not an `install-agent.sh` re-run on that host (gap 10) — even
|
Copying 1.1.0 into place is not an `install-agent.sh` re-run on that host (gap 10) — even
|
||||||
though `ops` there is sudo-granted a staged copy of the installer (see the fleet record above),
|
though `ops` there is sudo-granted a staged copy of the installer (see the fleet record above),
|
||||||
which is the one route that must not be taken until phase 17 fixes the unit template — and no
|
which is the one route that must not be taken until that snapshot is re-staged from this repo:
|
||||||
live change was made while pinning phase 16.
|
the Oct-18 copy predates phase 17's template *and* its re-run guard, so it would rewrite that
|
||||||
|
unit silently — and no live change was made while pinning phase 16.
|
||||||
|
|
||||||
## Locked decisions (and why)
|
## Locked decisions (and why)
|
||||||
|
|
||||||
|
|||||||
@@ -81,10 +81,21 @@ python3 host_agent.py --host 0.0.0.0 --port 9120 --token-file /etc/dashboards-ag
|
|||||||
python3 host_agent.py --host 0.0.0.0 --port 9120 --cert-bundle /etc/dashboards-agent.certs
|
python3 host_agent.py --host 0.0.0.0 --port 9120 --cert-bundle /etc/dashboards-agent.certs
|
||||||
sudo deploy/install-agent.sh # systemd unit + user + token
|
sudo deploy/install-agent.sh # systemd unit + user + token
|
||||||
sudo deploy/install-agent.sh --cert-bundle agent.bundle # ...or the TLS install (bundle from /certs)
|
sudo deploy/install-agent.sh --cert-bundle agent.bundle # ...or the TLS install (bundle from /certs)
|
||||||
|
sudo deploy/install-agent.sh --as-root --caps CAP_SYS_RAWIO,CAP_SYS_ADMIN
|
||||||
|
# ...or a kernel-gated host (SMART passthrough, below)
|
||||||
|
sudo deploy/install-agent.sh --force # ...only when it must overwrite a unit it would change
|
||||||
deploy/install-agent.sh --status # unit state and /health (https when TLS)
|
deploy/install-agent.sh --status # unit state and /health (https when TLS)
|
||||||
sudo deploy/install-agent.sh --uninstall
|
sudo deploy/install-agent.sh --uninstall
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Re-running the installer is the silent, idempotent path it has always been **as long as the unit
|
||||||
|
it would write is the unit already on disk**. When the two differ the run stops: it prints
|
||||||
|
`refusing to overwrite …` with the unified diff on stdout and exits 1 before writing the unit,
|
||||||
|
before `daemon-reload` and before the service is re-enabled, so a routine redeploy can never undo
|
||||||
|
a fix somebody made by hand on the host (`--as-root`/`--caps` on a host already running the
|
||||||
|
dedicated user is exactly such a change, and installing a TLS bundle onto a token-mode host is
|
||||||
|
another). `--force` prints the same diff and then applies it.
|
||||||
|
|
||||||
No root shell on the monitored host? `deploy/agent-sudoers` is the single sudoers line that
|
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
|
lets the fleet's unprivileged `ops` account run the installer; run it without root and it
|
||||||
prints that exact line with the script's own resolved path instead of saying "run as root".
|
prints that exact line with the script's own resolved path instead of saying "run as root".
|
||||||
@@ -149,9 +160,30 @@ hostname, mounts, disk and GPU models, and process names. `deploy/install-agent.
|
|||||||
a system user, generates a token once (re-runs reuse it; `--rotate-token` for a new one),
|
a system user, generates a token once (re-runs reuse it; `--rotate-token` for a new one),
|
||||||
stores it `0640 root:dashboards-agent` — the unit hands the agent the *path*, and the agent
|
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 —
|
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
|
and runs the agent under `ProtectSystem=strict` with an empty `CapabilityBoundingSet` (unless
|
||||||
nodes and the `/dev/null` family reopened (SMART needs the former; without `/dev/null` in
|
`--caps` is asked for, below) and **no device filter**.
|
||||||
the allow list systemd cannot start the service at all), with no capabilities.
|
SMART device access is DAC: the installer adds the service user to the `disk` group and SELinux
|
||||||
|
stays in front of it. The strict device policy this script used to write is what locked
|
||||||
|
`driveripper`'s whole device namespace — systemd matches those globs by name against
|
||||||
|
`/proc/devices`, where kernel 7.2 lists NVMe as `blkext`, so `/dev/nvme*` matched nothing and a
|
||||||
|
strict policy denies whatever a list fails to match. `PrivateDevices=false` is still written out
|
||||||
|
explicitly, because a private `/dev` with no device list is what dies at spawn
|
||||||
|
(`status=208/STDIN`).
|
||||||
|
|
||||||
|
When DAC is not enough — a **kernel gate** — `--as-root` and `--caps` write the two unit lines
|
||||||
|
that gate checks accept. Kernel 7.2 gates smartctl's raw passthrough behind *capabilities*, not
|
||||||
|
uid: an NVMe SMART-log read through the block node needs `CAP_SYS_ADMIN` unless the drive's
|
||||||
|
firmware advertises the page, and SATA SMART behind a SAT layer is an ATA pass-through the
|
||||||
|
unprivileged `SG_IO` allow-list does not include (`CAP_SYS_RAWIO` is what the kernel names for
|
||||||
|
it). So `--as-root` writes `User=root` and `--caps CAP_SYS_RAWIO,CAP_SYS_ADMIN` writes
|
||||||
|
`CapabilityBoundingSet=CAP_SYS_RAWIO CAP_SYS_ADMIN` (comma-separated in, space-separated out);
|
||||||
|
both default off, because a host whose drives answer through the `disk` group needs neither.
|
||||||
|
`--caps` without `--as-root` is refused with exit 1 — a capability grant on the dedicated
|
||||||
|
service user is not the kernel-gate fix — and `--as-root` alone proceeds with a note, because a
|
||||||
|
root process under the default empty bounding set is capability-less (`CapEff 0`) and reads no
|
||||||
|
more than the unprivileged service user did. The token file, its `0640 root:dashboards-agent`
|
||||||
|
mode and the `--status` probe are deliberately unchanged by these flags: root reads the token
|
||||||
|
either way, and a probe of a port cannot see who owns the process behind it.
|
||||||
|
|
||||||
### Managing the certificates
|
### Managing the certificates
|
||||||
|
|
||||||
@@ -197,9 +229,12 @@ curl -s -H "Authorization: Bearer $SESSION" -o agent-deskwork.bundle \
|
|||||||
# 2. install it beside the token — as root on the host, 0640 root:dashboards-agent. The same run
|
# 2. install it beside the token — as root on the host, 0640 root:dashboards-agent. The same run
|
||||||
# copies the repo's `agent/host_agent.py`, which is how a pre-1.1.0 agent gains TLS mode at all
|
# copies the repo's `agent/host_agent.py`, which is how a pre-1.1.0 agent gains TLS mode at all
|
||||||
# (an older file does not know the `--cert-bundle` flag). The unit gains `--cert-bundle` and its
|
# (an older file does not know the `--cert-bundle` flag). The unit gains `--cert-bundle` and its
|
||||||
# RuntimeDirectory, and restarts into TLS mode.
|
# RuntimeDirectory — and then restart the service as root, because the installer's
|
||||||
|
# `systemctl enable --now` only *starts* a unit, so an already-running agent keeps serving its
|
||||||
|
# old mode until something restarts it.
|
||||||
scp agent-deskwork.bundle <host>-ops:/tmp/
|
scp agent-deskwork.bundle <host>-ops:/tmp/
|
||||||
ssh <host>-ops sudo /tmp/install-agent.sh --cert-bundle /tmp/agent-deskwork.bundle
|
ssh <host>-ops sudo /tmp/install-agent.sh --cert-bundle /tmp/agent-deskwork.bundle --force
|
||||||
|
ssh <host>-ops sudo systemctl restart dashboards-agent
|
||||||
ssh <host>-ops sudo /tmp/install-agent.sh --status # https, with the CA and no client cert
|
ssh <host>-ops sudo /tmp/install-agent.sh --status # https, with the CA and no client cert
|
||||||
|
|
||||||
# 3. point that host's HOST_TARGETS entry at https with NO userinfo token — the certificate is
|
# 3. point that host's HOST_TARGETS entry at https with NO userinfo token — the certificate is
|
||||||
@@ -207,6 +242,12 @@ ssh <host>-ops sudo /tmp/install-agent.sh --status # https, with the CA and no
|
|||||||
export HOST_TARGETS="deskwork=https://<addr>:9120 …"
|
export HOST_TARGETS="deskwork=https://<addr>:9120 …"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`--force` in step 2 is not a shortcut: installing a bundle *is* a change to that host's unit (the
|
||||||
|
`--cert-bundle` flag and its `RuntimeDirectory`), so without it the run prints the diff and stops
|
||||||
|
by design — read it, then re-run with the flag. The same rule covers every change the installer
|
||||||
|
applies, which is why the `--status` line above comes *after* the restart: the probe reads the
|
||||||
|
unit file to decide between http and https, so pointing it at a process that has not been
|
||||||
|
restarted yet reports "no answer" for a bundle that installed perfectly.
|
||||||
`install-agent.sh --bind … --port 9120` prints the `https://<addr>:9120` line for a bundle install,
|
`install-agent.sh --bind … --port 9120` prints the `https://<addr>:9120` line for a bundle install,
|
||||||
so step 3 is a copy rather than a guess. A host left on `http://` keeps working over its bearer
|
so step 3 is a copy rather than a guess. A host left on `http://` keeps working over its bearer
|
||||||
token — the rollout is per host, and one revoked or expired certificate never blinks the others.
|
token — the rollout is per host, and one revoked or expired certificate never blinks the others.
|
||||||
@@ -854,8 +895,10 @@ Instant commands are the only endpoint in the application that changes anything
|
|||||||
process** (`POST /api/ups/command`; the session and certificate-authority writes change only this
|
process** (`POST /api/ups/command`; the session and certificate-authority writes change only this
|
||||||
app's own rows), and they are gated four times: `NUT_ALLOW_ACTIONS=1`, not `DASH_READ_ONLY`, an `upsd.users`
|
app's own rows), and they are gated four times: `NUT_ALLOW_ACTIONS=1`, not `DASH_READ_ONLY`, an `upsd.users`
|
||||||
account that actually has the command, and a confirmation in the UI — destructive ones
|
account that actually has the command, and a confirmation in the UI — destructive ones
|
||||||
(deep test, calibration, `load.off`) require typing `DANGER`. The server re-checks every
|
(deep test, calibration, `load.off`) require typing `DANGER`. Two more sit in front of those
|
||||||
gate; hiding a button is not a safety control. Commands that would cut power are refused
|
(since the login landed): a session, and this session's CSRF token when the write arrived on the
|
||||||
|
cookie — additive, never a replacement, so the typed `DANGER` is still what cuts power. The server
|
||||||
|
re-checks every gate on every request; hiding a button is not a safety control. Commands that would cut power are refused
|
||||||
outright while the UPS is already on battery.
|
outright while the UPS is already on battery.
|
||||||
|
|
||||||
Give the app a narrowly scoped NUT user instead of a god-mode one — per-command `instcmds`,
|
Give the app a narrowly scoped NUT user instead of a god-mode one — per-command `instcmds`,
|
||||||
|
|||||||
+162
-30
@@ -4,8 +4,11 @@
|
|||||||
# sudo ./deploy/install-agent.sh # loopback only (read it locally)
|
# 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 --bind 10.0.0.5 # serve the LAN, token required
|
||||||
# sudo ./deploy/install-agent.sh --cert-bundle agent.bundle # serve TLS (phase 15)
|
# sudo ./deploy/install-agent.sh --cert-bundle agent.bundle # serve TLS (phase 15)
|
||||||
|
# sudo ./deploy/install-agent.sh --as-root --caps CAP_SYS_RAWIO,CAP_SYS_ADMIN
|
||||||
|
# # a kernel-gated host (see below)
|
||||||
# ./deploy/install-agent.sh --status # unit state + /health, no root
|
# ./deploy/install-agent.sh --status # unit state + /health, no root
|
||||||
# sudo ./deploy/install-agent.sh --uninstall
|
# sudo ./deploy/install-agent.sh --uninstall
|
||||||
|
# sudo ./deploy/install-agent.sh --force # re-apply over a unit this would change
|
||||||
#
|
#
|
||||||
# No root shell? `deploy/agent-sudoers` is the single sudoers line that lets the fleet's
|
# No root shell? `deploy/agent-sudoers` is the single sudoers line that lets the fleet's
|
||||||
# unprivileged `ops` account run this script as root. Run it without root and it prints that
|
# unprivileged `ops` account run this script as root. Run it without root and it prints that
|
||||||
@@ -32,17 +35,56 @@
|
|||||||
# the agent exempts for probes. Re-running without the flag leaves an installed bundle in
|
# the agent exempts for probes. Re-running without the flag leaves an installed bundle in
|
||||||
# place, the token-reuse idiom.
|
# place, the token-reuse idiom.
|
||||||
#
|
#
|
||||||
# The agent is read-only by construction (it answers GET and writes nothing), runs as its
|
# The agent is read-only by construction (it answers GET and writes nothing), runs as its own
|
||||||
# own system user, and gets the usual systemd lockdown. It still needs to read /proc, /sys
|
# system user (unless --as-root says otherwise, below), and gets the usual systemd lockdown. It
|
||||||
# and statvfs(), which the hardening below leaves alone.
|
# still needs to read /proc, /sys and statvfs(), which the hardening below leaves alone.
|
||||||
#
|
#
|
||||||
# Disk health (`smartctl -i -A -H`, the `/disks` page) is the one add-on that needs more
|
# Disk health (`smartctl -i -A -H`, the `/disks` page) is the one add-on that needs more
|
||||||
# than /proc and /sys: smartctl must open the disk device nodes. The installer therefore
|
# than /proc and /sys: smartctl must open the disk device nodes. The installer therefore
|
||||||
# adds the agent user to the `disk` group when one exists, and the unit device-allows the
|
# adds the agent user to the `disk` group when one exists, and the unit applies no device
|
||||||
# disk nodes instead of PrivateDevices. This is raw *read* access to the disks — the
|
# filter at all: kernel 7.2 matches a device-filter glob by name against /proc/devices, where
|
||||||
# standard arrangement for SMART monitoring, granted deliberately — and its absence is a
|
# NVMe is listed as `blkext`, so the old `/dev/nvme*` pattern denied the whole device
|
||||||
# documented degradation, never an install failure: no smartmontools (or no group) simply
|
# namespace instead of opening four nodes. Device access is DAC via the `disk` group, with
|
||||||
# means every disk reports `smart: null` with an explanatory error entry.
|
# SELinux still in front of it — the standard arrangement for SMART monitoring, granted
|
||||||
|
# deliberately. Its absence is a documented degradation, never an install failure: no
|
||||||
|
# smartmontools (or no group) simply means every disk reports `smart: null` with an
|
||||||
|
# explanatory error entry.
|
||||||
|
#
|
||||||
|
# A kernel gate is a different grant, and --as-root / --caps are how the unit gets it. Kernel
|
||||||
|
# 7.2 gates smartctl's raw passthrough behind *capabilities*, not uid: an NVMe SMART-log read
|
||||||
|
# through the block node needs CAP_SYS_ADMIN unless the drive's firmware advertises the page,
|
||||||
|
# and SATA SMART behind a SAT layer is an ATA pass-through the unprivileged SG_IO allow-list
|
||||||
|
# does not include (CAP_SYS_RAWIO is what the kernel names for that). Those checks run against
|
||||||
|
# the process's capabilities, so a root service under the default empty CapabilityBoundingSet
|
||||||
|
# is capability-less (CapEff 0) and reads nothing — which is why the two flags belong together.
|
||||||
|
#
|
||||||
|
# --as-root run the service as root (for a kernel-gated host: kernel 7.2 gates
|
||||||
|
# unprivileged smartctl passthrough behind capabilities, not uid — a root
|
||||||
|
# process under the default empty bounding set is capability-less, so this
|
||||||
|
# flag needs --caps to mean anything)
|
||||||
|
# --caps LIST CapabilityBoundingSet line for the unit (comma-separated, e.g.
|
||||||
|
# CAP_SYS_RAWIO,CAP_SYS_ADMIN); default: empty (capability-less service)
|
||||||
|
#
|
||||||
|
# Both default off, because a host whose drives answer through the disk group needs neither.
|
||||||
|
# --caps without --as-root is refused (exit 1): a capability grant on the dedicated service
|
||||||
|
# user is not the kernel-gate fix. --as-root alone proceeds with a note, since a
|
||||||
|
# capability-less root is a legitimate if unusual choice. The recipe for the host this exists
|
||||||
|
# for — 16 drives, NVMe firmware without the SMART-log page and SATA behind SAT — is:
|
||||||
|
#
|
||||||
|
# sudo ./deploy/install-agent.sh --bind <addr> --port 9120 \
|
||||||
|
# --as-root --caps CAP_SYS_RAWIO,CAP_SYS_ADMIN
|
||||||
|
#
|
||||||
|
# A re-run is the zero-noise path it has always been as long as the unit this script would
|
||||||
|
# write is the unit already on disk. When the two differ the run stops: the diff between the
|
||||||
|
# installed unit and the generated one is printed and the exit code is 1, before the unit is
|
||||||
|
# written, before daemon-reload and before the service is re-enabled — so a routine redeploy
|
||||||
|
# cannot reset a unit somebody fixed by hand. driveripper's root + CapabilityBoundingSet unit
|
||||||
|
# is exactly that hand-fixed case (and the flags above are what reproduce it), which is why
|
||||||
|
# the refusal, not the overwrite, is the default. --force applies the change, with the same
|
||||||
|
# diff printed first so the operator sees what they are committing to.
|
||||||
|
#
|
||||||
|
# --force overwrite an existing unit even if the generated unit differs (prints the
|
||||||
|
# diff first)
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
SERVICE="dashboards-agent"
|
SERVICE="dashboards-agent"
|
||||||
@@ -69,6 +111,14 @@ PORT="9120"
|
|||||||
TOKEN="${AGENT_TOKEN:-}"
|
TOKEN="${AGENT_TOKEN:-}"
|
||||||
ROTATE=0
|
ROTATE=0
|
||||||
BUNDLE_SRC=""
|
BUNDLE_SRC=""
|
||||||
|
# --as-root / --caps: the kernel-gate variant of the unit (phase 17 task 02). Both off means
|
||||||
|
# the default install — dedicated user, empty CapabilityBoundingSet — byte-for-byte what this
|
||||||
|
# script wrote before the flags existed.
|
||||||
|
AS_ROOT=0
|
||||||
|
CAPS=""
|
||||||
|
# --force: the re-run guard's opt-in (phase 17 task 03). 0 means a re-run whose generated unit
|
||||||
|
# differs from the installed one refuses after printing the diff.
|
||||||
|
FORCE=0
|
||||||
MODE="install"
|
MODE="install"
|
||||||
|
|
||||||
usage() {
|
usage() {
|
||||||
@@ -84,6 +134,22 @@ while [ $# -gt 0 ]; do
|
|||||||
--token) TOKEN="$2"; shift 2 ;;
|
--token) TOKEN="$2"; shift 2 ;;
|
||||||
--rotate-token) ROTATE=1; shift ;;
|
--rotate-token) ROTATE=1; shift ;;
|
||||||
--cert-bundle) BUNDLE_SRC="$2"; shift 2 ;;
|
--cert-bundle) BUNDLE_SRC="$2"; shift 2 ;;
|
||||||
|
--as-root) AS_ROOT=1; shift ;;
|
||||||
|
--caps)
|
||||||
|
[ $# -ge 2 ] || {
|
||||||
|
echo "--caps needs a capability list (e.g. --caps CAP_SYS_RAWIO,CAP_SYS_ADMIN)" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
# An empty list is a typo, not "the default": the operator typed the flag, so refusing
|
||||||
|
# is kinder than writing the capability-less unit they did not ask for.
|
||||||
|
if [ -z "$(printf '%s' "$2" | tr -d '[:space:],')" ]; then
|
||||||
|
echo "--caps needs at least one capability name (e.g. --caps CAP_SYS_RAWIO,CAP_SYS_ADMIN)" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
CAPS="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--force) FORCE=1; shift ;;
|
||||||
--status) MODE="status"; shift ;;
|
--status) MODE="status"; shift ;;
|
||||||
--uninstall) MODE="uninstall"; shift ;;
|
--uninstall) MODE="uninstall"; shift ;;
|
||||||
-h|--help) usage 0 ;;
|
-h|--help) usage 0 ;;
|
||||||
@@ -91,6 +157,16 @@ while [ $# -gt 0 ]; do
|
|||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
|
||||||
|
# Refused here, before need_root and before any write, for the bundle validator's reason: a
|
||||||
|
# wrong flag combination is answered with its own sentence rather than sending the operator to
|
||||||
|
# add a sudoers line for a mistake in the flags. --caps on the dedicated service user is not
|
||||||
|
# the kernel-gate fix — the gate checks the process's capabilities, and a non-root user cannot
|
||||||
|
# hold them across exec — so the grant only means anything on a root service.
|
||||||
|
if [ -n "$CAPS" ] && [ "$AS_ROOT" -ne 1 ]; then
|
||||||
|
echo "--caps needs --as-root: a capability grant on the service user is not the kernel-gate fix" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
need_root() {
|
need_root() {
|
||||||
if [ "$(id -u)" -ne 0 ]; then
|
if [ "$(id -u)" -ne 0 ]; then
|
||||||
# The dev workflow deploys over ssh as the unprivileged `ops` account, so "run as root"
|
# The dev workflow deploys over ssh as the unprivileged `ops` account, so "run as root"
|
||||||
@@ -327,7 +403,38 @@ RuntimeDirectory=dashboards-agent
|
|||||||
RuntimeDirectoryMode=0700"
|
RuntimeDirectoryMode=0700"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
cat > "$UNIT" <<UNIT_FILE
|
# The kernel-gate variant (--as-root / --caps). Default: the dedicated user and an empty
|
||||||
|
# CapabilityBoundingSet, i.e. the capability-less unit this script has always written.
|
||||||
|
UNIT_USER="$AGENT_USER"
|
||||||
|
if [ "$AS_ROOT" -eq 1 ]; then
|
||||||
|
UNIT_USER="root"
|
||||||
|
fi
|
||||||
|
# systemd wants the capabilities space-separated; the flag takes the comma-separated form
|
||||||
|
# that reads like a list on a command line. Empty entries are dropped so `A,,B` cannot put
|
||||||
|
# a double space in the unit file.
|
||||||
|
CAPS_LINE=""
|
||||||
|
set -f # a capability name is a word, never a glob: keep `*` out of the unit file
|
||||||
|
for _cap in $(printf '%s' "$CAPS" | tr ',' ' '); do
|
||||||
|
CAPS_LINE="${CAPS_LINE:+$CAPS_LINE }$_cap"
|
||||||
|
done
|
||||||
|
set +f
|
||||||
|
if [ "$AS_ROOT" -eq 1 ] && [ -z "$CAPS_LINE" ]; then
|
||||||
|
# Not a refusal: a capability-less root is a legitimate if unusual choice, and the operator
|
||||||
|
# may have a reason this script cannot see. The note is the reminder gap 10 paid for — the
|
||||||
|
# first root-user attempt on the kernel-7.2 host still read n/a on all 16 drives, and this
|
||||||
|
# empty bounding set was the only thing that had changed.
|
||||||
|
echo "note: --as-root with the default empty CapabilityBoundingSet leaves the process" \
|
||||||
|
"capability-less (the kernel gates check capabilities, not uid); pair it with --caps" \
|
||||||
|
"for a kernel-gated host"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# The unit is rendered to a temp file rather than straight to $UNIT so the guard below can
|
||||||
|
# compare the guard's input with what would actually be written — byte for byte, with every
|
||||||
|
# flag, bundle and capability decision already applied, not a model of them. The prefix is so
|
||||||
|
# the +++ header of the diff the guard prints reads as the generated unit and not as an
|
||||||
|
# anonymous /tmp file.
|
||||||
|
TMP_UNIT="$(mktemp "${TMPDIR:-/tmp}/dashboards-agent-unit.XXXXXX")"
|
||||||
|
cat > "$TMP_UNIT" <<UNIT_FILE
|
||||||
[Unit]
|
[Unit]
|
||||||
Description=Homelab host agent (read-only system metrics)
|
Description=Homelab host agent (read-only system metrics)
|
||||||
After=network-online.target
|
After=network-online.target
|
||||||
@@ -335,7 +442,7 @@ Wants=network-online.target
|
|||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=simple
|
Type=simple
|
||||||
User=$AGENT_USER
|
User=$UNIT_USER
|
||||||
ExecStart=/usr/bin/env python3 $AGENT_TARGET --host $BIND --port $PORT --token-file ${TOKEN_FILE}${TLS_EXEC_ARGS}
|
ExecStart=/usr/bin/env python3 $AGENT_TARGET --host $BIND --port $PORT --token-file ${TOKEN_FILE}${TLS_EXEC_ARGS}
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=5
|
RestartSec=5
|
||||||
@@ -343,29 +450,26 @@ Environment=PYTHONUNBUFFERED=1
|
|||||||
SyslogIdentifier=$SERVICE
|
SyslogIdentifier=$SERVICE
|
||||||
$TLS_UNIT_LINES
|
$TLS_UNIT_LINES
|
||||||
# The agent reads /proc, /sys and statvfs() and writes nothing, so everything writable can
|
# The agent reads /proc, /sys and statvfs() and writes nothing, so everything writable can
|
||||||
# be taken away. smartctl is the one thing that needs a real /dev node, so instead of
|
# be taken away. smartctl is the one thing that needs a real /dev node, and it gets one the
|
||||||
# PrivateDevices the unit closes all devices and reopens only the disk nodes, read/write
|
# way every SMART monitor on Linux does: DAC — the installer adds the service user to the
|
||||||
# at the cgroup layer while the code (and smartctl -i -A -H) stays read-only by design.
|
# disk group — with SELinux still in front of it. The unit therefore carries no device
|
||||||
|
# filter of its own, and 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
|
# No filter is the fix, not an omission. systemd resolves a device-filter glob by name
|
||||||
# listed, and systemd needs /dev/null for the service's stdin before it ever execs python —
|
# against /proc/devices (fnmatch in bpf_devices_allow_list_major), and kernel 7.2 lists NVMe
|
||||||
# without it the unit dies at spawn with "Failed at step STDIN" (status 208), which is how
|
# there as blkext (major 259) rather than nvme, so the old /dev/nvme* glob matched nothing —
|
||||||
# the first phase-10 install failed on a live host running systemd 257. /dev/zero, full,
|
# and a strict device policy denies every node the list failed to match, so on driveripper
|
||||||
# random and urandom are the rest of the set a PrivateDevices= sandbox would have provided,
|
# that unit locked the whole device namespace, root included
|
||||||
# and none of them is a disk.
|
# (data/logs/NOTES-driveripper-agent.md). A glob that silently matches nothing is the failure
|
||||||
|
# mode; having no filter has none, and it is what the live hosts run today.
|
||||||
|
#
|
||||||
|
# PrivateDevices=false is the systemd default and is written out for one reason: a private
|
||||||
|
# /dev with no device list is what dies at spawn with "Failed at step STDIN" (status 208),
|
||||||
|
# because systemd opens /dev/null for the service stdin before it ever execs python — that is
|
||||||
|
# how the first phase-10 install failed on a live host running systemd 257.
|
||||||
NoNewPrivileges=true
|
NoNewPrivileges=true
|
||||||
PrivateTmp=true
|
PrivateTmp=true
|
||||||
PrivateDevices=false
|
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
|
|
||||||
DeviceAllow=/dev/sg* rw
|
|
||||||
ProtectSystem=strict
|
ProtectSystem=strict
|
||||||
ProtectHome=read-only
|
ProtectHome=read-only
|
||||||
ProtectKernelTunables=true
|
ProtectKernelTunables=true
|
||||||
@@ -376,13 +480,41 @@ RestrictNamespaces=true
|
|||||||
RestrictRealtime=true
|
RestrictRealtime=true
|
||||||
LockPersonality=true
|
LockPersonality=true
|
||||||
MemoryDenyWriteExecute=true
|
MemoryDenyWriteExecute=true
|
||||||
CapabilityBoundingSet=
|
# --caps: kernel 7.2 gates unprivileged smartctl passthrough (NVMe admin log: CAP_SYS_ADMIN;
|
||||||
|
# SG_IO allow-list: CAP_SYS_RAWIO) and those checks look at capabilities, not uid — so a root
|
||||||
|
# service needs them named here, because an empty bounding set bounds even root to CapEff 0.
|
||||||
|
CapabilityBoundingSet=$CAPS_LINE
|
||||||
SystemCallFilter=@system-service
|
SystemCallFilter=@system-service
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
WantedBy=multi-user.target
|
WantedBy=multi-user.target
|
||||||
UNIT_FILE
|
UNIT_FILE
|
||||||
|
|
||||||
|
# No silent clobber (phase 17 task 03). $UNIT is the operator's file as much as this
|
||||||
|
# template's output: it gets edited on the host to fix something the template did not know
|
||||||
|
# (driveripper: User=root plus the two capabilities the kernel-7.2 passthrough gate needs),
|
||||||
|
# and an unconditional rewrite would undo that fix while looking like a routine redeploy. So
|
||||||
|
# a re-run whose unit differs stops, with the diff on stdout so the difference is readable
|
||||||
|
# rather than guessed, and --force is the opt-in to apply it anyway. The diff is printed on
|
||||||
|
# both paths and on stdout, not stderr: it is the payload of the answer, and splitting it
|
||||||
|
# across two streams interleaves it with the sentence above it. The writes before this point
|
||||||
|
# are the idempotent ones — the service user, its disk-group membership, the agent file, and
|
||||||
|
# the token/bundle reuse, which re-reads the existing secret instead of replacing it — and a
|
||||||
|
# refusal does nothing after it: no unit write, no daemon-reload, no enable, so the unit on
|
||||||
|
# disk and the service running against it stay exactly as they were.
|
||||||
|
if [ -f "$UNIT" ] && ! cmp -s "$UNIT" "$TMP_UNIT"; then
|
||||||
|
if [ "$FORCE" -eq 1 ]; then
|
||||||
|
diff -u "$UNIT" "$TMP_UNIT" || true
|
||||||
|
else
|
||||||
|
echo "refusing to overwrite $UNIT (generated unit differs); run with --force to apply:"
|
||||||
|
diff -u "$UNIT" "$TMP_UNIT" || true
|
||||||
|
rm -f "$TMP_UNIT"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
cat "$TMP_UNIT" > "$UNIT"
|
||||||
|
rm -f "$TMP_UNIT"
|
||||||
|
|
||||||
systemctl daemon-reload
|
systemctl daemon-reload
|
||||||
systemctl enable --now "$SERVICE"
|
systemctl enable --now "$SERVICE"
|
||||||
sleep 1
|
sleep 1
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ apart from the path the script was invoked through.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import difflib
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
@@ -372,11 +373,22 @@ def test_install_path_installs_into_its_own_paths_and_prints_the_token_once(tmp_
|
|||||||
# the bundle unpacks into (the agent falls back to a private mkdtemp without one).
|
# the bundle unpacks into (the agent falls back to a private mkdtemp without one).
|
||||||
assert "--cert-bundle" not in unit, "no bundle installed means no TLS flag on a token install"
|
assert "--cert-bundle" not in unit, "no bundle installed means no TLS flag on a token install"
|
||||||
assert "RuntimeDirectory=dashboards-agent" not in unit
|
assert "RuntimeDirectory=dashboards-agent" not in unit
|
||||||
# DevicePolicy=strict denies every node not listed, and systemd opens /dev/null for stdin
|
# No device filter, on purpose (phase 17): a strict device policy denies every node the
|
||||||
# before it execs python: without this line the unit dies at spawn (status 208/STDIN).
|
# allow-list fails to match, and systemd matches those globs by *name* against
|
||||||
assert "DevicePolicy=strict" in unit
|
# /proc/devices — where kernel 7.2 lists NVMe as `blkext`, so the old `/dev/nvme*` entry
|
||||||
assert "DeviceAllow=/dev/null" in unit, "strict device policy needs /dev/null or the unit cannot start"
|
# locked the whole device namespace on driveripper. Device access is DAC (the `disk` group
|
||||||
assert "DeviceAllow=/dev/nvme*" in unit, "SMART reads need the disk nodes back"
|
# the installer grants) plus SELinux, the standard SMART-monitoring arrangement.
|
||||||
|
assert "DevicePolicy" not in unit, (
|
||||||
|
"a strict device policy with a name-matched allow-list is the deny-all device filter "
|
||||||
|
"that locked driveripper's SMART reads — the unit must carry none"
|
||||||
|
)
|
||||||
|
assert "DeviceAllow" not in unit, "no device filter means no allow-list either"
|
||||||
|
# PrivateDevices=false survives as the explicit statement of the systemd default: a
|
||||||
|
# private /dev with no device list dies at spawn (status 208/STDIN), because systemd
|
||||||
|
# opens /dev/null for the service stdin before it execs python.
|
||||||
|
assert "PrivateDevices=false" in unit, (
|
||||||
|
"the explicit default is what keeps a private-/dev sandbox from dying at STDIN"
|
||||||
|
)
|
||||||
assert token and len(token) >= 32, "a generated token is expected"
|
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 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
|
assert f'HOST_TARGETS="hostname=http://{token}@10.0.0.5:9120"' in result.stdout
|
||||||
@@ -387,6 +399,473 @@ def test_install_path_installs_into_its_own_paths_and_prints_the_token_once(tmp_
|
|||||||
assert "chown root:dashboards-agent" in calls, "the token file must become group-readable"
|
assert "chown root:dashboards-agent" in calls, "the token file must become group-readable"
|
||||||
|
|
||||||
|
|
||||||
|
#: A line the unit heredoc may legitimately write: a comment, a `[Section]` header, or a
|
||||||
|
#: systemd directive assignment. Anything else is the unquoted heredoc having *expanded*
|
||||||
|
#: something — see `test_the_written_unit_is_only_comments_and_directives`.
|
||||||
|
_UNIT_LINE_RE = re.compile(r"^(?:#|\[[A-Za-z]+\]|[A-Za-z]+=)")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(hasattr(os, "geteuid") and os.geteuid() == 0, reason="running as root")
|
||||||
|
def test_the_written_unit_is_only_comments_and_directives(tmp_path: Path) -> None:
|
||||||
|
"""The unit heredoc is unquoted so `$TLS_UNIT_LINES` and `$TLS_EXEC_ARGS` can expand.
|
||||||
|
|
||||||
|
That is also what executes its **comments**: writing the phase-17 rationale with backticks
|
||||||
|
around `disk`, `blkext` and `/dev/nvme*` made the install run them (`disk: command not
|
||||||
|
found`, smartctl's version banner) and paste the output into the unit file, with `bash -n`
|
||||||
|
and every text assertion still happy. Quote the heredoc and phase 15's interpolated lines
|
||||||
|
stop working, so the guard is on the *result*: a unit line is a comment, a section header,
|
||||||
|
or a directive assignment — never the output of something the prose mentioned.
|
||||||
|
"""
|
||||||
|
script, _call_log, env = _sandbox_installer(tmp_path)
|
||||||
|
result = subprocess.run(
|
||||||
|
[BASH, str(script), "--bind", "127.0.0.1", "--port", "9199"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
env=env,
|
||||||
|
timeout=60,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
assert result.returncode == 0, f"install failed:\n{result.stdout}\n{result.stderr}"
|
||||||
|
unit = (tmp_path / "target" / "dashboards-agent.service").read_text()
|
||||||
|
offenders = [line for line in unit.splitlines() if line.strip() and not _UNIT_LINE_RE.match(line)]
|
||||||
|
assert not offenders, "the unit heredoc expanded its own text into the unit file:\n" + "\n".join(
|
||||||
|
offenders
|
||||||
|
)
|
||||||
|
assert result.stderr == "", "installing must not execute anything the unit's comments mention"
|
||||||
|
|
||||||
|
|
||||||
|
# --- the template's unit: no device filter, and the whole unit pinned (phase 17 task 04) ------
|
||||||
|
|
||||||
|
#: The ten lines the pre-phase template wrote between ``PrivateDevices=false`` and
|
||||||
|
#: ``ProtectSystem=strict`` — the device filter phase 17 removes. Pinned as the *old* text, in
|
||||||
|
#: order, because the fix is the absence of exactly these lines: re-adding
|
||||||
|
#: ``DeviceAllow=/dev/nvme* rw`` with ``DevicePolicy=strict`` is the deny-all returning, and a
|
||||||
|
#: test that only asserted "`DevicePolicy` is absent today" would pass while somebody added
|
||||||
|
#: the allow-list back under a default `auto` policy that ignores it.
|
||||||
|
DEVICE_FILTER_LINES = (
|
||||||
|
"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",
|
||||||
|
"DeviceAllow=/dev/sg* rw",
|
||||||
|
)
|
||||||
|
|
||||||
|
#: The unit the template wrote **before** phase 17, rendered for a default token install
|
||||||
|
#: (bind `10.0.0.5`, port `9120`, no bundle), with the sandbox root spelled `<ROOT>`.
|
||||||
|
#:
|
||||||
|
#: This is the whole unit and not a highlight reel: it is the reference the default install is
|
||||||
|
#: compared against, so a template line added, dropped, reordered or reworded *outside* this
|
||||||
|
#: phase's remit fails a test instead of landing in the next host's service file. The comment
|
||||||
|
#: block is included verbatim (it is what the operator reads in `systemctl cat`) even though
|
||||||
|
#: phase 17 task 01 rewrote that prose on purpose — which is why the comparison below reads
|
||||||
|
#: directives, and the new rationale is pinned separately for the two facts it must carry.
|
||||||
|
PRE_PHASE_DEFAULT_UNIT = """[Unit]
|
||||||
|
Description=Homelab host agent (read-only system metrics)
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=dashboards-agent
|
||||||
|
ExecStart=/usr/bin/env python3 <ROOT>/target/lib/host_agent.py --host 10.0.0.5 --port 9120 --token-file <ROOT>/target/token
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
Environment=PYTHONUNBUFFERED=1
|
||||||
|
SyslogIdentifier=dashboards-agent
|
||||||
|
|
||||||
|
# The agent reads /proc, /sys and statvfs() and writes nothing, so everything writable can
|
||||||
|
# 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
|
||||||
|
DeviceAllow=/dev/sg* rw
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=read-only
|
||||||
|
ProtectKernelTunables=true
|
||||||
|
ProtectKernelModules=true
|
||||||
|
ProtectControlGroups=true
|
||||||
|
RestrictSUIDSGID=true
|
||||||
|
RestrictNamespaces=true
|
||||||
|
RestrictRealtime=true
|
||||||
|
LockPersonality=true
|
||||||
|
MemoryDenyWriteExecute=true
|
||||||
|
CapabilityBoundingSet=
|
||||||
|
SystemCallFilter=@system-service
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _unit_heredoc() -> str:
|
||||||
|
"""The template's unit heredoc, exactly as the script holds it.
|
||||||
|
|
||||||
|
Scoped on purpose: the rest of the script is prose about flags, and a pin that read the
|
||||||
|
whole file for `DeviceAllow` would be satisfied by deleting a sentence rather than by the
|
||||||
|
absence of a *unit line*.
|
||||||
|
"""
|
||||||
|
text = INSTALLER.read_text()
|
||||||
|
assert "<<UNIT_FILE" in text, "the unit is still written from a heredoc"
|
||||||
|
heredoc = text.split("<<UNIT_FILE\n", 1)[1].split("\nUNIT_FILE\n", 1)[0]
|
||||||
|
assert "[Service]" in heredoc and "CapabilityBoundingSet" in heredoc, (
|
||||||
|
"the heredoc region extracted from the script is not the unit"
|
||||||
|
)
|
||||||
|
return heredoc
|
||||||
|
|
||||||
|
|
||||||
|
def _unit_directives(unit: str) -> list[str]:
|
||||||
|
"""The lines systemd acts on — every line that is neither a comment nor blank.
|
||||||
|
|
||||||
|
Comments carry the phase's reasoning and *are* part of the file, but they are also the one
|
||||||
|
thing phase 17 task 01 rewrote deliberately (the old block justified the filter; the new
|
||||||
|
one explains its absence). Directives are what a host runs, so those are compared exactly.
|
||||||
|
"""
|
||||||
|
return [line for line in unit.splitlines() if line.strip() and not line.startswith("#")]
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_template_unit_has_no_device_filter_lines() -> None:
|
||||||
|
"""The fix is the absence, so the absence is what is pinned.
|
||||||
|
|
||||||
|
Three layers, because the regression has three shapes: a `DevicePolicy=strict` or
|
||||||
|
`DeviceAllow=` line back in the heredoc (the deny-all returning on kernel 7.2, where
|
||||||
|
systemd matches those globs by *name* against `/proc/devices` and lists NVMe as `blkext`,
|
||||||
|
so `/dev/nvme*` matched nothing and strict denied the whole namespace);
|
||||||
|
`PrivateDevices=false` removed as "redundant" because it is the systemd default (a private
|
||||||
|
`/dev` with no device list dies at spawn with `status=208/STDIN`); and a rationale comment
|
||||||
|
that stops naming the mechanism, which is how the next operator re-adds the filter while
|
||||||
|
believing they are hardening the unit.
|
||||||
|
"""
|
||||||
|
text = INSTALLER.read_text()
|
||||||
|
# The phase's completion criterion is this exact grep, so it is pinned as one: neither
|
||||||
|
# directive may appear anywhere in the script, prose included.
|
||||||
|
assert "DevicePolicy" not in text, "no device policy line anywhere in the installer"
|
||||||
|
assert "DeviceAllow" not in text, "no device allow-list anywhere in the installer"
|
||||||
|
|
||||||
|
heredoc = _unit_heredoc()
|
||||||
|
offenders = [line for line in heredoc.splitlines() if re.match(r"^\s*(DevicePolicy|DeviceAllow)\s*=", line)]
|
||||||
|
assert offenders == [], "the unit heredoc grew a device filter line again:\n" + "\n".join(offenders)
|
||||||
|
assert "PrivateDevices=false" in heredoc, (
|
||||||
|
"the explicit default is the line that keeps the unit from dying at STDIN; the filter "
|
||||||
|
"going away does not make it redundant"
|
||||||
|
)
|
||||||
|
# The rewritten rationale has to be an explanation, not a shrug (task 01's own criterion).
|
||||||
|
comment = "\n".join(line for line in heredoc.splitlines() if line.startswith("#"))
|
||||||
|
assert "/proc/devices" in comment and "blkext" in comment, (
|
||||||
|
"the comment must name the mechanism, or the next reader cannot tell an omitted filter "
|
||||||
|
"from a forgotten one"
|
||||||
|
)
|
||||||
|
assert "208" in comment, "and the STDIN failure that PrivateDevices=false still prevents"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(hasattr(os, "geteuid") and os.geteuid() == 0, reason="running as root")
|
||||||
|
def test_a_default_install_unit_is_the_pre_phase_unit_minus_the_filter_lines(tmp_path: Path) -> None:
|
||||||
|
"""No flags writes the unit this script has always written, minus exactly ten lines.
|
||||||
|
|
||||||
|
`AGENTS.md` invariant 3 in deployment clothes: a host that needs nothing new must not get a
|
||||||
|
new unit from an unrelated phase. The reference is the whole pre-phase unit recorded above,
|
||||||
|
so every directive is compared (not a keyword spot-check) and the only allowed difference is
|
||||||
|
the device filter's absence — asserted as the ordered list comparison *and* as each of the ten
|
||||||
|
lines being absent by name, so a filter line silently renamed rather than removed fails too.
|
||||||
|
"""
|
||||||
|
script, _call_log, env = _sandbox_installer(tmp_path)
|
||||||
|
result = _install(script, env, "--bind", "10.0.0.5", "--port", "9120")
|
||||||
|
assert result.returncode == 0, f"install failed:\n{result.stdout}\n{result.stderr}"
|
||||||
|
installed = (tmp_path / "target" / "dashboards-agent.service").read_text()
|
||||||
|
|
||||||
|
reference = PRE_PHASE_DEFAULT_UNIT.replace("<ROOT>", str(tmp_path))
|
||||||
|
expected = [line for line in _unit_directives(reference) if line not in DEVICE_FILTER_LINES]
|
||||||
|
assert len(expected) == len(_unit_directives(reference)) - len(DEVICE_FILTER_LINES), (
|
||||||
|
"DEVICE_FILTER_LINES must match ten real lines of the reference unit — a filter line "
|
||||||
|
"this list no longer names is a filter that would pass this test"
|
||||||
|
)
|
||||||
|
got = _unit_directives(installed)
|
||||||
|
drift = "\n".join(
|
||||||
|
difflib.unified_diff(
|
||||||
|
expected, got, fromfile="pre-phase unit minus the filter", tofile="installed", lineterm=""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert got == expected, f"the default unit drifted from the pre-phase unit by more than the filter:\n{drift}"
|
||||||
|
|
||||||
|
# And the ten lines are gone rather than reworded into something equivalent.
|
||||||
|
assert not [line for line in installed.splitlines() if line.startswith(DEVICE_FILTER_LINES)]
|
||||||
|
|
||||||
|
|
||||||
|
# --- the kernel-gate unit: --as-root / --caps (phase 17 task 02) ---------------------------
|
||||||
|
|
||||||
|
#: The exact sentence `--as-root` alone prints. It is not a refusal — a capability-less root is
|
||||||
|
#: a legitimate if unusual choice — but it is the fact gap 10 paid for: the first root-user
|
||||||
|
#: attempt on the kernel-7.2 host still read `n/a` on all 16 drives, because the bounding set
|
||||||
|
#: bounds even root (`CapEff 0`). Pinned verbatim so a paraphrase that drops "not uid" is a
|
||||||
|
#: test failure rather than an operator rediscovering it.
|
||||||
|
AS_ROOT_NOTE = (
|
||||||
|
"note: --as-root with the default empty CapabilityBoundingSet leaves the process "
|
||||||
|
"capability-less (the kernel gates check capabilities, not uid); pair it with --caps "
|
||||||
|
"for a kernel-gated host"
|
||||||
|
)
|
||||||
|
|
||||||
|
#: `--caps` on the dedicated service user is not the kernel-gate fix (a non-root process cannot
|
||||||
|
#: hold these capabilities across exec), so the combination is refused rather than written.
|
||||||
|
CAPS_REFUSAL = "--caps needs --as-root: a capability grant on the service user is not the kernel-gate fix"
|
||||||
|
|
||||||
|
#: The two capabilities the kernel names for the two gates (NVMe SMART log / SG_IO), and the
|
||||||
|
#: `driveripper` recipe as `--help` must teach it (phase 17 completion criterion).
|
||||||
|
KERNEL_CAPS = "CAP_SYS_RAWIO,CAP_SYS_ADMIN"
|
||||||
|
KERNEL_GATE_RECIPE = f"--as-root --caps {KERNEL_CAPS}"
|
||||||
|
|
||||||
|
|
||||||
|
def _install(script: Path, env: dict[str, str], *flags: str) -> subprocess.CompletedProcess[str]:
|
||||||
|
"""One sandboxed installer run: fake root boundary, real script body."""
|
||||||
|
try:
|
||||||
|
return subprocess.run(
|
||||||
|
[BASH, str(script), *flags],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
env=env,
|
||||||
|
timeout=60,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
except subprocess.TimeoutExpired as exc: # pragma: no cover - the recursion regression
|
||||||
|
pytest.fail(f"the installer did not return (flags {flags}, partial output: {exc.stdout!r})")
|
||||||
|
|
||||||
|
|
||||||
|
def _directive(unit: str, key: str) -> list[str]:
|
||||||
|
"""Every line of the unit assigning `key` — a duplicate assignment is a heredoc bug."""
|
||||||
|
return [line for line in unit.splitlines() if line.split("=", 1)[0] == key]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(hasattr(os, "geteuid") and os.geteuid() == 0, reason="running as root")
|
||||||
|
def test_the_default_unit_keeps_the_service_user_and_an_empty_bounding_set(tmp_path: Path) -> None:
|
||||||
|
"""No flags = the unit this script wrote before the flags existed, byte-for-byte.
|
||||||
|
|
||||||
|
`CapabilityBoundingSet=` with nothing after the `=` is the capability-less default and what
|
||||||
|
every host whose drives answer through the `disk` group runs; it is also the line
|
||||||
|
`--caps` replaces, so its exact spelling is pinned here rather than as `in unit`.
|
||||||
|
"""
|
||||||
|
script, _call_log, env = _sandbox_installer(tmp_path)
|
||||||
|
result = _install(script, env, "--bind", "127.0.0.1", "--port", "9199")
|
||||||
|
assert result.returncode == 0, f"install failed:\n{result.stdout}\n{result.stderr}"
|
||||||
|
unit = (tmp_path / "target" / "dashboards-agent.service").read_text()
|
||||||
|
assert _directive(unit, "User") == ["User=dashboards-agent"]
|
||||||
|
assert _directive(unit, "CapabilityBoundingSet") == ["CapabilityBoundingSet="]
|
||||||
|
assert AS_ROOT_NOTE not in result.stdout, "the note belongs to --as-root alone"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(hasattr(os, "geteuid") and os.geteuid() == 0, reason="running as root")
|
||||||
|
def test_as_root_with_caps_writes_the_kernel_gate_unit(tmp_path: Path) -> None:
|
||||||
|
"""The two lines that reproduce the working `driveripper` unit, exactly.
|
||||||
|
|
||||||
|
Comma on the command line, space in the unit: that is systemd's own spelling for
|
||||||
|
`CapabilityBoundingSet`, and it is the whole reason the flag takes a list rather than the
|
||||||
|
line. Everything else in the unit — the token file, its `0640 root:dashboards-agent`, the
|
||||||
|
hardening block — is unchanged by these flags.
|
||||||
|
"""
|
||||||
|
script, call_log, env = _sandbox_installer(tmp_path)
|
||||||
|
kernel_gate = ["--bind", "10.0.0.5", "--port", "9120", "--as-root", "--caps", KERNEL_CAPS]
|
||||||
|
result = _install(script, env, *kernel_gate)
|
||||||
|
assert result.returncode == 0, f"install failed:\n{result.stdout}\n{result.stderr}"
|
||||||
|
unit = (tmp_path / "target" / "dashboards-agent.service").read_text()
|
||||||
|
assert _directive(unit, "User") == ["User=root"]
|
||||||
|
assert _directive(unit, "CapabilityBoundingSet") == ["CapabilityBoundingSet=CAP_SYS_RAWIO CAP_SYS_ADMIN"]
|
||||||
|
assert AS_ROOT_NOTE not in result.stdout, "--caps answers the note: the grant is there"
|
||||||
|
# The token file's logic is deliberately untouched by --as-root: root reads it either way,
|
||||||
|
# and re-deriving the mode per service user would be a second rule for no new safety.
|
||||||
|
token = tmp_path / "target" / "token"
|
||||||
|
assert stat.S_IMODE(token.stat().st_mode) == 0o640
|
||||||
|
assert "chown root:dashboards-agent" in call_log.read_text()
|
||||||
|
assert "--host 10.0.0.5 --port 9120" in unit
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(hasattr(os, "geteuid") and os.geteuid() == 0, reason="running as root")
|
||||||
|
def test_as_root_and_caps_write_the_kernel_gate_unit_lines(tmp_path: Path) -> None:
|
||||||
|
"""The whole flag contract in one place, plus the part only a comparison can pin.
|
||||||
|
|
||||||
|
The three flag shapes (`--as-root --caps LIST`, `--caps` alone, `--as-root` alone) each have
|
||||||
|
their own detailed pin nearby; what none of them shows is **how much** the recipe moves. So:
|
||||||
|
two installs in two sandboxes at the same bind and port, one default and one kernel-gated, and
|
||||||
|
their directive lists must differ by exactly the two lines this phase promises and nothing
|
||||||
|
else — the hardening block, the token file's path, the `ExecStart` and the `[Install]` section
|
||||||
|
are not these flags' business, and an `--as-root` that also slipped a `ProtectSystem` change in
|
||||||
|
would be a privilege change nobody asked for. The two half-recipes are then run in their own
|
||||||
|
sandboxes (one host re-run with different flags is the guard's decision, not the parser's).
|
||||||
|
"""
|
||||||
|
plain_script, _plain_log, plain_env = _sandbox_installer(tmp_path / "plain")
|
||||||
|
plain = _install(plain_script, plain_env, "--bind", "10.1.0.126", "--port", "9120")
|
||||||
|
assert plain.returncode == 0, f"default install failed:\n{plain.stdout}\n{plain.stderr}"
|
||||||
|
|
||||||
|
gate_script, gate_log, gate_env = _sandbox_installer(tmp_path / "gate")
|
||||||
|
recipe = _install(
|
||||||
|
gate_script, gate_env, "--bind", "10.1.0.126", "--port", "9120", "--as-root", "--caps", KERNEL_CAPS
|
||||||
|
)
|
||||||
|
assert recipe.returncode == 0, f"the driveripper recipe failed:\n{recipe.stdout}\n{recipe.stderr}"
|
||||||
|
|
||||||
|
def directives(where: str) -> list[str]:
|
||||||
|
root = tmp_path / where
|
||||||
|
unit = (root / "target" / "dashboards-agent.service").read_text()
|
||||||
|
return _unit_directives(unit.replace(str(root), "<ROOT>"))
|
||||||
|
|
||||||
|
changed = [
|
||||||
|
f"{a} -> {b}"
|
||||||
|
for a, b in zip(directives("plain"), directives("gate"), strict=True)
|
||||||
|
if a != b
|
||||||
|
]
|
||||||
|
assert changed == [
|
||||||
|
"User=dashboards-agent -> User=root",
|
||||||
|
"CapabilityBoundingSet= -> CapabilityBoundingSet=CAP_SYS_RAWIO CAP_SYS_ADMIN",
|
||||||
|
], "--as-root --caps may write these two lines and nothing else"
|
||||||
|
assert "chown root:dashboards-agent" in gate_log.read_text(), (
|
||||||
|
"--as-root leaves the token file's ownership alone: root reads it either way, and the "
|
||||||
|
"grant must not widen because of a flag that is about capabilities"
|
||||||
|
)
|
||||||
|
|
||||||
|
# The two half-recipes, in their own sandboxes (a differing re-run of one host is the
|
||||||
|
# guard's business, not the flag parser's).
|
||||||
|
caps_only_script, caps_only_log, caps_only_env = _sandbox_installer(tmp_path / "caps-only")
|
||||||
|
caps_only = _install(
|
||||||
|
caps_only_script, caps_only_env, "--bind", "127.0.0.1", "--port", "9199", "--caps", KERNEL_CAPS
|
||||||
|
)
|
||||||
|
assert caps_only.returncode == 1, "--caps without --as-root is refused"
|
||||||
|
assert CAPS_REFUSAL in caps_only.stderr
|
||||||
|
assert not caps_only_log.exists(), "and refused before a single privileged command"
|
||||||
|
|
||||||
|
root_only_script, _root_only_log, root_only_env = _sandbox_installer(tmp_path / "root-only")
|
||||||
|
root_only = _install(root_only_script, root_only_env, "--bind", "127.0.0.1", "--port", "9199", "--as-root")
|
||||||
|
assert root_only.returncode == 0, f"--as-root alone must proceed:\n{root_only.stderr}"
|
||||||
|
assert AS_ROOT_NOTE in root_only.stdout, "with the capability-less note"
|
||||||
|
root_unit = (tmp_path / "root-only" / "target" / "dashboards-agent.service").read_text()
|
||||||
|
assert _directive(root_unit, "User") == ["User=root"]
|
||||||
|
assert _directive(root_unit, "CapabilityBoundingSet") == ["CapabilityBoundingSet="]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(hasattr(os, "geteuid") and os.geteuid() == 0, reason="running as root")
|
||||||
|
def test_as_root_alone_installs_and_prints_the_capability_note(tmp_path: Path) -> None:
|
||||||
|
"""A capability-less root proceeds — with the reminder, on stdout, and no refusal."""
|
||||||
|
script, _call_log, env = _sandbox_installer(tmp_path)
|
||||||
|
result = _install(script, env, "--bind", "127.0.0.1", "--port", "9199", "--as-root")
|
||||||
|
assert result.returncode == 0, f"--as-root alone must proceed:\n{result.stdout}\n{result.stderr}"
|
||||||
|
assert AS_ROOT_NOTE in result.stdout, "the note is the operator's only warning here"
|
||||||
|
unit = (tmp_path / "target" / "dashboards-agent.service").read_text()
|
||||||
|
assert _directive(unit, "User") == ["User=root"]
|
||||||
|
assert _directive(unit, "CapabilityBoundingSet") == ["CapabilityBoundingSet="], (
|
||||||
|
"--as-root alone writes no capability grant: the note is the whole effect"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("caps", "refusal"),
|
||||||
|
[
|
||||||
|
("CAP_SYS_RAWIO", CAPS_REFUSAL),
|
||||||
|
("CAP_SYS_RAWIO,CAP_SYS_ADMIN", CAPS_REFUSAL),
|
||||||
|
("", "--caps needs at least one capability name"),
|
||||||
|
(" , ", "--caps needs at least one capability name"),
|
||||||
|
],
|
||||||
|
ids=["one-cap", "two-caps", "empty", "punctuation-only"],
|
||||||
|
)
|
||||||
|
@pytest.mark.skipif(hasattr(os, "geteuid") and os.geteuid() == 0, reason="running as root")
|
||||||
|
def test_caps_without_as_root_is_refused_before_anything_is_written(
|
||||||
|
tmp_path: Path, caps: str, refusal: str
|
||||||
|
) -> None:
|
||||||
|
"""Exit 1, the reason on stderr, and no side effect at all.
|
||||||
|
|
||||||
|
The check sits *before* `need_root` (the `--cert-bundle` validator's reasoning): a wrong
|
||||||
|
flag combination gets its own sentence instead of a sudoers hint, and nothing after it —
|
||||||
|
user, group membership, token, unit — can have run.
|
||||||
|
"""
|
||||||
|
script, call_log, env = _sandbox_installer(tmp_path)
|
||||||
|
result = _install(script, env, "--bind", "127.0.0.1", "--port", "9199", "--caps", caps)
|
||||||
|
assert result.returncode == 1, "the refusal keeps exit 1"
|
||||||
|
assert result.stdout == "", "a refusal is a failure: nothing on stdout"
|
||||||
|
assert refusal in result.stderr
|
||||||
|
assert GRANT_PREFIX not in result.stderr, "a flag mistake is not a sudoers problem"
|
||||||
|
target = tmp_path / "target"
|
||||||
|
assert not (target / "dashboards-agent.service").exists(), "refused means nothing written"
|
||||||
|
assert not (target / "token").exists()
|
||||||
|
assert not call_log.exists(), "no privileged command ran either"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(hasattr(os, "geteuid") and os.geteuid() == 0, reason="running as root")
|
||||||
|
def test_caps_without_an_argument_is_refused_rather_than_crashing(tmp_path: Path) -> None:
|
||||||
|
"""`--caps` last on the command line: exit 1 naming the flag, not an `unbound variable`."""
|
||||||
|
script, _call_log, env = _sandbox_installer(tmp_path)
|
||||||
|
result = _install(script, env, "--bind", "127.0.0.1", "--port", "9199", "--as-root", "--caps")
|
||||||
|
assert result.returncode == 1
|
||||||
|
assert result.stdout == ""
|
||||||
|
assert "--caps needs a capability list" in result.stderr
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(hasattr(os, "geteuid") and os.geteuid() == 0, reason="running as root")
|
||||||
|
def test_the_caps_list_is_normalised_to_systemd_spelling(tmp_path: Path) -> None:
|
||||||
|
"""Empty entries and stray whitespace never reach the unit as a double space…
|
||||||
|
|
||||||
|
…and a `*` stays a literal: the list is split with pathname expansion switched off, so an
|
||||||
|
operator typo cannot substitute the working directory's file names into `User=`'s partner.
|
||||||
|
"""
|
||||||
|
script, _call_log, env = _sandbox_installer(tmp_path)
|
||||||
|
messy = ["--bind", "127.0.0.1", "--port", "9199", "--as-root", "--caps", "CAP_SYS_RAWIO,, CAP_SYS_ADMIN "]
|
||||||
|
result = _install(script, env, *messy)
|
||||||
|
assert result.returncode == 0, f"install failed:\n{result.stdout}\n{result.stderr}"
|
||||||
|
unit = (tmp_path / "target" / "dashboards-agent.service").read_text()
|
||||||
|
assert _directive(unit, "CapabilityBoundingSet") == ["CapabilityBoundingSet=CAP_SYS_RAWIO CAP_SYS_ADMIN"]
|
||||||
|
|
||||||
|
script_glob, _log, env_glob = _sandbox_installer(tmp_path / "glob")
|
||||||
|
star = ["--bind", "127.0.0.1", "--port", "9199", "--as-root", "--caps", "*"]
|
||||||
|
result_glob = _install(script_glob, env_glob, *star)
|
||||||
|
assert result_glob.returncode == 0, f"install failed:\n{result_glob.stdout}\n{result_glob.stderr}"
|
||||||
|
unit_glob = (tmp_path / "glob" / "target" / "dashboards-agent.service").read_text()
|
||||||
|
assert _directive(unit_glob, "CapabilityBoundingSet") == ["CapabilityBoundingSet=*"], (
|
||||||
|
"the capability list is words, not a glob"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_help_teaches_the_kernel_gate_recipe() -> None:
|
||||||
|
"""Completion criterion: the `driveripper` flags are learnable from `--help` alone.
|
||||||
|
|
||||||
|
`--help` *is* the header comment, so the sentence that explains why a root service needs
|
||||||
|
its capabilities named is pinned here too — it is the one thing an executor on a fresh
|
||||||
|
clone cannot infer from a unit file.
|
||||||
|
"""
|
||||||
|
result = _run([BASH, str(INSTALLER), "--help"])
|
||||||
|
assert result.returncode == 0
|
||||||
|
assert KERNEL_GATE_RECIPE in result.stdout, "the recipe line must be copy-pasteable"
|
||||||
|
assert "--as-root run the service as root" in result.stdout
|
||||||
|
assert "--caps LIST CapabilityBoundingSet line for the unit" in result.stdout
|
||||||
|
assert "capabilities, not uid" in result.stdout, "the kernel gate is the why of both flags"
|
||||||
|
assert "capability-less" in result.stdout, "--as-root without --caps has to say what it is"
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_status_probe_is_blind_to_who_the_service_runs_as() -> None:
|
||||||
|
"""`--status` probes the agent over the wire; the unit's user is invisible to it, on purpose.
|
||||||
|
|
||||||
|
A status path that read `AS_ROOT`/`CAPS` would make a health probe depend on flags the
|
||||||
|
operator may not have passed on this invocation — the probe answers about the port.
|
||||||
|
"""
|
||||||
|
status_body = INSTALLER.read_text().split("status() {", 1)[1].split("\n}", 1)[0]
|
||||||
|
assert "AS_ROOT" not in status_body
|
||||||
|
assert "CAPS" not in status_body
|
||||||
|
|
||||||
|
|
||||||
def test_token_file_is_readable_by_the_service_user_and_nobody_else(tmp_path: Path) -> None:
|
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.
|
"""`--token-file` is a path the *agent* opens, as its own user.
|
||||||
|
|
||||||
@@ -409,6 +888,297 @@ def test_token_file_is_readable_by_the_service_user_and_nobody_else(tmp_path: Pa
|
|||||||
assert not mode & 0o007, "the token file must never be world-readable"
|
assert not mode & 0o007, "the token file must never be world-readable"
|
||||||
|
|
||||||
|
|
||||||
|
# --- the re-run guard: no silent clobber (phase 17 task 03) ----------------------------------
|
||||||
|
|
||||||
|
def _refusal(unit: Path) -> str:
|
||||||
|
"""The sentence a refused re-run prints, with the unit path substituted.
|
||||||
|
|
||||||
|
Pinned verbatim because it is the operator's only explanation of why a routine re-run
|
||||||
|
stopped, and because the diff follows it on **stdout** rather than stderr: the diff is the
|
||||||
|
payload of the answer (an operator pipes it into a pager or a file), and splitting a
|
||||||
|
sentence from its diff across two streams only interleaves them.
|
||||||
|
"""
|
||||||
|
return f"refusing to overwrite {unit} (generated unit differs); run with --force to apply:"
|
||||||
|
|
||||||
|
|
||||||
|
#: One line the operator can plausibly have edited on the host — `RestartSec` is a real knob,
|
||||||
|
#: it is not a line any flag writes, and it sits far from every other assertion in these tests.
|
||||||
|
OPERATOR_EDIT = ("RestartSec=5", "RestartSec=7 # hand-tuned on the host after a flapping start")
|
||||||
|
|
||||||
|
|
||||||
|
def _hand_edit(unit_path: Path) -> str:
|
||||||
|
"""Edit the installed unit the way an operator does, and return what it now holds."""
|
||||||
|
text = unit_path.read_text()
|
||||||
|
assert OPERATOR_EDIT[0] in text, "the template unit lost the line the operator edits"
|
||||||
|
edited = text.replace(*OPERATOR_EDIT)
|
||||||
|
unit_path.write_text(edited, encoding="utf-8")
|
||||||
|
return edited
|
||||||
|
|
||||||
|
|
||||||
|
def _systemctl_calls(call_log: Path) -> list[str]:
|
||||||
|
"""Only the `systemctl` lines of the stub log — the ones that touch the running service.
|
||||||
|
|
||||||
|
The privileged calls before the guard (`id`, `getent`, `usermod`, `chown`) do run again on
|
||||||
|
a refused re-run: they are the idempotent ones the guard's reasoning accepts. `systemctl`
|
||||||
|
is what a refused run must not do — no `daemon-reload`, no `enable --now` — so that is the
|
||||||
|
half compared here.
|
||||||
|
"""
|
||||||
|
return [line for line in call_log.read_text().splitlines() if line.startswith("systemctl ")]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(hasattr(os, "geteuid") and os.geteuid() == 0, reason="running as root")
|
||||||
|
def test_a_rerun_that_would_change_the_installed_unit_refuses(tmp_path: Path) -> None:
|
||||||
|
"""install → hand-edit → re-run: exit 1, the diff on stdout, nothing applied.
|
||||||
|
|
||||||
|
This is the incident the guard exists for: `driveripper`'s unit was fixed by hand, and the
|
||||||
|
installer rewrote it unconditionally. A refused run must leave the unit on disk as the
|
||||||
|
operator left it and must not reload or re-enable anything, or the refusal arrives after
|
||||||
|
the thing it is refusing.
|
||||||
|
"""
|
||||||
|
script, call_log, env = _sandbox_installer(tmp_path)
|
||||||
|
unit_path = tmp_path / "target" / "dashboards-agent.service"
|
||||||
|
token_path = tmp_path / "target" / "token"
|
||||||
|
|
||||||
|
first = _install(script, env, "--bind", "10.0.0.5", "--port", "9120")
|
||||||
|
assert first.returncode == 0, f"first install failed:\n{first.stdout}\n{first.stderr}"
|
||||||
|
edited = _hand_edit(unit_path)
|
||||||
|
token_before = token_path.read_bytes()
|
||||||
|
reloads_before = _systemctl_calls(call_log)
|
||||||
|
assert reloads_before, "the first install did reload and enable the service"
|
||||||
|
|
||||||
|
result = _install(script, env, "--bind", "10.0.0.5", "--port", "9120")
|
||||||
|
assert result.returncode == 1, "a differing re-run refuses with exit 1"
|
||||||
|
assert result.stderr == "", "the refusal and its diff are one answer: stdout, not split"
|
||||||
|
assert _refusal(unit_path) in result.stdout
|
||||||
|
assert "--- " in result.stdout and "+++ " in result.stdout, "the diff is unified"
|
||||||
|
assert f"-{OPERATOR_EDIT[1]}" in result.stdout, "the diff shows the operator's line going away"
|
||||||
|
assert "+RestartSec=5" in result.stdout, "and the template's line coming back"
|
||||||
|
assert unit_path.read_text() == edited, "a refused run leaves the installed unit byte-identical"
|
||||||
|
# The reuse path re-reads the existing secret and rewrites the same bytes with the same
|
||||||
|
# mode; its *value* — what invariant 6 and the HOST_TARGETS entry depend on — never moves,
|
||||||
|
# which is the guarantee here (mtime is not: the write happens before the guard).
|
||||||
|
assert token_path.read_bytes() == token_before, "the reused token must not change"
|
||||||
|
assert stat.S_IMODE(token_path.stat().st_mode) == 0o640
|
||||||
|
assert _systemctl_calls(call_log) == reloads_before, (
|
||||||
|
"a refused run must not daemon-reload or enable: that would restart the service the "
|
||||||
|
"refusal exists to leave alone"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(hasattr(os, "geteuid") and os.geteuid() == 0, reason="running as root")
|
||||||
|
def test_force_prints_the_diff_then_writes_the_generated_unit(tmp_path: Path) -> None:
|
||||||
|
"""`--force` is the opt-in, and the diff still arrives: an operator approves what they saw."""
|
||||||
|
script, call_log, env = _sandbox_installer(tmp_path)
|
||||||
|
unit_path = tmp_path / "target" / "dashboards-agent.service"
|
||||||
|
_install(script, env, "--bind", "10.0.0.5", "--port", "9120")
|
||||||
|
_hand_edit(unit_path)
|
||||||
|
|
||||||
|
result = _install(script, env, "--bind", "10.0.0.5", "--port", "9120", "--force")
|
||||||
|
assert result.returncode == 0, f"--force must proceed:\n{result.stdout}\n{result.stderr}"
|
||||||
|
assert _refusal(unit_path) not in result.stdout, "--force is not a refusal"
|
||||||
|
assert f"-{OPERATOR_EDIT[1]}" in result.stdout and "+RestartSec=5" in result.stdout, (
|
||||||
|
"the diff is printed even when the answer is yes"
|
||||||
|
)
|
||||||
|
unit = unit_path.read_text()
|
||||||
|
assert "RestartSec=5" in unit and OPERATOR_EDIT[1] not in unit, "the generated unit is written"
|
||||||
|
assert _systemctl_calls(call_log).count("systemctl enable --now dashboards-agent") == 2, (
|
||||||
|
"the overwrite is followed by the usual reload/enable"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(hasattr(os, "geteuid") and os.geteuid() == 0, reason="running as root")
|
||||||
|
def test_an_unchanged_rerun_is_silent_and_proceeds(tmp_path: Path) -> None:
|
||||||
|
"""Idempotence is the normal path: same flags, same unit, no diff, no complaint, exit 0."""
|
||||||
|
script, call_log, env = _sandbox_installer(tmp_path)
|
||||||
|
unit_path = tmp_path / "target" / "dashboards-agent.service"
|
||||||
|
first = _install(script, env, "--bind", "10.0.0.5", "--port", "9120")
|
||||||
|
assert first.returncode == 0, f"first install failed:\n{first.stdout}\n{first.stderr}"
|
||||||
|
installed = unit_path.read_text()
|
||||||
|
|
||||||
|
result = _install(script, env, "--bind", "10.0.0.5", "--port", "9120")
|
||||||
|
assert result.returncode == 0, f"an identical re-run must proceed:\n{result.stdout}\n{result.stderr}"
|
||||||
|
assert _refusal(unit_path) not in result.stdout
|
||||||
|
assert "+++ " not in result.stdout, "nothing differs, so nothing is printed about differences"
|
||||||
|
assert "reusing the existing token" in result.stdout, "the token idiom is unchanged"
|
||||||
|
assert unit_path.read_text() == installed
|
||||||
|
assert _systemctl_calls(call_log).count("systemctl enable --now dashboards-agent") == 2
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(hasattr(os, "geteuid") and os.geteuid() == 0, reason="running as root")
|
||||||
|
def test_a_re_run_that_differs_refuses_without_force_and_applies_with_it(tmp_path: Path) -> None:
|
||||||
|
"""The refuse → force → silent sequence on **one** host, in that order.
|
||||||
|
|
||||||
|
Each leg has its own detailed pin beside it; what those cannot show is the transition — a
|
||||||
|
guard that refuses correctly the first time and then never lets an identical re-run through
|
||||||
|
silently is the same bug seen from the other side (an operator who has to type `--force`
|
||||||
|
every deploy stops reading the diff). So: install, hand-edit, re-run (refused, unit left
|
||||||
|
exactly as edited, no new `systemctl` call), re-run with `--force` (diff first, generated
|
||||||
|
unit on disk, service re-enabled), then re-run plain once more (silent, unit unmoved).
|
||||||
|
"""
|
||||||
|
script, call_log, env = _sandbox_installer(tmp_path)
|
||||||
|
unit_path = tmp_path / "target" / "dashboards-agent.service"
|
||||||
|
flags = ["--bind", "10.1.0.126", "--port", "9120", "--as-root", "--caps", KERNEL_CAPS]
|
||||||
|
|
||||||
|
first = _install(script, env, *flags)
|
||||||
|
assert first.returncode == 0, f"first install failed:\n{first.stdout}\n{first.stderr}"
|
||||||
|
edited = _hand_edit(unit_path)
|
||||||
|
reloads = len(_systemctl_calls(call_log))
|
||||||
|
|
||||||
|
refused = _install(script, env, *flags)
|
||||||
|
assert refused.returncode == 1, "leg 1: a differing re-run refuses"
|
||||||
|
assert _refusal(unit_path) in refused.stdout and "+++ " in refused.stdout
|
||||||
|
assert unit_path.read_text() == edited, "leg 1: the hand edit survives"
|
||||||
|
assert len(_systemctl_calls(call_log)) == reloads, "leg 1: nothing was reloaded or re-enabled"
|
||||||
|
|
||||||
|
applied = _install(script, env, *flags, "--force")
|
||||||
|
assert applied.returncode == 0, f"leg 2: --force failed:\n{applied.stdout}\n{applied.stderr}"
|
||||||
|
assert "+++ " in applied.stdout, "leg 2: the diff is printed even on a yes"
|
||||||
|
assert unit_path.read_text() != edited, "leg 2: the generated unit replaced the hand edit"
|
||||||
|
assert "RestartSec=5" in unit_path.read_text()
|
||||||
|
generated = unit_path.read_text()
|
||||||
|
# three calls per applied install: daemon-reload, enable --now, and the is-active check
|
||||||
|
assert len(_systemctl_calls(call_log)) == reloads + 3, "leg 2: reload + enable, as a normal install"
|
||||||
|
|
||||||
|
settled = _install(script, env, *flags)
|
||||||
|
assert settled.returncode == 0, f"leg 3: a silent re-run failed:\n{settled.stdout}\n{settled.stderr}"
|
||||||
|
assert "refusing to overwrite" not in settled.stdout and "+++ " not in settled.stdout
|
||||||
|
assert "reusing the existing token" in settled.stdout
|
||||||
|
assert unit_path.read_text() == generated, "leg 3: the silent path leaves the applied unit in place"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(hasattr(os, "geteuid") and os.geteuid() == 0, reason="running as root")
|
||||||
|
def test_a_fresh_install_never_meets_the_guard(tmp_path: Path) -> None:
|
||||||
|
"""No installed unit → no comparison, no output about one, and `--force` changes nothing.
|
||||||
|
|
||||||
|
The guard is about a *re*-run. A first install must stay exactly the run it was before this
|
||||||
|
task, with or without `--force` — hence the two sandboxes writing byte-identical units.
|
||||||
|
"""
|
||||||
|
plain_script, _plain_log, plain_env = _sandbox_installer(tmp_path / "plain")
|
||||||
|
forced_script, _forced_log, forced_env = _sandbox_installer(tmp_path / "forced")
|
||||||
|
|
||||||
|
plain = _install(plain_script, plain_env, "--bind", "10.0.0.5", "--port", "9120")
|
||||||
|
forced = _install(forced_script, forced_env, "--bind", "10.0.0.5", "--port", "9120", "--force")
|
||||||
|
assert plain.returncode == 0, f"a first install failed:\n{plain.stdout}\n{plain.stderr}"
|
||||||
|
assert forced.returncode == 0, f"--force broke a first install:\n{forced.stdout}\n{forced.stderr}"
|
||||||
|
for stdout in (plain.stdout, forced.stdout):
|
||||||
|
assert "refusing to overwrite" not in stdout
|
||||||
|
assert "+++ " not in stdout
|
||||||
|
|
||||||
|
def rendered(where: str) -> str:
|
||||||
|
"""The unit that sandbox wrote, with its own sandbox root name blanked out.
|
||||||
|
|
||||||
|
The two runs live in different directories, so the absolute paths in `ExecStart` and
|
||||||
|
`--token-file` differ while every *decision* in the unit is the same — and the decision
|
||||||
|
is what `--force` must not touch.
|
||||||
|
"""
|
||||||
|
root = tmp_path / where
|
||||||
|
return (
|
||||||
|
(root / "target" / "dashboards-agent.service")
|
||||||
|
.read_text()
|
||||||
|
.replace(str(root), "<sandbox>")
|
||||||
|
)
|
||||||
|
|
||||||
|
assert rendered("plain") == rendered("forced"), "--force writes the unit the default install writes"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(hasattr(os, "geteuid") and os.geteuid() == 0, reason="running as root")
|
||||||
|
def test_the_kernel_gate_reproduction_refuses_once_and_then_holds(tmp_path: Path) -> None:
|
||||||
|
"""The `driveripper` case end to end: a token-mode unit is *not* the kernel-gate unit.
|
||||||
|
|
||||||
|
Switching a host to `--as-root --caps` is a real change to a real unit, so it asks first,
|
||||||
|
applies with `--force`, and is silent afterwards — the flag line in `--help` is a
|
||||||
|
reproduction command only if running it twice lands the same unit and says nothing twice.
|
||||||
|
"""
|
||||||
|
script, _call_log, env = _sandbox_installer(tmp_path)
|
||||||
|
unit_path = tmp_path / "target" / "dashboards-agent.service"
|
||||||
|
kernel_gate = ["--bind", "10.1.0.126", "--port", "9120", "--as-root", "--caps", KERNEL_CAPS]
|
||||||
|
_install(script, env, "--bind", "10.1.0.126", "--port", "9120")
|
||||||
|
token_mode = unit_path.read_text()
|
||||||
|
|
||||||
|
asked = _install(script, env, *kernel_gate)
|
||||||
|
assert asked.returncode == 1, "a flag change to an installed unit is a change, so it asks"
|
||||||
|
assert _refusal(unit_path) in asked.stdout
|
||||||
|
assert "-User=dashboards-agent" in asked.stdout and "+User=root" in asked.stdout
|
||||||
|
assert "+CapabilityBoundingSet=CAP_SYS_RAWIO CAP_SYS_ADMIN" in asked.stdout
|
||||||
|
assert unit_path.read_text() == token_mode, "the question must not also be the answer"
|
||||||
|
|
||||||
|
applied = _install(script, env, *kernel_gate, "--force")
|
||||||
|
assert applied.returncode == 0, f"--force failed:\n{applied.stdout}\n{applied.stderr}"
|
||||||
|
applied_unit = unit_path.read_text()
|
||||||
|
assert _directive(applied_unit, "User") == ["User=root"]
|
||||||
|
assert _directive(applied_unit, "CapabilityBoundingSet") == [
|
||||||
|
"CapabilityBoundingSet=CAP_SYS_RAWIO CAP_SYS_ADMIN"
|
||||||
|
]
|
||||||
|
|
||||||
|
again = _install(script, env, *kernel_gate, "--force")
|
||||||
|
assert again.returncode == 0, f"the same command twice failed:\n{again.stdout}\n{again.stderr}"
|
||||||
|
assert "+++ " not in again.stdout, "once applied, the same flags are the zero-noise path"
|
||||||
|
assert unit_path.read_text() == applied_unit
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(hasattr(os, "geteuid") and os.geteuid() == 0, reason="running as root")
|
||||||
|
def test_uninstall_is_still_deliberate_and_never_asks(tmp_path: Path) -> None:
|
||||||
|
"""The guard belongs to `do_install`: `--uninstall` removes a hand-edited unit without one.
|
||||||
|
|
||||||
|
A guard on the teardown path would be a guard that keeps a unit nobody wants, and
|
||||||
|
`uninstall()` does not render a unit at all.
|
||||||
|
"""
|
||||||
|
script, call_log, env = _sandbox_installer(tmp_path)
|
||||||
|
unit_path = tmp_path / "target" / "dashboards-agent.service"
|
||||||
|
_install(script, env, "--bind", "10.0.0.5", "--port", "9120")
|
||||||
|
_hand_edit(unit_path)
|
||||||
|
|
||||||
|
result = _install(script, env, "--uninstall")
|
||||||
|
assert result.returncode == 0, f"uninstall refused:\n{result.stdout}\n{result.stderr}"
|
||||||
|
assert "refusing to overwrite" not in result.stdout
|
||||||
|
assert not unit_path.exists(), "uninstall still removes the unit, edited or not"
|
||||||
|
assert "systemctl disable --now dashboards-agent" in _systemctl_calls(call_log)[-3:]
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_guard_sits_between_the_render_and_the_unit_write() -> None:
|
||||||
|
"""Text pin for the ordering the behaviour depends on.
|
||||||
|
|
||||||
|
The comparison must read the *rendered* unit (every flag, bundle and capability decision
|
||||||
|
applied) and must come before the write, the `daemon-reload` and the `enable`; a `cat >
|
||||||
|
"$UNIT" <<UNIT_FILE` would put the clobber back while still passing every behavioural test
|
||||||
|
above on a host with no installed unit.
|
||||||
|
"""
|
||||||
|
text = INSTALLER.read_text()
|
||||||
|
assert "do_install() {" in text, "the install body is still the function that writes the unit"
|
||||||
|
# Scoped to do_install: `uninstall()` also reloads the daemon, and an unscoped index would
|
||||||
|
# compare offsets across two different functions.
|
||||||
|
body = text.split("do_install() {", 1)[1]
|
||||||
|
render = body.index('cat > "$TMP_UNIT" <<UNIT_FILE')
|
||||||
|
guard = body.index('! cmp -s "$UNIT" "$TMP_UNIT"')
|
||||||
|
write = body.index('cat "$TMP_UNIT" > "$UNIT"')
|
||||||
|
reload = body.index("systemctl daemon-reload")
|
||||||
|
enable = body.index('systemctl enable --now "$SERVICE"')
|
||||||
|
assert render < guard < write < reload < enable
|
||||||
|
assert 'cat > "$UNIT" <<' not in text, "the template may only write $UNIT after comparing it"
|
||||||
|
assert 'if [ "$FORCE" -eq 1 ]; then' in text, "--force is the guard's own opt-in branch"
|
||||||
|
assert "rm -f \"$TMP_UNIT\"" in text, "the rendered temp file is cleaned up on every path"
|
||||||
|
assert 'mktemp "${TMPDIR:-/tmp}/dashboards-agent-unit.XXXXXX"' in text, (
|
||||||
|
"the diff's +++ side must name the generated unit, not an anonymous temp file"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
#: The `--force` help line's sentence (phase 17 completion criterion: the flags are learnable
|
||||||
|
#: from `--help`, and the diff-first promise is the reason an operator trusts `--force`).
|
||||||
|
FORCE_HELP_SENTENCE = "overwrite an existing unit even if the generated unit differs"
|
||||||
|
|
||||||
|
|
||||||
|
def test_help_documents_force_and_keeps_the_flag_block_aligned() -> None:
|
||||||
|
stdout = _run([BASH, str(INSTALLER), "--help"]).stdout
|
||||||
|
lines = [line for line in stdout.splitlines() if FORCE_HELP_SENTENCE in line]
|
||||||
|
assert len(lines) == 1, f"exactly one --force flag line is expected, got {lines}"
|
||||||
|
assert lines[0].lstrip().startswith("--force")
|
||||||
|
assert "(prints the" in lines[0] and "diff first)" in stdout
|
||||||
|
# One column for every flag's description: the block is a table, and `--force` landing at
|
||||||
|
# its own column is how `--help` stays readable as a reference rather than a paragraph.
|
||||||
|
as_root = next(line for line in stdout.splitlines() if line.lstrip().startswith("--as-root"))
|
||||||
|
assert lines[0].index(FORCE_HELP_SENTENCE) == as_root.index("run the service as root")
|
||||||
|
|
||||||
|
|
||||||
# --- the certificate bundle (phase 15 task 06) --------------------------------------------
|
# --- the certificate bundle (phase 15 task 06) --------------------------------------------
|
||||||
|
|
||||||
#: Where the installed bundle lives — one constant in the script, pinned here so a rename
|
#: Where the installed bundle lives — one constant in the script, pinned here so a rename
|
||||||
|
|||||||
Reference in New Issue
Block a user