mirror of
https://github.com/CyberMind-FR/secubox-deb.git
synced 2026-07-29 18:36:55 +00:00
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:
parent
dee5670a14
commit
a88dd2beae
|
|
@ -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.
|
||||
|
|
|
|||
41
packages/secubox-profiles/api/actuate_paths.py
Normal file
41
packages/secubox-profiles/api/actuate_paths.py
Normal 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"
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 == []
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user