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_READ_ONLY=1 kills every mutating endpoint regardless of other flags
|
||||
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)
|
||||
@@ -110,6 +117,10 @@ NUT_TIMEOUT_SECONDS=4
|
||||
# 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.
|
||||
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)
|
||||
@@ -129,7 +140,7 @@ POLL_MS_UPS=5000
|
||||
DEBUGPY=0
|
||||
DEBUGPY_PORT=5678
|
||||
|
||||
# The host agent reads its OWN variables (AGENT_HOST, AGENT_PORT, AGENT_TOKEN,
|
||||
# AGENT_TOKEN_FILE). They belong on the monitored host — in the root-owned env/token files
|
||||
# that deploy/install-agent.sh writes — not in this file: a dashboard config that everyone
|
||||
# copies tends to end up with the fleet's tokens in it.
|
||||
# The host agent reads its OWN variables (AGENT_HOST, AGENT_PORT, AGENT_TOKEN, AGENT_TOKEN_FILE,
|
||||
# and AGENT_CERT_BUNDLE for the TLS bundle `--cert-bundle` installs). They belong on the monitored
|
||||
# host — in the root-owned env/token/bundle files that deploy/install-agent.sh writes — not in this
|
||||
# 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
|
||||
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
|
||||
root:dashboards-agent`, the mode `deploy/install-agent.sh` writes) with `User=dashboards-agent`,
|
||||
and `getent group disk` → `disk:x:6:dashboards-agent`.
|
||||
**The deployed unit has since diverged from the template in the three ways the SMART
|
||||
block below records** (the `DevicePolicy`/`DeviceAllow` lines removed, `User=root`,
|
||||
`CapabilityBoundingSet=CAP_SYS_RAWIO CAP_SYS_ADMIN`) — re-running `deploy/install-agent.sh`
|
||||
on driveripper would clobber the working unit, because it rewrites the unit unconditionally
|
||||
(gap 10).
|
||||
root:dashboards-agent`, the mode `deploy/install-agent.sh` writes) and is still in the `disk`
|
||||
group (`getent group disk` → `disk:x:6:dashboards-agent`) — but it does **not** run as that
|
||||
user: gap 10's hand-fix set `User=root`, and re-read from the host on 2026-10-20 the installed
|
||||
unit is `User=root` + `CapabilityBoundingSet=CAP_SYS_RAWIO CAP_SYS_ADMIN` with no device filter.
|
||||
**Those two extra lines are now template output, and the unit's rewrite is no longer
|
||||
silent**: phase 17 took the
|
||||
`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`,
|
||||
`host:gateway`, `disks:driveripper`, `disks:deskwork`, `disks:gateway`, `nut`, `llm:gufo`,
|
||||
`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
|
||||
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
|
||||
the operator's home (`sudo -n -l`, re-checked 2026-10-20) — and that grant is the trap, not the
|
||||
shortcut: the installer rewrites the unit unconditionally, so running it there today replaces the
|
||||
`User=root` + `CapabilityBoundingSet` unit that gap 10 resolved with this repo's
|
||||
`User=dashboards-agent` + `DevicePolicy=strict` template and re-breaks all 16 SMART reads. The
|
||||
live unit is not the one this repo writes (no `DevicePolicy`/`DeviceAllow` lines at all —
|
||||
systemd's default `auto` — `PrivateDevices=false`, and the two named capabilities).
|
||||
Worth knowing before the next install: the allow-list `install-agent.sh`
|
||||
does write (`/dev/nvme*`, `/dev/sd*`, `/dev/vd*`, `/dev/sg*`) has **no `/dev/ng*n*` and no
|
||||
`/dev/bsg/*`**, the nodes smartctl 7.5 reaches for NVMe admin and SATA pass-through, so a
|
||||
`strict` unit there would deny exactly what a SMART read needs — whatever phase takes
|
||||
driveripper's SMART on has to settle the node list and the grant together.
|
||||
the operator's home (`sudo -n -l`, re-checked 2026-10-20) — and that grant is still the trap,
|
||||
not the shortcut: it points at that Oct-18 **snapshot**, which carries neither this phase's
|
||||
flags, nor the filter removal, nor the re-run guard, so running the granted path replaces the
|
||||
`User=root` + `CapabilityBoundingSet` unit that gap 10 resolved with the snapshot's own
|
||||
`User=dashboards-agent`, capability-less unit and re-breaks all 16 SMART reads — **silently**,
|
||||
because the guard that refuses a differing re-run (task 03) lives only in this repo's script,
|
||||
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
|
||||
(phase 17 task 01 — systemd's default `auto`, with `PrivateDevices=false`) and `--as-root`
|
||||
plus `--caps CAP_SYS_RAWIO,CAP_SYS_ADMIN` for the `User=root` and
|
||||
`CapabilityBoundingSet=CAP_SYS_RAWIO CAP_SYS_ADMIN` lines (task 02). The old allow-list's
|
||||
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
|
||||
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
|
||||
@@ -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 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
|
||||
uv run pytest --cov # coverage gate (98.94%; floor 94%)
|
||||
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`,
|
||||
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`/
|
||||
`--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
|
||||
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
|
||||
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
|
||||
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
|
||||
`DevicePolicy=strict` reopening only the disk nodes plus the `/dev/null` family, because strict
|
||||
without `/dev/null` kills the unit at spawn (`status=208/STDIN`); a bundle-installed unit also
|
||||
gets `--cert-bundle` plus `RuntimeDirectory=dashboards-agent` (mode 0700 — the bundle unpacks its
|
||||
puts a token on argv, and runs under `ProtectSystem=strict` with **no device
|
||||
filter** — SMART device access is DAC via the `disk` group + SELinux, because kernel 7.2 matches
|
||||
a device-filter glob by name against `/proc/devices` (where NVMe is `blkext`), so the old
|
||||
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
|
||||
`--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
|
||||
`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
|
||||
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
|
||||
it prints that line with its own resolved path.)
|
||||
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
|
||||
short window or its label.
|
||||
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
|
||||
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
|
||||
@@ -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_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
|
||||
agent answers 16/16 drives with temperatures and no `pass budget … exhausted` line. The
|
||||
`deploy/install-agent.sh` template still ships the `DevicePolicy`/`DeviceAllow` lines that
|
||||
misbehaved on this host
|
||||
(a deny-all filter; the `nvme*` glob matches nothing on this kernel because
|
||||
`/proc/devices` lists NVMe as `blkext`) — the template change is its own phase.
|
||||
agent answers 16/16 drives with temperatures and no `pass budget … exhausted` line.
|
||||
The `deploy/install-agent.sh` template no longer ships the device-filter lines that
|
||||
misbehaved on this host — phase 17 task 01 removed them (the `nvme*` glob matches nothing
|
||||
on this kernel because `/proc/devices` lists NVMe as `blkext`, and a strict device policy
|
||||
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
|
||||
for the drives' shape.
|
||||
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
|
||||
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
|
||||
(`ops` has no installer grant on `deskwork`, and `driveripper`'s unit must not be rewritten, gap
|
||||
10), and flipping the URL before the bundle is installed turns that host red by construction.
|
||||
(`ops` has no installer grant on `deskwork`, and `driveripper`'s unit is reproducible only with
|
||||
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
|
||||
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
|
||||
@@ -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.
|
||||
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),
|
||||
which is the one route that must not be taken until phase 17 fixes the unit template — and no
|
||||
live change was made while pinning phase 16.
|
||||
which is the one route that must not be taken until that snapshot is re-staged from this repo:
|
||||
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)
|
||||
|
||||
|
||||
@@ -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
|
||||
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 --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)
|
||||
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
|
||||
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".
|
||||
@@ -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),
|
||||
stores it `0640 root:dashboards-agent` — the unit hands the agent the *path*, and the agent
|
||||
opens it as its own user, so the file must be readable by that user and by nobody else —
|
||||
and runs the agent under `ProtectSystem=strict`, `DevicePolicy=strict` with only the disk
|
||||
nodes and the `/dev/null` family reopened (SMART needs the former; without `/dev/null` in
|
||||
the allow list systemd cannot start the service at all), with no capabilities.
|
||||
and runs the agent under `ProtectSystem=strict` with an empty `CapabilityBoundingSet` (unless
|
||||
`--caps` is asked for, below) and **no device filter**.
|
||||
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
|
||||
|
||||
@@ -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
|
||||
# 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
|
||||
# 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/
|
||||
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
|
||||
|
||||
# 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 …"
|
||||
```
|
||||
|
||||
`--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,
|
||||
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.
|
||||
@@ -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
|
||||
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
|
||||
(deep test, calibration, `load.off`) require typing `DANGER`. The server re-checks every
|
||||
gate; hiding a button is not a safety control. Commands that would cut power are refused
|
||||
(deep test, calibration, `load.off`) require typing `DANGER`. Two more sit in front of those
|
||||
(since the login landed): a session, and this session's CSRF token when the write arrived on the
|
||||
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.
|
||||
|
||||
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 --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 --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
|
||||
# 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
|
||||
# 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
|
||||
# place, the token-reuse idiom.
|
||||
#
|
||||
# The agent is read-only by construction (it answers GET and writes nothing), runs as its
|
||||
# own system user, and gets the usual systemd lockdown. It still needs to read /proc, /sys
|
||||
# and statvfs(), which the hardening below leaves alone.
|
||||
# The agent is read-only by construction (it answers GET and writes nothing), runs as its own
|
||||
# system user (unless --as-root says otherwise, below), and gets the usual systemd lockdown. It
|
||||
# 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
|
||||
# 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
|
||||
# disk nodes instead of PrivateDevices. This is raw *read* access to the disks — the
|
||||
# standard arrangement for SMART monitoring, granted deliberately — and 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.
|
||||
# adds the agent user to the `disk` group when one exists, and the unit applies no device
|
||||
# filter at all: kernel 7.2 matches a device-filter glob by name against /proc/devices, where
|
||||
# NVMe is listed as `blkext`, so the old `/dev/nvme*` pattern denied the whole device
|
||||
# namespace instead of opening four nodes. Device access is DAC via the `disk` group, with
|
||||
# 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
|
||||
|
||||
SERVICE="dashboards-agent"
|
||||
@@ -69,6 +111,14 @@ PORT="9120"
|
||||
TOKEN="${AGENT_TOKEN:-}"
|
||||
ROTATE=0
|
||||
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"
|
||||
|
||||
usage() {
|
||||
@@ -84,6 +134,22 @@ while [ $# -gt 0 ]; do
|
||||
--token) TOKEN="$2"; shift 2 ;;
|
||||
--rotate-token) ROTATE=1; shift ;;
|
||||
--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 ;;
|
||||
--uninstall) MODE="uninstall"; shift ;;
|
||||
-h|--help) usage 0 ;;
|
||||
@@ -91,6 +157,16 @@ while [ $# -gt 0 ]; do
|
||||
esac
|
||||
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() {
|
||||
if [ "$(id -u)" -ne 0 ]; then
|
||||
# The dev workflow deploys over ssh as the unprivileged `ops` account, so "run as root"
|
||||
@@ -327,7 +403,38 @@ RuntimeDirectory=dashboards-agent
|
||||
RuntimeDirectoryMode=0700"
|
||||
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]
|
||||
Description=Homelab host agent (read-only system metrics)
|
||||
After=network-online.target
|
||||
@@ -335,7 +442,7 @@ Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
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}
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
@@ -343,29 +450,26 @@ Environment=PYTHONUNBUFFERED=1
|
||||
SyslogIdentifier=$SERVICE
|
||||
$TLS_UNIT_LINES
|
||||
# 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.
|
||||
# be taken away. smartctl is the one thing that needs a real /dev node, and it gets one the
|
||||
# way every SMART monitor on Linux does: DAC — the installer adds the service user to the
|
||||
# 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
|
||||
# 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.
|
||||
# No filter is the fix, not an omission. systemd resolves a device-filter glob by name
|
||||
# against /proc/devices (fnmatch in bpf_devices_allow_list_major), and kernel 7.2 lists NVMe
|
||||
# there as blkext (major 259) rather than nvme, so the old /dev/nvme* glob matched nothing —
|
||||
# and a strict device policy denies every node the list failed to match, so on driveripper
|
||||
# that unit locked the whole device namespace, root included
|
||||
# (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
|
||||
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
|
||||
@@ -376,13 +480,41 @@ RestrictNamespaces=true
|
||||
RestrictRealtime=true
|
||||
LockPersonality=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
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
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 enable --now "$SERVICE"
|
||||
sleep 1
|
||||
|
||||
@@ -12,6 +12,7 @@ apart from the path the script was invoked through.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import difflib
|
||||
import json
|
||||
import os
|
||||
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).
|
||||
assert "--cert-bundle" not in unit, "no bundle installed means no TLS flag on a token install"
|
||||
assert "RuntimeDirectory=dashboards-agent" not in unit
|
||||
# DevicePolicy=strict denies every node not listed, and systemd opens /dev/null for stdin
|
||||
# before it execs python: without this line the unit dies at spawn (status 208/STDIN).
|
||||
assert "DevicePolicy=strict" in unit
|
||||
assert "DeviceAllow=/dev/null" in unit, "strict device policy needs /dev/null or the unit cannot start"
|
||||
assert "DeviceAllow=/dev/nvme*" in unit, "SMART reads need the disk nodes back"
|
||||
# No device filter, on purpose (phase 17): a strict device policy denies every node the
|
||||
# allow-list fails to match, and systemd matches those globs by *name* against
|
||||
# /proc/devices — where kernel 7.2 lists NVMe as `blkext`, so the old `/dev/nvme*` entry
|
||||
# locked the whole device namespace on driveripper. Device access is DAC (the `disk` group
|
||||
# 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 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
|
||||
@@ -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"
|
||||
|
||||
|
||||
#: 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:
|
||||
"""`--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"
|
||||
|
||||
|
||||
# --- 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) --------------------------------------------
|
||||
|
||||
#: Where the installed bundle lives — one constant in the script, pinned here so a rename
|
||||
|
||||
Reference in New Issue
Block a user