fix(profiles): a condition-gated START is not an apply failure (no spurious rollback)
Some checks failed
License Headers / check (push) Has been cancelled

A unit whose systemd start-condition fails (e.g. hexo) can never go active, so
enable --now succeeds but wait_state timed out → apply of any profile listing such
a module rolled back. condition_failed() detects ConditionResult=no and _do_change/
_rollback treat that START as reached (the module is deliberately off here). Found
applying the full profile from the panel.

Co-Authored-By: Gerald KERMA <devel@cybermind.fr>
This commit is contained in:
CyberMind-FR 2026-07-19 10:49:46 +02:00
parent e2819f3d31
commit ffff1b70f5
5 changed files with 83 additions and 3 deletions

View File

@ -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

View File

@ -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)

View File

@ -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 <devel@cybermind.fr> 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

View File

@ -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

View File

@ -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 == []