diff --git a/packages/secubox-profiles/api/actuate.py b/packages/secubox-profiles/api/actuate.py index 405f7272..f20b558c 100644 --- a/packages/secubox-profiles/api/actuate.py +++ b/packages/secubox-profiles/api/actuate.py @@ -190,3 +190,19 @@ def wait_state(m: Manifest, want_on: bool, *, observe=_observe, if now() - start >= timeout: return False sleep(poll) + + +def condition_failed(m: Manifest, run) -> bool: + """True si l'unité systemd du module a une Condition= de démarrage qui + ÉCHOUE : `systemctl enable --now` réussit, mais systemd laisse alors l'unité + inactive VOLONTAIREMENT (le module ne doit pas tourner dans cet + environnement — arch, présence d'un chemin, etc.). Ce n'est PAS un échec + d'actionnement : attendre `active` sur une telle unité timeout à l'infini. + Ne concerne que les modules natifs (LXC/portail n'ont pas de Condition=).""" + if m.runtime != "native": + return False + for u in m.units: + rc, out = run(["systemctl", "show", u, "-p", "ConditionResult", "--value"]) + if rc == 0 and out.strip() == "no": + return True + return False diff --git a/packages/secubox-profiles/api/apply.py b/packages/secubox-profiles/api/apply.py index fb8d2fb3..2029564f 100644 --- a/packages/secubox-profiles/api/apply.py +++ b/packages/secubox-profiles/api/apply.py @@ -17,7 +17,7 @@ from dataclasses import dataclass, field from . import audit as _audit from . import snapshot as _snapshot -from .actuate import ActuationError, actuate, wait_state +from .actuate import ActuationError, actuate, condition_failed, wait_state from .diff import START, STOP, Change from .manifest import Manifest @@ -41,6 +41,12 @@ def _want_on(action: str) -> bool: def _do_change(c: Change, m: Manifest, *, run, observe, routes_value, sleep, now, wait_timeout) -> None: actuate(c, m, run=run, route_value=routes_value) + # Un START d'une unité dont la Condition= de démarrage échoue ne deviendra + # JAMAIS active (enable --now a réussi, systemd la laisse inactive par + # design). On ne l'attend pas (ça timeout) et on ne la traite PAS comme un + # échec : le module est délibérément off ici. + if c.action == START and condition_failed(m, run): + return if not wait_state(m, _want_on(c.action), observe=observe, sleep=sleep, now=now, timeout=wait_timeout): raise ActuationError(f"{c.id}: état non atteint (timeout)") @@ -109,8 +115,12 @@ def _rollback_applied(applied, manifests, snap, *, run, observe, sleep, clock, continue try: actuate(rev, m, run=run, route_value=rv) - if wait_state(m, want_on, observe=observe, sleep=sleep, now=clock, - timeout=wait_timeout): + # Même règle que le forward : re-démarrer une unité condition-gated + # ne la rend jamais active — c'est un succès, pas un timeout. + reached = (rev.action == START and condition_failed(m, run)) or \ + wait_state(m, want_on, observe=observe, sleep=sleep, now=clock, + timeout=wait_timeout) + if reached: _audit.record({"ts": now, "module": c.id, "action": rev.action, "result": "rollback"}, path=audit_path) rolled.append(c.id) diff --git a/packages/secubox-profiles/debian/changelog b/packages/secubox-profiles/debian/changelog index 7ecf2708..b4a5d9eb 100644 --- a/packages/secubox-profiles/debian/changelog +++ b/packages/secubox-profiles/debian/changelog @@ -1,3 +1,13 @@ +secubox-profiles (0.6.2-1~bookworm1) bookworm; urgency=medium + + * Fix apply on condition-gated modules: a unit whose systemd start-condition + fails (e.g. hexo — ConditionResult=no) can never become active, so `enable + --now` succeeds but wait_state timed out → the whole apply spuriously + rolled back. Now a START of a condition-failed native unit is treated as + reached (the module is deliberately off in this environment), not a failure. + + -- Gerald KERMA Sun, 19 Jul 2026 14:00:00 +0200 + secubox-profiles (0.6.1-1~bookworm1) bookworm; urgency=medium * Fix apply/rollback from the panel: the service runs ProtectSystem=strict, so a diff --git a/packages/secubox-profiles/tests/test_actuate.py b/packages/secubox-profiles/tests/test_actuate.py index df0dfa8b..8d3f599b 100644 --- a/packages/secubox-profiles/tests/test_actuate.py +++ b/packages/secubox-profiles/tests/test_actuate.py @@ -140,3 +140,26 @@ def test_wait_state_times_out(): sleep=lambda s: None, now=iter([0, 10, 20, 31]).__next__, timeout=30, poll=10) assert ok is False + + +def test_condition_failed_detects_gated_native_unit(): + from api.actuate import condition_failed + + def run(argv): + if argv[:2] == ["systemctl", "show"] and "ConditionResult" in argv: + return 0, "no\n" # systemd start-condition failed + return 0, "" + assert condition_failed(_m("hexo"), run) is True + + +def test_condition_failed_false_when_condition_ok(): + from api.actuate import condition_failed + assert condition_failed(_m("x"), lambda a: (0, "yes\n")) is False + + +def test_condition_failed_false_for_lxc(): + from api.actuate import condition_failed + # LXC/portal modules have no systemd start-condition; never gated. + called = [] + condition_failed(_m("l", runtime="lxc", lxc="l"), lambda a: (called.append(a), (0, "no"))[1]) + assert not called # short-circuits on runtime != native diff --git a/packages/secubox-profiles/tests/test_apply.py b/packages/secubox-profiles/tests/test_apply.py index e9763ac9..64b2a906 100644 --- a/packages/secubox-profiles/tests/test_apply.py +++ b/packages/secubox-profiles/tests/test_apply.py @@ -176,3 +176,24 @@ def test_rollback_to_restores_snapshot_state(tmp_path): audit_path=tmp_path / "audit-dry.log", apply=False) assert dry_rep.status == "planned" assert dry_calls == [] + + +def test_start_of_condition_gated_native_module_is_not_a_failure(tmp_path): + # hexo-like: START issued, but its systemd start-condition fails so it never + # goes active. enable --now succeeded → not an actuation failure → no rollback. + calls = [] + manifests = {"hexo": _m("hexo")} + plan = [Change("hexo", START, "", 50)] + + def run(argv): + calls.append(argv) + if argv[:2] == ["systemctl", "show"] and "ConditionResult" in argv: + return 0, "no\n" + return 0, "" + + rep = apply_plan(plan, manifests, {"hexo": Actual(enabled=False, active=False)}, + run=run, observe=lambda m: Actual(enabled=True, active=False), + now="t", routes={}, snap_root=tmp_path, + audit_path=tmp_path / "audit.log", apply=True, wait_timeout=0) + assert rep.status == "applied" # NOT rolled_back + assert rep.changed == ["hexo"] and rep.failed == []