diff --git a/packages/secubox-profiles/api/actuate.py b/packages/secubox-profiles/api/actuate.py index f20b558c..871d7901 100644 --- a/packages/secubox-profiles/api/actuate.py +++ b/packages/secubox-profiles/api/actuate.py @@ -32,6 +32,15 @@ class ActuationError(Exception): """Une commande d'actionnement a échoué (rc non-nul) ou n'a pas pu tourner (rc=None).""" +# Sentinel returncode for a command that RAN but did not return within the +# deadline (subprocess.TimeoutExpired), as opposed to one that could not run at +# all (OSError -> rc is None). The actuator fast-fails only on the latter; a +# timeout on lxc-start/lxc-stop is deferred to wait_state (observed state +# decides). 1000 is outside every real returncode (exit codes 0-255, signal +# codes negative), so `rc != 0` still reads it as "not success". +TIMED_OUT = 1000 + + def _must(run, argv: list[str]) -> None: rc, out = run(argv) if rc != 0: diff --git a/packages/secubox-profiles/api/cli.py b/packages/secubox-profiles/api/cli.py index 267eaa8d..9c075136 100644 --- a/packages/secubox-profiles/api/cli.py +++ b/packages/secubox-profiles/api/cli.py @@ -21,6 +21,7 @@ import sys from pathlib import Path from . import apply, export +from .actuate import TIMED_OUT from .audit import AUDIT_LOG from .diff import ProtectedViolation, plan_changes from .export import format_apt, format_json, format_pkglist, resolve_packages @@ -296,16 +297,24 @@ def _cmd_scan(args) -> int: return 0 +_RUN_TIMEOUT_S = 30 # was 15: --no-block native commands return at once; the +# extra headroom is for lxc-start/lxc-stop (no --no-block flag) so the container +# CLI usually finishes before we give up and defer to wait_state. + + def _run(argv: list[str]) -> tuple[int | None, str]: - """rc=None signale que la commande n'a PAS pu s'exécuter (OSError, timeout) — - à distinguer d'un rc non-nul qui est une réponse authentique de la commande. - Même contrat que observe._run_cmd : un (1, "") fabriqué ici serait - indistinguable d'une vraie réponse "non" de la commande (voir _cmd_scan, - qui a besoin de cette distinction pour ne pas écrire un manifeste faux).""" + """rc=None = la commande n'a PAS pu s'exécuter (OSError) — jamais un faux + succès. rc=TIMED_OUT = elle a bien démarré mais n'a pas répondu dans le + délai (subprocess.TimeoutExpired) : pour lxc-start/lxc-stop (sans --no-block) + ce n'est PAS un échec, c'est wait_state qui tranche sur l'état observé. + Même contrat de lecture-seule côté observe._run_cmd (qui, lui, garde + timeout->None : une sonde qui traîne reste indéterminée).""" try: - p = subprocess.run(argv, capture_output=True, text=True, timeout=15) + p = subprocess.run(argv, capture_output=True, text=True, timeout=_RUN_TIMEOUT_S) return p.returncode, p.stdout - except (OSError, subprocess.SubprocessError): + except subprocess.TimeoutExpired: + return TIMED_OUT, "" + except OSError: return None, "" diff --git a/packages/secubox-profiles/tests/test_cli.py b/packages/secubox-profiles/tests/test_cli.py index e5927542..96589490 100644 --- a/packages/secubox-profiles/tests/test_cli.py +++ b/packages/secubox-profiles/tests/test_cli.py @@ -445,3 +445,24 @@ def test_apply_error_maps_to_rc3_not_traceback(tmp_path, monkeypatch): raise apply_mod.ApplyError("x est protégé — un STOP est refusé") monkeypatch.setattr(apply_mod, "apply_plan", boom) assert cli.main(["--root", str(root), "apply", "--yes"]) == 3 + + +def test_run_distinguishes_timeout_from_could_not_run(monkeypatch): + # cli._run must return the actuate.TIMED_OUT sentinel on a subprocess + # timeout (the command RAN, still working) and None only on OSError + # (the command could not run at all) — the actuator relies on this + # distinction to fast-fail only on genuine could-not-run. + import subprocess + + import api.cli as cli + from api.actuate import TIMED_OUT + + def raise_timeout(*a, **k): + raise subprocess.TimeoutExpired(cmd="x", timeout=1) + monkeypatch.setattr(cli.subprocess, "run", raise_timeout) + assert cli._run(["whatever"]) == (TIMED_OUT, "") + + def raise_oserror(*a, **k): + raise OSError("no such binary") + monkeypatch.setattr(cli.subprocess, "run", raise_oserror) + assert cli._run(["whatever"]) == (None, "")