diff --git a/.superpowers/sdd/task-5-report.md b/.superpowers/sdd/task-5-report.md index e321b0f9..357bd243 100644 --- a/.superpowers/sdd/task-5-report.md +++ b/.superpowers/sdd/task-5-report.md @@ -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 -``` - ---- - -## 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. diff --git a/packages/secubox-profiles/api/actuate_paths.py b/packages/secubox-profiles/api/actuate_paths.py new file mode 100644 index 00000000..6de26960 --- /dev/null +++ b/packages/secubox-profiles/api/actuate_paths.py @@ -0,0 +1,41 @@ +# 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 — 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" diff --git a/packages/secubox-profiles/api/sleeper.py b/packages/secubox-profiles/api/sleeper.py index 17120731..4babc2e3 100644 --- a/packages/secubox-profiles/api/sleeper.py +++ b/packages/secubox-profiles/api/sleeper.py @@ -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 diff --git a/packages/secubox-profiles/api/wake.py b/packages/secubox-profiles/api/wake.py index 1816ab38..0e00a505 100644 --- a/packages/secubox-profiles/api/wake.py +++ b/packages/secubox-profiles/api/wake.py @@ -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 où --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, diff --git a/packages/secubox-profiles/tests/test_sleeper.py b/packages/secubox-profiles/tests/test_sleeper.py index a9c4940c..e1689b4b 100644 --- a/packages/secubox-profiles/tests/test_sleeper.py +++ b/packages/secubox-profiles/tests/test_sleeper.py @@ -1,3 +1,14 @@ +# 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 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 == []