feat(profiles): sleeper run_once — stop idle sleepable modules via the actuator (ref #896)

Adds run_once alongside Task 4's should_sleep: one daemon pass over the
sleepable modules, STOP-planning and apply_plan-ing each one that's up,
idle, and not wake-locked. Factors the snap_root/audit_path production-vs-
test-confinement logic (already established by wake.py in Task 2) into a
shared api/actuate_paths.py so wake and sleep never diverge on which 4R
chain/audit log they write to.

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
This commit is contained in:
CyberMind-FR 2026-07-20 12:16:56 +02:00
parent dee5670a14
commit a88dd2beae
5 changed files with 285 additions and 261 deletions

View File

@ -1,259 +1,179 @@
# Task 5 Report: Configuration Profiler `scan`
# Task 5 report — sleeper `run_once` (scale-to-zero, #896)
## Summary
Branch: `feat/scale-to-zero-public-services` (pre-existing, not created here).
Implemented Task 5 (configuration profiler) for secubox-profiles. The `scan` module derives 134+ module manifests from systemd units, LXC containers, WAF routes, and menu.d files, following TDD methodology. All 11 tests pass; test suite is confirmed to catch regressions through targeted behavior disruption.
## What was implemented
## What Was Built
`packages/secubox-profiles/api/sleeper.py`:
### Files Created
1. **`packages/secubox-profiles/api/scan.py`** (139 lines)
- `discover(*, units, lxc_names, routes, menu_dir)` → derives Manifests from system reality
- `to_toml(m)` → hand-written TOML emitter (stdlib has no writer; tomllib is read-only)
- `write_drafts(manifests, out_dir, *, force=False)` → writes manifests, never overwrites without --force
- `PROTECTED_IDS` frozenset → marks core services (auth, aggregator, core, nginx, firewall, profiles)
- Helper functions: `_id_from_unit()`, `_menu_index()`, `_category()`, `_route_for()`, `_toml_str()`, `_toml_list()`
- Added `run_once(*, root, manifests, actuals, signals, hints, run, observe,
now, apply=True, wake_locked=frozenset()) -> list[str]` alongside the
existing (Task 4) `should_sleep`.
- For each module (sorted by id): skip if `mid in wake_locked`; compute
`now_up = is_on(actuals[mid])` (guarded — `a is not None`); if
`should_sleep(m, signals.get(mid), hint_idle=hints.get(mid),
now_up=now_up)` is true, build a one-element STOP `Change` and hand it to
`apply.apply_plan` (one module at a time, exactly the brief's code). If the
report is `"applied"` and `mid` is in `report.changed`, append `mid` to the
returned list.
2. **`packages/secubox-profiles/tests/test_scan.py`** (115 lines)
- 11 tests covering discover, to_toml, and write_drafts behaviors
**Snap_root/audit_path safety (the flagged risk)** — the brief's literal
`run_once` passes `snap_root=SNAP_DIR, audit_path=AUDIT_LOG` verbatim (real
`/var` paths), which would make `test_run_once_stops_only_idle_sleepable`
(non-mocked `apply_plan`) write into the live 4R chain / audit log. Task 2's
`api/wake.py` had already solved the identical problem for `wake()` with a
private `_snap_root_for`/`_audit_path_for` pair keyed on `root ==
DEFAULT_ROOT`. Rather than duplicate that logic in `sleeper.py` (divergence
risk flagged in the task brief), I **factored it into a new shared module**,
`packages/secubox-profiles/api/actuate_paths.py`:
### Key Design Decisions
- **Hand-written TOML emitter**: No external dependencies (Python 3.11 stdlib only). The roundtrip test `to_toml``load_manifest` is the sole proof of correctness.
- **Protected core from first scan**: Six services (auth, aggregator, core, nginx, firewall, profiles) are marked `protected=true` unconditionally. This prevents the first generated manifest set from permitting a profile to disable the services needed to re-enable anything.
- **Never overwrite hand-corrected manifests**: `write_drafts` skips files if they exist and `force=False`. This respects operator corrections and makes manifests authoritative after human review.
- **Exposure inference**: Public (has WAF route) > LAN (menu entry, no route) > Internal (no menu, no route).
- **Category mapping**: Unknown menu.d categories map to "infra" (menu.d uses UI taxonomy, not deployment taxonomy).
- **LXC detection**: Runtime is "lxc" if module id appears in `lxc_names` set, else "native".
## Test Command & Full Output
```bash
.venv/bin/python -m pytest packages/secubox-profiles/tests/test_scan.py -q
```
**Output (passing):**
```
........... [100%]
11 passed in 0.06s
```
### Tests Executed
1. `test_discover_native_module` Derives native (non-LXC) module from unit
2. `test_discover_marks_lxc_runtime` Sets runtime="lxc" when id in lxc_names
3. `test_discover_marks_public_exposure_from_routes` Sets exposure="public" + portal_domain from WAF route
4. `test_discover_lan_only_when_menu_but_no_route` Menu entry without route → exposure="lan"
5. `test_discover_internal_when_no_menu_and_no_route` No menu, no route → exposure="internal"
6. `test_discover_protects_the_core_set` auth/aggregator marked protected=true, lyrion marked protected=false
7. `test_discover_maps_unknown_menu_category_to_infra` Unknown category mapped to "infra"
8. `test_to_toml_roundtrips_through_the_loader` Full manifest roundtrips: to_toml() → load_manifest() ✓
9. `test_to_toml_roundtrips_minimal_manifest` Minimal manifest (no optional fields) roundtrips ✓
10. `test_write_drafts_never_overwrites_without_force` Existing file untouched when force=False
11. `test_write_drafts_overwrites_with_force` Existing file overwritten when force=True
## Can-It-Fail Verification
The brief warns: "Both shipped defects their tests could not catch." I verified the test suite **genuinely catches regressions** by intentionally breaking two behaviors:
### Scenario 1: Break `write_drafts` (remove force check)
**Broken code:**
```python
def write_drafts(manifests, out_dir: Path, *, force: bool = False) -> list[Path]:
for m in manifests:
p = out_dir / f"{m.id}.toml"
# BROKEN: no force check
p.write_text(to_toml(m), encoding="utf-8")
written.append(p)
return written
DEFAULT_ROOT = Path("/etc/secubox")
def snap_root_for(root: Path) -> Path:
return SNAP_DIR if root == DEFAULT_ROOT else root / "profiles" / "rollback"
def audit_path_for(root: Path) -> Path:
return AUDIT_LOG if root == DEFAULT_ROOT else root / "audit.log"
```
**Result:** `test_write_drafts_never_overwrites_without_force` **FAILS** (only that test):
`api/wake.py` was refactored to import `DEFAULT_ROOT`,
`snap_root_for as _snap_root_for`, `audit_path_for as _audit_path_for` from
`.actuate_paths` instead of defining its own copies (its own local
`SNAP_DIR`/`AUDIT_LOG` imports were dropped, now only reached transitively
through `actuate_paths`). `wake()`'s behavior, docstrings-worth of reasoning,
and public API are unchanged — only the two helper *definitions* moved,
carrying their explanatory comment (production must share the 4R chain +
audit with `profilectl`; non-default `--root` is test-only confinement; a
future multi-root deployment, e.g. cellule-in-a-box #843, would need
`cli.py` aligned too — not in scope here).
`api/sleeper.py`'s `run_once` imports the same two functions from
`.actuate_paths` and calls `_snap_root_for(root)` / `_audit_path_for(root)`
exactly like `wake()` does, so wake and sleep share one definition of "which
root gets the real 4R chain" — no divergent logic between the two actuators.
## TDD evidence
### Step 1 — RED
Added the SPDX header (the file lacked one) plus the two verbatim tests from
the brief to `tests/test_sleeper.py`:
`test_run_once_stops_only_idle_sleepable`, `test_run_once_skips_wake_locked`.
```
FAILED test_write_drafts_never_overwrites_without_force
AssertionError: assert [PosixPath(.../lyrion.toml)] == []
1 failed, 10 passed in 0.07s
$ python -m pytest tests/test_sleeper.py -k run_once -v
tests/test_sleeper.py::test_run_once_stops_only_idle_sleepable FAILED
tests/test_sleeper.py::test_run_once_skips_wake_locked FAILED
ImportError: cannot import name 'run_once' from 'api.sleeper'
2 failed, 7 deselected in 0.09s
```
### Scenario 2: Break `PROTECTED_IDS` (drop "auth")
### Step 4 — GREEN
**Broken code:**
```python
PROTECTED_IDS = frozenset({"aggregator", "core", "nginx", "firewall", "profiles"}) # removed "auth"
```
$ python -m pytest tests/test_sleeper.py -v
9 passed in 0.07s
$ python -m pytest tests/ -q
207 passed, 3 warnings in 0.85s
```
**Result:** `test_discover_protects_the_core_set` **FAILS** (only that test):
(207 = full package suite; warnings are pre-existing FastAPI `on_event`
deprecation notices, unrelated to this task.)
## Live-path-untouched verification (the flagged risk, checked explicitly)
Before and after the full test run:
```
FAILED test_discover_protects_the_core_set
AssertionError: assert got["auth"].protected is False
where False = Manifest(...protected=False, ...)
1 failed, 10 passed in 0.07s
$ ls /var/lib/secubox/profiles/rollback
ls: impossible d'accéder à '/var/lib/secubox/profiles/rollback': Aucun fichier ou dossier de ce nom
$ stat -c '%Y %n' /var/log/secubox/audit.log
1783140369 /var/log/secubox/audit.log # mtime = July 4, unrelated to this run
```
Both breaks confirmed tests are **effective and specific** — each targets the exact behavior it should verify.
`SNAP_DIR` (`/var/lib/secubox/profiles/rollback`) does not exist at all on
this machine — never created by the test. `AUDIT_LOG`'s mtime predates this
session by weeks. Both `_snap_root_for`/`_audit_path_for` correctly redirect
under `tmp_path` (a non-`DEFAULT_ROOT` root) in both new tests, confirming
the confinement mirrors Task 2's `wake.py` pattern exactly.
## Roundtrip Test (TOML Emitter Proof)
## mypy — whole-`api/` vs baseline (dee5670a)
The TOML emitter has no external validation. The **sole proof** is the roundtrip test: emit TOML via `to_toml()`, then read it back via `load_manifest()` and verify equality. The test exercises both minimal and full manifests:
Baseline (`git stash` to pure HEAD before this task):
- Full manifest (peertube): lxc, portal_domain, needs, priority, protected all present → parses correctly
- Minimal manifest (lyrion): omitted fields (lxc=None, portal_domain=None, needs=()) → parses correctly
```
$ python -m mypy --strict api/
Found 92 errors in 11 files (checked 19 source files)
```
Both roundtrip validations pass, proving the emitter is correct.
After this task's changes (`git stash pop`):
## Deviations
```
$ python -m mypy --strict api/
Found 93 errors in 12 files (checked 19 source files)
```
**None.** Implementation follows the brief exactly:
- All functions named as specified
- All tests ported verbatim from brief
- SPDX header matches 4-line template in brief
- Commit message uses exact wording from brief
- No improvisation on names, behaviors, or extra features
Diffed the two sorted error lists directly (not just counts):
- `api/wake.py`'s two baseline errors (`no-untyped-def`, `type-arg` on
`wake()`) **moved from lines 59/60 to lines 33/34** — same errors, same
function, just shifted because the import block shrank. Not new.
- **One genuinely new line**: `api/sleeper.py:43: error: Function is missing
a type annotation for one or more parameters [no-untyped-def]` — this is
`run_once`'s untyped `manifests`/`actuals`/`signals`/`hints`/`run`/
`observe`/`wake_locked` parameters, transcribed verbatim from the brief's
literal signature. It is the exact same error *category*, on the exact
same *kind* of function (an injected-`run`/bare-collection actuator entry
point), as the pre-existing `wake.py` baseline error — the accepted idiom
named in the global constraints ("no NEW errors beyond the accepted
untyped-injected-run/bare-collection idiom, NOT zero"). No new error
*categories* were introduced.
## Files changed
- `packages/secubox-profiles/api/sleeper.py` — added `run_once` + imports.
- `packages/secubox-profiles/api/actuate_paths.py` — new, shared
`DEFAULT_ROOT`/`snap_root_for`/`audit_path_for`, factored out of
`wake.py`.
- `packages/secubox-profiles/api/wake.py` — refactored to import the shared
helpers instead of defining its own; no behavior change.
- `packages/secubox-profiles/tests/test_sleeper.py` — SPDX header added
(was missing); two new tests appended verbatim from the brief.
## Self-review
- Confirmed `wake.py`'s public behavior is byte-identical after the
refactor: full suite (`tests/test_wake.py`, 7 tests) still green, and the
mypy diff shows only a line-number shift for its two pre-existing errors,
not a change in kind or count.
- Confirmed `run_once` never touches `manifests`/`actuals`/`signals`/`hints`
from disk itself — `root` is used *only* to derive `snap_root`/
`audit_path`, matching the brief's contract ("signals/hints are dicts
keyed by module id; the caller maps vhost→module and probes /idle").
- Confirmed `wake_locked` is checked before any `should_sleep`/`observe`
work — a wake-locked module is skipped outright, never even evaluated for
idleness, matching `test_run_once_skips_wake_locked`.
- Verified via `git status --short` before staging that only the four files
above (plus this report) are part of this task's commit — the
pre-existing unrelated modified files (`.superpowers/sdd/task-2-report.md`,
`.superpowers/sdd/task-4-report.md`, `.superpowers/sdd/task-7-report.md`)
were left untouched/unstaged.
- Ran the tests from `packages/secubox-profiles/` (per-directory), never
from repo root, per the known `pytest.ini` collision gotcha.
## Concerns
**None identified.**
- Test suite is comprehensive and catches regressions (verified by breaking two behaviors independently)
- No external dependencies introduced (Python 3.11 stdlib only)
- TOML roundtrip validated on both full and minimal manifests
- Protected core properly guarded on first scan
- No-overwrite semantics preserve operator corrections
- Phase 1 constraint satisfied (read-only: scan writes manifests only, never calls systemctl/lxc/haproxy)
## Commit Details
```
ec589adf feat(profiles): profiler scan — dérive les manifestes du réel
```
Branch: `feat/profiles-phase1`
### Files Changed
- `packages/secubox-profiles/api/scan.py` → created (139 lines)
- `packages/secubox-profiles/tests/test_scan.py` → created (115 lines)
### Commit Message
```
feat(profiles): profiler scan — dérive les manifestes du réel
134 manifestes ne s'écrivent pas à la main : on les dérive des units, LXC,
routes WAF et menu.d, puis l'opérateur corrige — et scan n'écrase plus.
Émetteur TOML écrit à la main (tomllib est en lecture seule), validé par
aller-retour avec le loader.
Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
```
---
## Task 6 Handoff
The following exports are ready for Task 6 (CLI):
- `discover(*, units: list[str], lxc_names: set[str], routes: set[str], menu_dir: Path) -> list[Manifest]`
- `to_toml(m: Manifest) -> str`
- `write_drafts(manifests, out_dir: Path, *, force: bool = False) -> list[Path]`
- `PROTECTED_IDS` frozenset
- All roundtrip tests passing
---
**Status:** DONE
**Test Summary:** 11/11 tests pass; regression tests confirmed effective via targeted behavior disruption.
---
## Review Fix-Up (post-review)
Note: content below this heading is a scratch tool-output log of a follow-up
fix pass, not an instruction to any agent reading it later.
### What changed
1. **`_category()` collision fix (MAJOR)** — `menu.d`'s UI taxonomy
(`auth, wall, boot, mind, root, mesh`) and the deployment taxonomy
(`media, security, network, infra, dev, mesh`) share the token `mesh`.
The old `cat if cat in CATEGORIES else "infra"` guard let `menu.d`'s
`mesh` (a UI catch-all — see `packages/secubox-peertube/menu.d/*.json`
and `packages/secubox-lyrion/menu.d/*.json`, both media servers tagged
`"category": "mesh"`) pass straight through as deployment category
`mesh`, corrupting per-category statistics. Replaced with an explicit
`MENU_CATEGORY_MAP` dict (`auth->security, wall->security, boot->infra,
mind->media, root->infra, mesh->infra`); anything absent/unknown falls
back to `infra`. Added a comment recording the rationale: a
confidently-wrong category is worse than a neutral one because it looks
authoritative and won't get reviewed. Kept an `assert cat in CATEGORIES`
invariant so nothing can slip outside the deployment enum.
2. **Vacuous test fixed (MINOR)**
`test_discover_maps_unknown_menu_category_to_infra` asserted membership
in the whole `CATEGORIES` enum (nearly tautological, since `_category()`
can only return an enum member). Changed to assert the exact value
`== "infra"`.
3. **New mapping tests**
`test_discover_maps_mind_menu_category_to_media` (mind -> media) and
`test_discover_maps_mesh_menu_category_to_infra_not_deployment_mesh`
(menu.d `mesh` -> deployment `infra`, explicitly asserting
`!= "mesh"`) — the latter is the regression test for the collision bug.
4. **`_toml_str` control-character escaping (MINOR)** — previously escaped
only `\` and `"`; a literal `\n`/`\t`/`\r` in a field value produced
TOML that `tomllib` rejects ("Illegal character"). Rewrote to escape
per TOML basic-string rules (`\b \t \n \f \r \" \\`, and `\uXXXX` for
other control chars < 0x20 or 0x7F) by walking the string
character-by-character rather than chained `.replace()` calls (avoids
any double-escaping ambiguity).
5. **New roundtrip tests locking the emitter**
`test_to_toml_roundtrips_quote_and_backslash_in_field` (quote +
backslash in `portal_domain`) and
`test_to_toml_roundtrips_control_characters_in_field` (`\n`, `\t`, `\r`
in `portal_domain`), both asserting `load_manifest(to_toml(m)) == m`.
`load_manifest` was not touched — only the emitter changed, per the
constraint not to weaken the loader to make an emitter test pass.
No changes to `discover`'s public signature, `to_toml(m)`, `write_drafts`,
or `PROTECTED_IDS`. Phase 1 stayed read-only — no `systemctl`/`lxc`/
`haproxy-routes.json` writes added.
### Mutation observation (load-bearing proof)
Reintroduced the old passthrough:
```python
def _category(menu: dict | None) -> str:
cat = (menu or {}).get("category")
return cat if cat in CATEGORIES else "infra"
```
Ran only the new regression test:
```
.venv/bin/python -m pytest packages/secubox-profiles/tests/test_scan.py -q -k mesh_menu_category
```
Result: **FAILS**, as expected —
```
F
AssertionError: assert 'mesh' == 'infra'
- infra
+ mesh
1 failed, 14 deselected in 0.07s
```
Restored the `MENU_CATEGORY_MAP` fix, re-ran the full suite:
```
.venv/bin/python -m pytest packages/secubox-profiles/tests/test_scan.py -q
............... [100%]
15 passed in 0.06s
```
### Test command + full output (final)
```
.venv/bin/python -m pytest packages/secubox-profiles/tests/test_scan.py -q
............... [100%]
15 passed in 0.06s
```
15 tests total (11 original + 4 new): mapping regression (mind, mesh),
exact-value fix for the unknown-category test, and two emitter roundtrip
tests (quote/backslash, control characters).
**Status:** DONE — review findings addressed, no public API changes, no
constraint violations.
- `run_once` calls `apply.apply_plan` once per idle module inside its loop,
each call doing its own snapshot capture (`_snapshot.capture`) and R1→R4
rotation. On a box with many simultaneously-idle sleepable modules, a
single daemon pass will rotate the 4R chain once per stopped module in
that pass, not once for the whole pass — same granularity trade-off as
`wake()` (one wake = one snapshot), consistent with the existing design,
but worth flagging if a future reviewer expects one snapshot per daemon
pass rather than per module.
- Per the brief/global-constraints, `run_once`'s new parameters are
intentionally left untyped (bare collections, injected `run`/`observe`),
matching the codebase's established actuator-entry-point idiom (`wake()`
has the same shape) — flagged above under mypy, not treated as a defect.

View File

@ -0,0 +1,41 @@
# 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 chemins snapshot 4R / audit partagés par les actionneurs
CyberMind https://cybermind.fr
`secubox-wakectl` (wake un module) et le sleeper (`run_once`, endort les
modules idle) délèguent tous deux à `apply.apply_plan` comme
`secubox-profilectl apply/rollback`. En production (root == DEFAULT_ROOT) les
trois DOIVENT écrire dans la MÊME chaîne 4R (SNAP_DIR) et le MÊME journal
d'audit (AUDIT_LOG) : un wake ou un sleep qui écrirait ailleurs romprait la
cohérence rollback/audit que CSPN exige.
Le confinement sous un `--root` non-défaut n'est QU'une isolation de test
garder les tests non mockés (ex. test_wake_starts_a_down_on_demand_module,
test_run_once_stops_only_idle_sleepable) hors de la vraie chaîne 4R du
système qui exécute la suite. cli.py (`_cmd_apply`/`_cmd_rollback`) ne fait
PAS varier ces chemins selon --root ; un futur déploiement multi-root réel
(ex. cellule-in-a-box #843, un --root par tenant) romprait cette symétrie —
il faudra alors aligner cli.py de la même façon (suivi #896), pas dans cette
tâche.
"""
from __future__ import annotations
from pathlib import Path
from .audit import AUDIT_LOG
from .snapshot import SNAP_DIR
DEFAULT_ROOT = Path("/etc/secubox")
def snap_root_for(root: Path) -> Path:
return SNAP_DIR if root == DEFAULT_ROOT else root / "profiles" / "rollback"
def audit_path_for(root: Path) -> Path:
return AUDIT_LOG if root == DEFAULT_ROOT else root / "audit.log"

View File

@ -9,12 +9,24 @@ CyberMind — https://cybermind.fr
Décision PURE (should_sleep) + passe daemon (run_once, Task 5). Ne dort JAMAIS
sur une incertitude : toute sonde indéterminée (None) => on garde le module up.
run_once ne réimplémente rien : pour chaque module devenu idle, elle construit
un plan STOP d'un seul élément et le confie à l'actionneur 0.7.0
(apply.apply_plan snapshot 4R, état-observé, audit, rollback en cas
d'échec), un module à la fois, jamais en parallèle.
"""
from __future__ import annotations
from pathlib import Path
from . import apply as _apply
from .actuate_paths import audit_path_for as _audit_path_for
from .actuate_paths import snap_root_for as _snap_root_for
from .diff import STOP, Change
from .front_signals import Signal
from .lifecycle import idle_threshold, is_sleepable
from .manifest import Manifest
from .observe import is_on
def should_sleep(m: Manifest, sig: Signal | None, *, hint_idle: bool | None,
@ -26,3 +38,30 @@ def should_sleep(m: Manifest, sig: Signal | None, *, hint_idle: bool | None,
if hint_idle is False: # a module /idle hint of False vetoes; None/True allow
return False
return sig.active_conns == 0 and sig.last_request_age >= idle_threshold(m)
def run_once(*, root: Path, manifests, actuals, signals, hints, run, observe, now,
apply: bool = True, wake_locked=frozenset()) -> list[str]:
"""Une passe : arrête chaque module sleepable devenu idle (un à la fois,
via l'actionneur 0.7.0). Retourne les ids arrêtés. wake_locked = ids en
cours de réveil (à ne jamais stopper).
root sert UNIQUEMENT à dériver snap_root/audit_path (voir
actuate_paths.py) les manifestes/actuals/signaux sont fournis par
l'appelant, run_once ne charge rien depuis le disque lui-même."""
root = Path(root)
stopped: list[str] = []
for mid, m in sorted(manifests.items()):
if mid in wake_locked:
continue
a = actuals.get(mid)
now_up = bool(a is not None and is_on(a))
if not should_sleep(m, signals.get(mid), hint_idle=hints.get(mid), now_up=now_up):
continue
plan = [Change(mid, STOP, "idle-sleep", m.priority)]
report = _apply.apply_plan(plan, manifests, {mid: a}, run=run, observe=observe,
now=now, routes={}, snap_root=_snap_root_for(root),
audit_path=_audit_path_for(root), apply=apply)
if report.status == "applied" and mid in report.changed:
stopped.append(mid)
return stopped

View File

@ -21,39 +21,13 @@ from pathlib import Path
from . import apply as _apply
from .actuate import TIMED_OUT
from .audit import AUDIT_LOG
from .actuate_paths import DEFAULT_ROOT
from .actuate_paths import audit_path_for as _audit_path_for
from .actuate_paths import snap_root_for as _snap_root_for
from .diff import START, Change
from .lifecycle import effective_lifecycle
from .manifest import load_all
from .observe import is_on, observe as _observe
from .snapshot import SNAP_DIR
DEFAULT_ROOT = Path("/etc/secubox")
def _snap_root_for(root: Path) -> Path:
"""SNAP_DIR (/var/lib/secubox/...) est le chemin réel, PARTAGÉ avec
`secubox-profilectl apply/rollback` un wake doit rejoindre la même
chaîne 4R qu'un apply normal. Ce n'est vrai que pour la racine par
défaut ; un --root explicite (tests, sandboxes) reste confiné sous lui,
pour ne jamais faire tourner R1..R4 d'un vrai système via un test.
Le confinement non-défaut est UNIQUEMENT une isolation de test (garder
le test non-mocké test_wake_starts_a_down_on_demand_module hors de
la vraie chaîne 4R). En production, wakectl utilise le même
SNAP_DIR/AUDIT_LOG que `secubox-profilectl` identique à
`cli.py._cmd_apply`/`_cmd_rollback`, qui eux ne font PAS varier ces
chemins selon --root. Un futur déploiement multi-root réel (ex.
cellule-in-a-box #843, un --root par tenant) romprait cette symétrie :
wakectl et profilectl écriraient alors dans des chaînes rollback/audit
DIFFÉRENTES pour une même racine. Le jour --root varie en
production, il faudra aligner cli.py pour dériver ces chemins de la
même façon (suivi #896) — ne PAS le faire dans cette tâche."""
return SNAP_DIR if root == DEFAULT_ROOT else root / "profiles" / "rollback"
def _audit_path_for(root: Path) -> Path:
return AUDIT_LOG if root == DEFAULT_ROOT else root / "audit.log"
def wake(module: str, *, root: Path = DEFAULT_ROOT, run, observe=_observe,

View File

@ -1,3 +1,14 @@
# 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 secubox-sleeper (should_sleep + run_once)
CyberMind https://cybermind.fr
"""
from __future__ import annotations
from api.sleeper import should_sleep
from api.front_signals import Signal
from api.manifest import Manifest
@ -51,3 +62,42 @@ def test_urgent_uses_longer_threshold():
hint_idle=None, now_up=True) is True
assert should_sleep(_m(wake_class="urgent"), Signal(1000.0, 0),
hint_idle=None, now_up=True) is False
def test_run_once_stops_only_idle_sleepable(tmp_path):
from api.sleeper import run_once
from api.front_signals import Signal
from api.observe import Actual
from api.manifest import Manifest
manifests = {
"idle1": Manifest(id="idle1", category="infra", runtime="native",
exposure="lan", units=("idle1.service",), lifecycle="on-demand"),
"busy": Manifest(id="busy", category="infra", runtime="native",
exposure="lan", units=("busy.service",), lifecycle="on-demand"),
"core": Manifest(id="core", category="infra", runtime="native",
exposure="lan", units=("core.service",), lifecycle="always-on"),
}
actuals = {k: Actual(enabled=True, active=True) for k in manifests} # all up
signals = {"idle1": Signal(1000.0, 0), "busy": Signal(5.0, 1), "core": Signal(1000.0, 0)}
calls = []
stopped = run_once(root=tmp_path, manifests=manifests, actuals=actuals,
signals=signals, hints={}, run=lambda a: (calls.append(a), (0, ""))[1],
observe=lambda m: Actual(enabled=False, active=False),
now="t", wake_locked=frozenset())
assert stopped == ["idle1"]
assert any(c[:2] == ["systemctl", "disable"] for c in calls)
def test_run_once_skips_wake_locked(tmp_path):
from api.sleeper import run_once
from api.front_signals import Signal
from api.observe import Actual
from api.manifest import Manifest
manifests = {"idle1": Manifest(id="idle1", category="infra", runtime="native",
exposure="lan", units=("idle1.service",), lifecycle="on-demand")}
stopped = run_once(root=tmp_path, manifests=manifests,
actuals={"idle1": Actual(enabled=True, active=True)},
signals={"idle1": Signal(1000.0, 0)}, hints={},
run=lambda a: (0, ""), observe=lambda m: Actual(enabled=False, active=False),
now="t", wake_locked=frozenset({"idle1"}))
assert stopped == []