feat(profiles): boot reconciliation + watchdog exclusion for on-demand (ref #896)

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 <devel@cybermind.fr>
This commit is contained in:
CyberMind-FR 2026-07-20 16:51:56 +02:00
parent 8e9ca2df12
commit dc08a8c139
9 changed files with 505 additions and 1 deletions

View File

@ -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).

View File

@ -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:

View File

@ -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}

View File

@ -0,0 +1,69 @@
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
# 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- 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

View File

@ -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)

View File

@ -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

View File

@ -0,0 +1,50 @@
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
# 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"]

View File

@ -2,7 +2,8 @@
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
# 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

View File

@ -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"]