mirror of
https://github.com/CyberMind-FR/secubox-deb.git
synced 2026-07-29 12:34:38 +00:00
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:
parent
8e9ca2df12
commit
dc08a8c139
231
.superpowers/sdd/task-11-report.md
Normal file
231
.superpowers/sdd/task-11-report.md
Normal 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).
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
69
packages/secubox-profiles/api/healthsync.py
Normal file
69
packages/secubox-profiles/api/healthsync.py
Normal 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-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
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
50
packages/secubox-profiles/tests/test_healthsync.py
Normal file
50
packages/secubox-profiles/tests/test_healthsync.py
Normal 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"]
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user