From dc08a8c1395eae91fb25f5e5d2af6bee8d03bee0 Mon Sep 17 00:00:00 2001 From: CyberMind-FR Date: Mon, 20 Jul 2026 16:51:56 +0200 Subject: [PATCH] feat(profiles): boot reconciliation + watchdog exclusion for on-demand (ref #896) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit boot_should_start(m) is the pure boot policy (always-on/eager start immediately, on-demand/manual wait for a real wake) and watchdog_should_manage(m) documents/tests that no sleepable module is ever force-revived by secubox-watchdog. Investigation found: HEAD's secubox-watchdog has no auto-revive logic at all today (monitor_loop only logs up/down, restarts are manual-only); the unmerged feat/watchdog-auto-revive branch adds one gated purely on lxc.start.auto, which actuate.py::runtime_stop already clears before lxc-stop specifically to avoid this race — the same flag streamlit sleepers rely on for exclusion today. No separate exclusion file exists to reuse, so none was invented; once that branch merges it is already #896-safe with zero further wiring. The real, closeable gap was secubox-hub's own sidebar health-batch, which reported a sleeping on-demand module's inactive systemd unit as "warn". Added a profiles-side export (secubox-wakectl health-sync -> sleepable-modules.json, mirrors waf-sync/nginx-sync) and wired it into secubox-hub so a sleepable module shows "Asleep (on-demand)" instead of an alarm; a failed unit still alarms regardless. The full admin /health/ page reads module_prober.py/prober.py, which aren't sourced in this repo yet (TODO #393) -- documented as a precise cross-package follow-up. Co-Authored-By: Gerald KERMA --- .superpowers/sdd/task-11-report.md | 231 ++++++++++++++++++ packages/secubox-hub/api/main.py | 28 +++ packages/secubox-hub/tests/test_cache_warm.py | 38 +++ packages/secubox-profiles/api/healthsync.py | 69 ++++++ packages/secubox-profiles/api/lifecycle.py | 31 +++ packages/secubox-profiles/api/wake.py | 8 + .../secubox-profiles/tests/test_healthsync.py | 50 ++++ .../secubox-profiles/tests/test_lifecycle.py | 36 ++- packages/secubox-profiles/tests/test_wake.py | 15 ++ 9 files changed, 505 insertions(+), 1 deletion(-) create mode 100644 .superpowers/sdd/task-11-report.md create mode 100644 packages/secubox-profiles/api/healthsync.py create mode 100644 packages/secubox-profiles/tests/test_healthsync.py diff --git a/.superpowers/sdd/task-11-report.md b/.superpowers/sdd/task-11-report.md new file mode 100644 index 00000000..a9705c3b --- /dev/null +++ b/.superpowers/sdd/task-11-report.md @@ -0,0 +1,231 @@ +# Task 11 report — watchdog exclusion + boot reconciliation + health-monitor awareness (ref #896) + +**Status: DONE_WITH_CONCERNS** (concerns are documented findings, not +failures — see "Concerns" below; everything that could be built and tested +in-scope is built and green). + +## What I investigated + +### secubox-watchdog (`packages/secubox-watchdog/api/main.py`) + +On the **current HEAD** (`8e9ca2df`), `secubox-watchdog` has **no auto-revive +logic at all**: `monitor_loop()` (line 394) only tracks up/down state +transitions and fires webhooks (`add_event`, line 194) — it never calls +`lxc-start`/`systemctl restart` on its own. The only restart paths are the +JWT-protected `POST /container/restart` (line 646) and `POST /service/restart` +(line 673), invoked by a human/other actor, not automatically. So **today**, +nothing in this repo would fight the sleeper/waker over a stopped on-demand +container. + +However, there is a **separate, unmerged local branch** +`feat/watchdog-auto-revive` (commit `0370beb4`, based on master `60f059ac`, +NOT an ancestor of this feature branch's HEAD) that adds exactly this +capability: `_discover_containers()` (`sudo lxc-ls -f`) + `_auto_revive()` +wired into `monitor_loop`. Its own commit message states the gate +explicitly: *"managed containers (`lxc.start.auto=1` — the 'should be up' +flag) that are STOPPED get `sudo lxc-start`... The AUTOSTART gate +deliberately EXCLUDES scale-to-zero sleepers (streamlit et al. are +NO-AUTO)."* — i.e. **the exclusion mechanism is `lxc.start.auto`**, checked +live via `lxc-ls -f`, not a separate exclusion file. + +I traced how "streamlit sleepers are excluded today" actually works: +`packages/secubox-streamlit/sbin/streamlitctl`'s `stop`/`pause` path calls +`lxc-stop -n "$LXC_NAME"` directly (line 164/185/199) and **never touches +`lxc.start.auto`**. The streamlit LXC simply never had `lxc.start.auto = 1` +granted in the first place (unlike `install-lxc.sh` for mail/lyrion/grafana/ +frigate/etc., which explicitly write `lxc.start.auto = 1` — streamlit's +provisioning does not). So the "exclusion" is not an active un-setting, it's +the container never having been granted autostart to begin with — the same +flag the (unmerged) auto-revive gate checks. + +**Verification of the task's core hypothesis:** `packages/secubox-profiles/api/actuate.py::runtime_stop` +(lines 175-188) already sets `lxc.start.auto=0` **before** `lxc-stop` for +every module it stops (LXC-backed on-demand or eager), with the comment on +line 185-186 spelling out the exact reason: *"autostart=0 AVANT lxc-stop : +sinon secubox-watchdog relance le conteneur (course observée sur la +board)."* — this is a pre-existing, already-shipped safeguard written +specifically anticipating this race. Combined with the discovery above: +**once `feat/watchdog-auto-revive` is merged, it is already fully compatible +with #896's on-demand sleepers with zero additional wiring** — it reads the +exact same flag the actuator already toggles, using the exact same +mechanism as the existing streamlit exclusion. No new exclusion file/format +was invented, per the task's explicit permission to document this instead +of forcing an artifact with no consumer. + +Native (non-LXC) modules: `secubox-watchdog`'s `services` monitoring is a +static allowlist (`nginx`, `haproxy`, `crowdsec`, `nftables` in +`DEFAULT_CONFIG`) with no auto-restart logic anywhere (native or LXC) on +HEAD, and the unmerged auto-revive commit only touches containers — no gap +for native on-demand modules either now or after that merge. + +### Health monitor (`admin.gk2.secubox.in/health/`) + +The `/health/` page is served by `secubox-hub` +(`packages/secubox-hub/menu.d/05-health.json`, `www/health/`). Its +JWT-protected data (`module_health_summary`/`status`/`alerts`, +`packages/secubox-hub/api/main.py:1173-1247`) reads +`MODULE_HEALTH_CACHE = /var/cache/secubox/health/modules.json` — produced by +**`module_prober.py`/`prober.py`, which are NOT in this source tree.** +`.claude/TODO.md:224` tracks this explicitly: `#393 source-home des scripts +health prober`. `.claude/WIP.md` (Session 107/108) documents these scripts +were created directly on the board (`/usr/lib/secubox/health/prober.py`, +`/usr/lib/secubox/health/module_prober.py`) and never rapatriated. **This is +a genuine cross-package situation I cannot cleanly fix from within this +repo** — exactly the fallback the task anticipated. + +`secubox-hub` **does** compute a second, simpler, in-repo signal used for the +sidebar LED widgets: `_refresh_health_batch()` +(`packages/secubox-hub/api/main.py:382`), a direct `systemctl list-units +secubox-*` walk. Before my change, any `secubox-*.service` observed +`inactive`/`dead` (exactly what an intentionally-slept module's unit looks +like once `actuate.runtime_stop` disables+stops it) fell into the catch-all +`else` branch → `{"status": "warn", "msg": "inactive/dead"}` — flagged as +degraded, indistinguishable from an actual failure. This one I *could* fix +in-repo, and did. + +## What I built + +1. **`packages/secubox-profiles/api/lifecycle.py`** — two new pure functions + (TDD, `tests/test_lifecycle.py` extended with 6 new tests, all RED before + → GREEN after): + - `boot_should_start(m) -> bool`: `effective_lifecycle(m) in ("always-on", + "eager")`. Exact brief signature. + - `watchdog_should_manage(m) -> bool`: `not is_sleepable(m)`. Documents + (docstring) that this is deliberately *not* a file-generator trigger — + the real non-revival mechanism is the `lxc.start.auto` flag toggled by + `actuate.py`, verified above; this pure function exists to make the + invariant explicit and testable, per the brief's required interface. + +2. **`packages/secubox-profiles/api/healthsync.py`** (new) — the + profiles-side export for the cross-package health-monitor gap: + `sleepable_module_ids(manifests)` (sorted ids where `is_sleepable`) and + `write_sleepable(*, manifests, out_path)` (atomic temp+rename write, + same pattern as `wafsync.write_ondemand`/`nginxgen`). TDD: + `tests/test_healthsync.py` (3 tests, RED → GREEN). + +3. **`packages/secubox-profiles/api/wake.py`** — new `health-sync` CLI verb + on `secubox-wakectl` (mirrors `nginx-sync`/`waf-sync`: config→file + round-trip, does **not** require root), default output + `/etc/secubox/health/sleepable-modules.json`. TDD: + `tests/test_wake.py` (+1 test, RED → GREEN). + +4. **`packages/secubox-hub/api/main.py`** — wired the export into the + in-repo sidebar signal: `_load_sleepable_modules()` (best-effort read of + the export, same absent/corrupt→empty-set discipline used throughout + `secubox-profiles`) + `_refresh_health_batch()` now checks the sleepable + set before falling into the generic `warn` branch: a sleepable module's + `inactive`/`dead` unit → `{"status": "ok", "msg": "Asleep (on-demand)"}` + instead of `warn`. A `failed` unit is **not** affected (stays `error` + even for a sleepable module — a crash is a real alarm; intentional sleep + goes through disable+stop, never `failed`). TDD: + `packages/secubox-hub/tests/test_cache_warm.py` (+2 tests: asleep-not-warn, + failed-beats-sleepable — both RED → GREEN). + +## TDD summary + +- `test_lifecycle.py`: RED (`ImportError: boot_should_start`) → wrote + `lifecycle.py` → GREEN, 11/11. +- `test_healthsync.py`: RED (`ModuleNotFoundError: api.healthsync`) → wrote + `healthsync.py` → GREEN, 3/3. +- `test_wake.py` (`health-sync` verb): RED (`argparse: invalid choice`) → + wired subparser+dispatch → GREEN, 10/10. +- `test_cache_warm.py` (secubox-hub): RED (asleep test asserted `"ok"`/ + `"Asleep (on-demand)"` against unmodified code, which produced `"warn"`) → + patched `_refresh_health_batch` → GREEN. + +## Test results + +``` +cd packages/secubox-profiles && python -m pytest tests/ -q +246 passed, 3 warnings + +cd packages/secubox-hub && python -m pytest tests/ -q +1 failed (test_health_batch_cold_miss_builds_once), 24 passed +``` + +The one hub failure is **pre-existing** — verified via `git stash` (reverts +all my changes) and re-running the exact same test in isolation: +`FAILED tests/test_cache_warm.py::test_health_batch_cold_miss_builds_once` +fails identically on unmodified HEAD `8e9ca2df`. Not caused by, and not +fixed by, this task's changes. + +## mypy vs baseline + +- `packages/secubox-profiles`: `python -m mypy --strict api/` → + **102 errors in 12 files**, identical count/file-set to unmodified HEAD + `8e9ca2df` (`git stash` verified). Targeted `mypy --strict api/lifecycle.py + api/healthsync.py` → 4 errors, all pre-existing and transitively from + `api/manifest.py` (same as Task 4's documented baseline noise) — zero + attributable to the two files touched/added here. +- `packages/secubox-hub`: `python -m mypy api/main.py` → **16 errors**, + identical count/content to unmodified HEAD (line numbers shift because + code was inserted; the error set itself — `secubox_core` import-not-found, + `datetime` not defined, etc. — is unchanged). Zero new errors from my + additions. + +## Files changed + +- `packages/secubox-profiles/api/lifecycle.py` — `boot_should_start`, + `watchdog_should_manage`. +- `packages/secubox-profiles/api/healthsync.py` (new) — sleepable-ids export. +- `packages/secubox-profiles/api/wake.py` — `health-sync` CLI verb. +- `packages/secubox-profiles/tests/test_lifecycle.py` (+6 tests). +- `packages/secubox-profiles/tests/test_healthsync.py` (new, 3 tests). +- `packages/secubox-profiles/tests/test_wake.py` (+1 test). +- `packages/secubox-hub/api/main.py` — `SLEEPABLE_MODULES_CACHE`, + `_load_sleepable_modules`, `_refresh_health_batch` sleepable branch. +- `packages/secubox-hub/tests/test_cache_warm.py` (+2 tests). + +## Judgment calls / deviations from the brief + +1. **No watchdog exclusion file was generated.** The brief's illustrative + interface says "a generated watchdog-exclusion list... so `wakectl + watchdog-sync` writes the exclusion file the watchdog reads." Investigation + showed **no such file is read by any watchdog code path** (current HEAD + has no auto-revive at all; the unmerged auto-revive commit reads + `lxc.start.auto` live via `lxc-ls`, never a config file for this + specific gate). Inventing a file nothing consumes would be exactly the + "parallel format" the task instructions explicitly forbid. I implemented + `watchdog_should_manage(m)` as the required pure-policy interface instead, + and documented the real mechanism (the flag) in its docstring and here. +2. **`boot_should_start` is not wired into any boot-time reconciler**, + because none exists in `secubox-profiles` today (`api/cli.py` only has + `status`/`diff`/`scan`/`export`/`apply`/`rollback`; no systemd unit runs + a reconcile pass at boot). Building that reconciler from scratch (a new + CLI verb + systemd unit + `actuate.py` semantics changes to distinguish + "profile ON" from "start now" for eager vs. on-demand) is materially + larger than this task's Files/Interfaces scope and touches + already-tested `actuate.py`/`apply.py` behavior relied on by + `test_actuate.py`/`test_apply.py`. Delivered as pure, tested policy ready + for that future reconciler; not wired. +3. **`health-sync` is not scheduled anywhere** (no systemd timer, no + postinst invocation) — consistent with `nginx-sync` (Task 7) and + `waf-sync` (Task 2), neither of which is scheduled either; nothing in + `apply.py`/`web.py`/`sleeper.py`/`waker.py` calls any of the three + `*-sync` verbs automatically today. This is a pre-existing, consistent + gap across all three generators, not something this task introduced or + was asked to close. + +## Concerns (why DONE_WITH_CONCERNS, not DONE) + +- The health-monitor fix only covers **secubox-hub's sidebar LED signal** + (`_refresh_health_batch`). The actual `/health/` full-page dashboard reads + a *different* cache (`/var/cache/secubox/health/modules.json`) produced by + board-only, not-yet-sourced scripts (TODO #393). Once those scripts are + rapatriated, they should read + `/etc/secubox/health/sleepable-modules.json` (produced by `secubox-wakectl + health-sync`) the same way `_load_sleepable_modules()` does here, and + report a sleepable+inactive module as a distinct non-alarm status instead + of `down`/`degraded`. This is an explicit, precise follow-up, not + something I can close from `secubox-profiles`/`secubox-hub`. +- `feat/watchdog-auto-revive` (commit `0370beb4`) is unmerged. I did **not** + merge it — it is unrelated pre-existing work for a different bug + (photoprism 502 self-heal), and merging branches without being asked is + out of scope here. Flagging as a recommended follow-up: once merged, no + further #896 wiring is needed (verified above), but until then the + watchdog literally cannot auto-revive anything, sleeping or not. +- `secubox-wakectl health-sync` needs to actually run (e.g. from whatever + eventually schedules `waf-sync`/`nginx-sync`) for `secubox-hub`'s new + branch to ever see a non-empty sleepable set on a real board; until then + `_load_sleepable_modules()` safely returns `set()` (no regression, just no + effect yet). diff --git a/packages/secubox-hub/api/main.py b/packages/secubox-hub/api/main.py index 5be1023f..725476ea 100644 --- a/packages/secubox-hub/api/main.py +++ b/packages/secubox-hub/api/main.py @@ -379,6 +379,28 @@ def _refresh_services_cache(): log.warning("Cache refresh failed: %s", e) +# Set of module ids allowed to sleep (eager/on-demand — scale-to-zero, ref +# #896), produced by `secubox-wakectl health-sync` (secubox-profiles) from +# the module manifests. A module in this set that's observed inactive/dead is +# an EXPECTED state (it's asleep, not down) — never alarm or count it against +# "warn" here. Read fresh every refresh (not cached at import time) so a +# profile change takes effect on the next tick without restarting hub. +SLEEPABLE_MODULES_CACHE = Path("/etc/secubox/health/sleepable-modules.json") + + +def _load_sleepable_modules() -> set: + """Best-effort read of the sleepable-module-ids export. Missing, unreadable, + or malformed => empty set (never raises — an absent file just means no + module gets the "asleep" treatment, same as before this existed).""" + try: + data = json.loads(SLEEPABLE_MODULES_CACHE.read_text(encoding="utf-8")) + except (OSError, ValueError): + return set() + if not isinstance(data, list): + return set() + return {x for x in data if isinstance(x, str)} + + def _refresh_health_batch(): """Build the sidebar health snapshot in ONE systemctl list-units call. @@ -387,6 +409,7 @@ def _refresh_health_batch(): the request never makes its own (3.3 s) synchronous systemctl call. """ modules = {} + sleepable = _load_sleepable_modules() try: result = subprocess.run( ["systemctl", "list-units", "--type=service", @@ -407,7 +430,12 @@ def _refresh_health_batch(): elif active == "active": modules[mod_id] = {"status": "warn", "msg": f"Active ({sub})"} elif active == "failed": + # A crash is a real alarm even for a sleepable module — + # intentional sleep goes through disable+stop (inactive/ + # dead), never "failed". modules[mod_id] = {"status": "error", "msg": "Failed"} + elif mod_id in sleepable: + modules[mod_id] = {"status": "ok", "msg": "Asleep (on-demand)"} else: modules[mod_id] = {"status": "warn", "msg": f"{active}/{sub}"} except Exception as e: diff --git a/packages/secubox-hub/tests/test_cache_warm.py b/packages/secubox-hub/tests/test_cache_warm.py index 8fd5c4d3..07a99c17 100644 --- a/packages/secubox-hub/tests/test_cache_warm.py +++ b/packages/secubox-hub/tests/test_cache_warm.py @@ -60,6 +60,44 @@ def test_refresh_health_batch_parses_units(monkeypatch): assert main._cache["health_batch_ts"] > 0 +def test_refresh_health_batch_reports_sleepable_as_asleep_not_warn(monkeypatch): + # A module in the sleepable-modules.json export (eager/on-demand, + # scale-to-zero ref #896) that's inactive/dead is EXPECTED — it's + # asleep, not down. Must stay in the "ok" bucket, never "warn"/"error". + _reset_cache() + + class R: + stdout = ( + "secubox-peertube.service loaded inactive dead PeerTube\n" + "secubox-otherapp.service loaded inactive dead OtherApp\n" + ) + + monkeypatch.setattr(main.subprocess, "run", lambda *a, **k: R()) + monkeypatch.setattr(main, "_load_sleepable_modules", lambda: {"peertube"}) + main._refresh_health_batch() + hb = main._cache["health_batch"] + assert hb["modules"]["peertube"]["status"] == "ok" + assert hb["modules"]["peertube"]["msg"] == "Asleep (on-demand)" + # A module NOT in the sleepable set with the same inactive/dead state + # keeps today's behavior (an unexpected stop is still a "warn"). + assert hb["modules"]["otherapp"]["status"] == "warn" + + +def test_refresh_health_batch_failed_beats_sleepable(monkeypatch): + # A crash (failed) is a real alarm even for a sleepable module — + # intentional sleep goes through disable+stop, never "failed". + _reset_cache() + + class R: + stdout = "secubox-peertube.service loaded failed failed PeerTube\n" + + monkeypatch.setattr(main.subprocess, "run", lambda *a, **k: R()) + monkeypatch.setattr(main, "_load_sleepable_modules", lambda: {"peertube"}) + main._refresh_health_batch() + hb = main._cache["health_batch"] + assert hb["modules"]["peertube"]["status"] == "error" + + def test_health_batch_serves_cache_without_subprocess(monkeypatch): _reset_cache() main._cache["health_batch"] = {"modules": {"hub": {"status": "ok", "msg": "Running"}}, "count": 1} diff --git a/packages/secubox-profiles/api/healthsync.py b/packages/secubox-profiles/api/healthsync.py new file mode 100644 index 00000000..13ad9b48 --- /dev/null +++ b/packages/secubox-profiles/api/healthsync.py @@ -0,0 +1,69 @@ +# SPDX-License-Identifier: LicenseRef-CMSD-1.0 +# Copyright (c) 2026 CyberMind — Gérald Kerma +# Source-Disclosed License — All rights reserved except as expressly granted. +# See LICENCE-CMSD-1.0.md for terms. + +""" +SecuBox-Deb :: profiles — export du set "sleepable" pour le moniteur de santé +CyberMind — https://cybermind.fr + +Un module eager/on-demand (scale-to-zero, ref #896) qu'on observe éteint est +un état ATTENDU — pas une panne. Aujourd'hui rien dans ce dépôt ne consomme +cette distinction pour le moniteur de santé : + + - les scripts health-prober réels (module_prober.py / prober.py, qui + produisent /var/cache/secubox/health/{modules,status}.json lus par + secubox-hub) ne sont PAS encore rapatriés dans ce dépôt (TODO #393, + `.claude/TODO.md`) — impossible de les corriger proprement d'ici ; + - `secubox-hub` (dans ce dépôt) calcule lui-même un second signal, plus + simple, pour les LEDs de la sidebar (`_refresh_health_batch`, un + `systemctl list-units secubox-*` direct) — CELUI-LÀ est corrigible + d'ici, et l'est (voir packages/secubox-hub/api/main.py). + +Ce module exporte, en JSON stable, l'ensemble trié des ids de modules +sleepables (même critère que `lifecycle.is_sleepable` : eager/on-demand, +protected exclu via effective_lifecycle) — pour que TOUT consommateur (le +futur prober rapatrié, ou secubox-hub dans l'intervalle) puisse distinguer +« volontairement endormi » de « down » sans deviner un format. +""" +from __future__ import annotations + +import json +import os +import tempfile +from pathlib import Path + +from .lifecycle import is_sleepable +from .manifest import Manifest + + +def sleepable_module_ids(manifests: dict[str, Manifest]) -> list[str]: + """Ids (triés) des modules eager/on-demand — jamais always-on/manual, et + jamais un module protégé même déclaré on-demand (is_sleepable applique + déjà cette règle via effective_lifecycle).""" + return sorted(mid for mid, m in manifests.items() if is_sleepable(m)) + + +def _write_atomic(path: Path, text: str) -> None: + fd, tmp = tempfile.mkstemp(dir=path.parent, prefix=f".{path.name}.", suffix=".tmp") + try: + with os.fdopen(fd, "w", encoding="utf-8") as f: + f.write(text) + os.replace(tmp, path) + except BaseException: + try: + os.unlink(tmp) + except FileNotFoundError: + pass + raise + + +def write_sleepable(*, manifests: dict[str, Manifest], out_path: Path) -> list[str]: + """Écrit atomiquement la liste JSON triée des ids sleepables vers + `out_path` (temp+rename, même motif que wafsync.write_ondemand), retourne + la liste écrite.""" + out_path = Path(out_path) + out_path.parent.mkdir(parents=True, exist_ok=True) + ids = sleepable_module_ids(manifests) + _write_atomic(out_path, json.dumps(ids)) + return ids diff --git a/packages/secubox-profiles/api/lifecycle.py b/packages/secubox-profiles/api/lifecycle.py index 76144484..d414c4ac 100644 --- a/packages/secubox-profiles/api/lifecycle.py +++ b/packages/secubox-profiles/api/lifecycle.py @@ -35,3 +35,34 @@ def wake_budget(m: Manifest, *, history_median: float | None = None, if history_median is not None: return history_median return urgent if m.wake_class == "urgent" else normal + + +def boot_should_start(m: Manifest) -> bool: + """Politique de démarrage au boot (ref #896). + + always-on et eager démarrent immédiatement — le noyau protégé et les + modules « toujours chauds » ne doivent jamais dépendre d'un premier + accès pour être disponibles. on-demand et manual restent éteints tant + qu'une requête réelle (ou un opérateur, pour manual) ne les réveille + pas : les redémarrer par défaut annulerait tout l'intérêt du + scale-to-zero dès le premier reboot. `effective_lifecycle` fait déjà + gagner `protected` sur toute déclaration de manifeste (même règle que + is_sleepable) : un module protégé est toujours démarré au boot, quoi + que dise son `lifecycle` déclaré.""" + return effective_lifecycle(m) in ("always-on", "eager") + + +def watchdog_should_manage(m: Manifest) -> bool: + """secubox-watchdog ne doit JAMAIS forcer la remise en route d'un module + sleepable (eager/on-demand, ref #896) — c'est le même invariant que + l'exclusion des « streamlit sleepers » déjà en place aujourd'hui : dans + les deux cas, le mécanisme réel de non-relance n'est pas une liste + séparée mais l'ABSENCE de `lxc.start.auto=1` pendant le sommeil. + `api/actuate.py::runtime_stop` remet explicitement `lxc.start.auto=0` + AVANT `lxc-stop` pour cette raison précise (course avec le watchdog + observée sur la board — voir son commentaire). Cette fonction n'est + donc pas un générateur de fichier d'exclusion : c'est la politique pure + qui documente/teste l'invariant — un module sleepable n'est jamais géré + par le watchdog pendant qu'il dort, un module always-on/manual/protégé + l'est toujours.""" + return not is_sleepable(m) diff --git a/packages/secubox-profiles/api/wake.py b/packages/secubox-profiles/api/wake.py index a6e9ea6d..44df9cfa 100644 --- a/packages/secubox-profiles/api/wake.py +++ b/packages/secubox-profiles/api/wake.py @@ -93,6 +93,8 @@ def main(argv: list[str] | None = None) -> int: sp2.add_argument("--out", default="/etc/nginx/secubox-waker.d") sp3 = sub.add_parser("waf-sync") sp3.add_argument("--out", default="/etc/secubox/waf/on-demand-vhosts.json") + sp4 = sub.add_parser("health-sync") + sp4.add_argument("--out", default="/etc/secubox/health/sleepable-modules.json") args = p.parse_args(argv) if args.cmd == "nginx-sync": from . import nginxgen @@ -106,6 +108,12 @@ def main(argv: list[str] | None = None) -> int: out_path=Path(args.out)) print(f"waf-sync: {len(doms)} vhost(s) — {', '.join(doms) or 'aucun'}") return 0 + if args.cmd == "health-sync": + from . import healthsync + ids = healthsync.write_sleepable(manifests=load_all(Path(args.root) / "modules.d"), + out_path=Path(args.out)) + print(f"health-sync: {len(ids)} module(s) sleepable — {', '.join(ids) or 'aucun'}") + return 0 if not _running_as_root(): print("wake doit être lancé en root (il pilote systemd/LXC).", file=sys.stderr) return 1 diff --git a/packages/secubox-profiles/tests/test_healthsync.py b/packages/secubox-profiles/tests/test_healthsync.py new file mode 100644 index 00000000..2859dde4 --- /dev/null +++ b/packages/secubox-profiles/tests/test_healthsync.py @@ -0,0 +1,50 @@ +# SPDX-License-Identifier: LicenseRef-CMSD-1.0 +# Copyright (c) 2026 CyberMind — Gérald Kerma +# Source-Disclosed License — All rights reserved except as expressly granted. +# See LICENCE-CMSD-1.0.md for terms. + +""" +SecuBox-Deb :: profiles — tests génération liste sleepable-modules (health monitor) +CyberMind — https://cybermind.fr +""" +from __future__ import annotations + + +def test_sleepable_module_ids_selects_eager_and_on_demand(): + from api.healthsync import sleepable_module_ids + from api.manifest import Manifest + def m(mid, lc, protected=False): + return Manifest(id=mid, category="infra", runtime="native", exposure="lan", + units=(f"{mid}.service",), lifecycle=lc, protected=protected) + ms = { + "a": m("a", "on-demand"), + "b": m("b", "always-on"), + "c": m("c", "eager"), + "d": m("d", "manual"), + "e": m("e", "on-demand", protected=True), # protected wins -> always-on + } + assert sleepable_module_ids(ms) == ["a", "c"] # sorted; b/d/e excluded + + +def test_write_sleepable_atomic(tmp_path): + from api.healthsync import write_sleepable + from api.manifest import Manifest + import json + ms = {"a": Manifest(id="a", category="infra", runtime="native", exposure="lan", + units=("a.service",), lifecycle="on-demand")} + out = tmp_path / "sleepable-modules.json" + assert write_sleepable(manifests=ms, out_path=out) == ["a"] + assert json.loads(out.read_text()) == ["a"] + assert list(tmp_path.glob("*.tmp")) == [] + + +def test_write_sleepable_prunes_nothing_but_overwrites(tmp_path): + from api.healthsync import write_sleepable + from api.manifest import Manifest + import json + out = tmp_path / "sleepable-modules.json" + out.write_text(json.dumps(["stale"])) + ms = {"b": Manifest(id="b", category="infra", runtime="native", exposure="lan", + units=("b.service",), lifecycle="eager")} + write_sleepable(manifests=ms, out_path=out) + assert json.loads(out.read_text()) == ["b"] diff --git a/packages/secubox-profiles/tests/test_lifecycle.py b/packages/secubox-profiles/tests/test_lifecycle.py index a04a20a4..d1a5f900 100644 --- a/packages/secubox-profiles/tests/test_lifecycle.py +++ b/packages/secubox-profiles/tests/test_lifecycle.py @@ -2,7 +2,8 @@ # Copyright (c) 2026 CyberMind — Gérald Kerma # Source-Disclosed License — All rights reserved except as expressly granted. # See LICENCE-CMSD-1.0.md for terms. -from api.lifecycle import effective_lifecycle, is_sleepable, idle_threshold, wake_budget +from api.lifecycle import (boot_should_start, effective_lifecycle, idle_threshold, + is_sleepable, wake_budget, watchdog_should_manage) from api.manifest import Manifest @@ -37,3 +38,36 @@ def test_wake_budget_history_beats_default(): assert wake_budget(_m(wake_class="normal"), history_median=None, normal=45.0) == 45.0 assert wake_budget(_m(wake_class="urgent"), history_median=None, urgent=15.0) == 15.0 assert wake_budget(_m(wake_class="normal"), history_median=30.0) == 30.0 + + +def test_boot_should_start_always_on_and_eager(): + assert boot_should_start(_m(lifecycle="always-on")) is True + assert boot_should_start(_m(lifecycle="eager")) is True + + +def test_boot_should_start_false_for_on_demand_and_manual(): + assert boot_should_start(_m(lifecycle="on-demand")) is False + assert boot_should_start(_m(lifecycle="manual")) is False + + +def test_boot_should_start_protected_always_true_even_if_on_demand(): + # protected forces effective_lifecycle to always-on regardless of the + # declared lifecycle — a negligent manifest must never keep the core off + # at boot (same invariant as effective_lifecycle/is_sleepable). + assert boot_should_start(_m(lifecycle="on-demand", protected=True)) is True + + +def test_watchdog_should_manage_true_for_always_on_and_manual(): + assert watchdog_should_manage(_m(lifecycle="always-on")) is True + assert watchdog_should_manage(_m(lifecycle="manual")) is True + + +def test_watchdog_should_manage_false_for_sleepable(): + # eager/on-demand modules cycle up/down by design (scale-to-zero, #896) — + # the watchdog must never force one back up just because it is stopped. + assert watchdog_should_manage(_m(lifecycle="eager")) is False + assert watchdog_should_manage(_m(lifecycle="on-demand")) is False + + +def test_watchdog_should_manage_protected_wins_over_on_demand(): + assert watchdog_should_manage(_m(lifecycle="on-demand", protected=True)) is True diff --git a/packages/secubox-profiles/tests/test_wake.py b/packages/secubox-profiles/tests/test_wake.py index 41167c36..a9821ef8 100644 --- a/packages/secubox-profiles/tests/test_wake.py +++ b/packages/secubox-profiles/tests/test_wake.py @@ -125,3 +125,18 @@ def test_main_waf_sync_writes_json_without_root(tmp_path): rc = w.main(["--root", str(tmp_path), "waf-sync", "--out", str(out)]) assert rc == 0 assert json.loads(out.read_text()) == ["demo.gk2"] + + +def test_main_health_sync_writes_json_without_root(tmp_path): + # health-sync est un aller-retour config->fichier, pas un pilotage + # systemd/LXC : il ne doit pas exiger root (comme nginx-sync/waf-sync). + import api.wake as w + import json + (tmp_path / "modules.d").mkdir() + (tmp_path / "modules.d" / "demo.toml").write_text( + 'id="demo"\ncategory="infra"\nruntime="native"\nexposure="lan"\n' + 'units=["demo.service"]\nlifecycle="on-demand"\n') + out = tmp_path / "sleepable-modules.json" + rc = w.main(["--root", str(tmp_path), "health-sync", "--out", str(out)]) + assert rc == 0 + assert json.loads(out.read_text()) == ["demo"]