mirror of
https://github.com/CyberMind-FR/secubox-deb.git
synced 2026-07-28 21:17:36 +00:00
fix(profiles): a condition-gated START is not an apply failure (no spurious rollback)
Some checks failed
License Headers / check (push) Has been cancelled
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:
parent
e2819f3d31
commit
ffff1b70f5
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 == []
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user