From a0ae5b80e53cf247eccf25d016f34860ae5e0b58 Mon Sep 17 00:00:00 2001 From: CyberMind-FR Date: Mon, 20 Jul 2026 12:01:48 +0200 Subject: [PATCH] fix(profiles): wakectl main() tests + --root scoping note + planned status (ref #896) Co-Authored-By: Gerald KERMA --- packages/secubox-profiles/api/wake.py | 24 +++++++++++-- packages/secubox-profiles/tests/test_wake.py | 37 ++++++++++++++++++++ 2 files changed, 59 insertions(+), 2 deletions(-) diff --git a/packages/secubox-profiles/api/wake.py b/packages/secubox-profiles/api/wake.py index d5e89cc7..1816ab38 100644 --- a/packages/secubox-profiles/api/wake.py +++ b/packages/secubox-profiles/api/wake.py @@ -36,7 +36,19 @@ def _snap_root_for(root: Path) -> Path: `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.""" + 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" @@ -68,6 +80,10 @@ def wake(module: str, *, root: Path = DEFAULT_ROOT, run, observe=_observe, audit_path=_audit_path_for(root), apply=apply) if report.status == "applied" and module in report.changed: return {"status": "woken", "module": module} + if report.status == "planned": + # apply=False (dry-run) : apply_plan n'a rien exécuté, ce n'est pas + # un échec — rien n'a été tenté. + return {"status": "planned", "module": module} return {"status": "failed", "module": module, "failed": report.failed, "rolled_back": report.rolled_back} @@ -88,6 +104,10 @@ def _now_iso() -> str: return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") +def _running_as_root() -> bool: + return os.geteuid() == 0 + + def main(argv: list[str] | None = None) -> int: p = argparse.ArgumentParser(prog="secubox-wakectl") p.add_argument("--root", default=str(DEFAULT_ROOT)) @@ -96,7 +116,7 @@ def main(argv: list[str] | None = None) -> int: sp.add_argument("module") sp.add_argument("--json", action="store_true") args = p.parse_args(argv) - if os.geteuid() != 0: + if not _running_as_root(): print("wake doit être lancé en root (il pilote systemd/LXC).", file=sys.stderr) return 1 rep = wake(args.module, root=Path(args.root), run=_run, now=_now_iso()) diff --git a/packages/secubox-profiles/tests/test_wake.py b/packages/secubox-profiles/tests/test_wake.py index 6d5bb6c5..8a1d17af 100644 --- a/packages/secubox-profiles/tests/test_wake.py +++ b/packages/secubox-profiles/tests/test_wake.py @@ -57,3 +57,40 @@ def test_wake_starts_a_down_on_demand_module(tmp_path): r = wake("demo", root=root, run=run, observe=obs, now="t") assert r["status"] == "woken" assert any(c[:2] == ["systemctl", "enable"] for c in calls) + + +def test_wake_dry_run_reports_planned_not_failed(tmp_path): + from api.wake import wake + from api.observe import Actual + root = tmp_path + (root / "modules.d").mkdir() + (root / "modules.d" / "demo.toml").write_text( + 'id="demo"\ncategory="infra"\nruntime="native"\nexposure="lan"\n' + 'units=["demo.service"]\nlifecycle="on-demand"\n') + r = wake("demo", root=root, run=lambda a: (0, ""), + observe=lambda m: Actual(enabled=False, active=False), now="t", + apply=False) + assert r["status"] == "planned" + + +def test_main_requires_root(monkeypatch): + import api.wake as w + monkeypatch.setattr(w, "_running_as_root", lambda: False) + assert w.main(["wake", "demo"]) == 1 + + +def test_main_refused_exit_3(monkeypatch, tmp_path, capsys): + import api.wake as w + monkeypatch.setattr(w, "_running_as_root", lambda: True) + monkeypatch.setattr(w, "wake", lambda *a, **k: {"status": "refused", "module": "demo", "reason": "manual"}) + rc = w.main(["--root", str(tmp_path), "wake", "demo", "--json"]) + out = capsys.readouterr().out + import json as _j + assert rc == 3 and _j.loads(out)["status"] == "refused" + + +def test_main_woken_exit_0(monkeypatch, tmp_path): + import api.wake as w + monkeypatch.setattr(w, "_running_as_root", lambda: True) + monkeypatch.setattr(w, "wake", lambda *a, **k: {"status": "woken", "module": "demo"}) + assert w.main(["--root", str(tmp_path), "wake", "demo"]) == 0