secubox-deb/packages/secubox-profiles/tests/test_actuate.py
2026-07-20 08:39:30 +02:00

276 lines
12 KiB
Python

# SPDX-License-Identifier: LicenseRef-CMSD-1.0
# Copyright (c) 2026 CyberMind — Gérald Kerma <devel@cybermind.fr>
# Source-Disclosed License — All rights reserved except as expressly granted.
# See LICENCE-CMSD-1.0.md for terms.
import json
import pytest
from api.actuate import ActuationError, actuate, wait_state
from api.diff import START, STOP, Change
from api.manifest import Manifest
from api.observe import Actual
def _m(mid, runtime="native", units=("u.service",), lxc=None, portal=None):
return Manifest(id=mid, category="infra", runtime=runtime, exposure="lan",
units=tuple(units), lxc=lxc, portal_domain=portal)
def _ok_run(calls):
def run(argv):
calls.append(argv)
return 0, ""
return run
def test_native_start_enables_then_starts_no_block():
calls = []
actuate(Change("x", START, "", 50), _m("x"), run=_ok_run(calls))
assert ["systemctl", "enable", "u.service"] in calls
assert ["systemctl", "start", "--no-block", "u.service"] in calls
# enable (symlink) before start (transition)
assert calls.index(["systemctl", "enable", "u.service"]) < \
calls.index(["systemctl", "start", "--no-block", "u.service"])
def test_native_stop_disables_then_stops_no_block():
calls = []
actuate(Change("x", STOP, "", 50), _m("x"), run=_ok_run(calls))
assert ["systemctl", "disable", "u.service"] in calls
assert ["systemctl", "stop", "--no-block", "u.service"] in calls
def test_issue_raises_only_on_could_not_run():
from api.actuate import _issue, ActuationError, TIMED_OUT
_issue(lambda a: (0, ""), ["lxc-stop", "-n", "c"]) # ok
_issue(lambda a: (1, "already stopped"), ["lxc-stop", "-n", "c"]) # non-zero deferred
_issue(lambda a: (TIMED_OUT, ""), ["lxc-stop", "-n", "c"]) # timeout deferred
with pytest.raises(ActuationError):
_issue(lambda a: (None, ""), ["lxc-stop", "-n", "c"]) # could-not-run fails
def _lxc_root_with_config(tmp_path, name, autostart="1"):
"""Prépare <tmp>/<name>/config avec une ligne lxc.start.auto — mime le vrai
fichier de conteneur (/data/lxc/<name>/config sur la board)."""
d = tmp_path / name
d.mkdir()
(d / "config").write_text(
f"lxc.uts.name = {name}\nlxc.start.auto = {autostart}\nlxc.net.0.type = veth\n")
return tmp_path
def test_lxc_stop_stops_and_clears_autostart(tmp_path):
calls = []
root = _lxc_root_with_config(tmp_path, "lyrion", autostart="1")
order = actuate(Change("l", STOP, "", 50),
_m("l", runtime="lxc", lxc="lyrion", units=("secubox-lyrion.service",)),
run=_ok_run(calls), lxc_root=root)
assert ["lxc-stop", "-n", "lyrion"] in calls
# autostart cleared by EDITING the container config file (not lxc-update-config).
assert "lxc.start.auto = 0" in (root / "lyrion" / "config").read_text()
# host API unit stopped too — decoupled from the container on the real board.
assert ["systemctl", "disable", "secubox-lyrion.service"] in calls
assert ["systemctl", "stop", "--no-block", "secubox-lyrion.service"] in calls
# autostart=0 BEFORE lxc-stop — else secubox-watchdog revives the container.
assert order.index("lxc:autostart:0") < order.index("lxc:stop")
def test_lxc_start_starts_container_then_host_unit(tmp_path):
calls = []
root = _lxc_root_with_config(tmp_path, "lyrion", autostart="0")
order = actuate(Change("l", START, "", 50),
_m("l", runtime="lxc", lxc="lyrion", units=("secubox-lyrion.service",)),
run=_ok_run(calls), lxc_root=root)
assert ["lxc-start", "-n", "lyrion"] in calls
assert "lxc.start.auto = 1" in (root / "lyrion" / "config").read_text()
assert ["systemctl", "enable", "secubox-lyrion.service"] in calls
assert ["systemctl", "start", "--no-block", "secubox-lyrion.service"] in calls
# container up BEFORE the host API that depends on it
assert order.index("lxc:start") < order.index("systemd:enable")
def test_lxc_autostart_edit_preserves_other_config_lines(tmp_path):
calls = []
root = _lxc_root_with_config(tmp_path, "lyrion", autostart="1")
actuate(Change("l", STOP, "", 50),
_m("l", runtime="lxc", lxc="lyrion", units=("secubox-lyrion.service",)),
run=_ok_run(calls), lxc_root=root)
cfg = (root / "lyrion" / "config").read_text()
assert "lxc.uts.name = lyrion" in cfg and "lxc.net.0.type = veth" in cfg
assert cfg.count("lxc.start.auto") == 1 # single canonical line, no dup
def test_portal_stop_removes_route_before_backend(tmp_path):
routes = tmp_path / "haproxy-routes.json"
routes.write_text(json.dumps({"lyrion.gk2.secubox.in": ["127.0.0.1", 9000],
"other.example": ["10.0.0.1", 80]}))
root = _lxc_root_with_config(tmp_path, "lyrion", autostart="1")
calls = []
order = actuate(Change("l", STOP, "", 50),
_m("l", runtime="lxc", lxc="lyrion", portal="lyrion.gk2.secubox.in"),
run=_ok_run(calls), route_value=None, routes_path=routes, lxc_root=root)
# route removed
left = json.loads(routes.read_text())
assert "lyrion.gk2.secubox.in" not in left and "other.example" in left
# portal removed BEFORE the runtime is torn down (host API first, then lxc)
assert order.index("portal:remove") < order.index("systemd:disable")
def test_portal_start_restores_route_from_value(tmp_path):
routes = tmp_path / "haproxy-routes.json"
routes.write_text(json.dumps({"other.example": ["10.0.0.1", 80]}))
root = _lxc_root_with_config(tmp_path, "lyrion", autostart="0")
actuate(Change("l", START, "", 50),
_m("l", runtime="lxc", lxc="lyrion", portal="lyrion.gk2.secubox.in"),
run=_ok_run([]), route_value=["127.0.0.1", 9000], routes_path=routes, lxc_root=root)
got = json.loads(routes.read_text())
assert got["lyrion.gk2.secubox.in"] == ["127.0.0.1", 9000]
def test_failed_command_raises():
def bad_run(argv):
return 1, "boom"
with pytest.raises(ActuationError):
actuate(Change("x", START, "", 50), _m("x"), run=bad_run)
def test_command_that_cannot_run_raises():
def dead_run(argv):
return None, ""
with pytest.raises(ActuationError):
actuate(Change("x", START, "", 50), _m("x"), run=dead_run)
def test_wait_state_converges():
seq = iter([Actual(enabled=True, active=False), Actual(enabled=True, active=True)])
ticks = []
ok = wait_state(_m("x"), True, observe=lambda m: next(seq),
sleep=lambda s: ticks.append(s), now=iter([0, 1, 2]).__next__,
timeout=30, poll=1)
assert ok is True
def test_wait_state_times_out():
ok = wait_state(_m("x"), True, observe=lambda m: Actual(active=False),
sleep=lambda s: None, now=iter([0, 10, 20, 31]).__next__,
timeout=30, poll=10)
assert ok is False
def test_wait_state_lxc_requires_container_state_for_start():
# Host unit is up (is_on True) but the container is NOT running yet →
# a START must NOT be considered reached until lxc_running is True too.
from api.actuate import wait_state
from api.observe import Actual
m = _m("l", runtime="lxc", lxc="l", units=("secubox-l.service",))
seq = iter([
Actual(enabled=True, active=True, lxc_running=False), # unit up, container not yet
Actual(enabled=True, active=True, lxc_running=True), # now fully up
])
ok = wait_state(m, True, observe=lambda mm: next(seq),
sleep=lambda s: None, now=iter([0, 1, 2]).__next__,
timeout=30, poll=1)
assert ok is True
def test_wait_state_lxc_stop_not_reached_while_container_runs():
# Host unit is down (is_on False) but the container refuses to die
# (lxc_running stays True) → a STOP must report NOT reached (zombie guard).
from api.actuate import wait_state
from api.observe import Actual
m = _m("l", runtime="lxc", lxc="l", units=("secubox-l.service",))
ok = wait_state(m, False, observe=lambda mm: Actual(enabled=False, active=False,
lxc_running=True),
sleep=lambda s: None, now=iter([0, 10, 20, 31]).__next__,
timeout=30, poll=10)
assert ok is False
def test_wait_state_native_unaffected_by_container_predicate():
# A native module has lxc_running=None; the container clause must not apply.
from api.actuate import wait_state
from api.observe import Actual
ok = wait_state(_m("x"), True, observe=lambda mm: Actual(enabled=True, active=True),
sleep=lambda s: None, now=iter([0, 1]).__next__, timeout=30, poll=1)
assert ok is True
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
def test_parse_systemd_timespan_forms():
from api.actuate import _parse_systemd_timespan
assert _parse_systemd_timespan("90s") == 90.0
assert _parse_systemd_timespan("1min 30s") == 90.0
assert _parse_systemd_timespan("2min") == 120.0
assert _parse_systemd_timespan("1h 30min") == 5400.0
assert _parse_systemd_timespan("500ms") == 0.5
assert _parse_systemd_timespan("infinity") is None
assert _parse_systemd_timespan("") is None
assert _parse_systemd_timespan("garbage") is None
def test_derived_timeout_native_uses_stop_timeout_plus_margin():
from api.actuate import derived_timeout
# STOP -> reads TimeoutStopUSec; 90s + 15s margin = 105, within [10, 300].
def run(argv):
if argv[:2] == ["systemctl", "show"] and "TimeoutStopUSec" in argv:
return 0, "1min 30s\n"
return 0, ""
assert derived_timeout(_m("metrics"), False, run, margin=15.0) == 105.0
def test_derived_timeout_native_start_reads_start_timeout():
from api.actuate import derived_timeout
seen = {}
def run(argv):
if argv[:2] == ["systemctl", "show"]:
seen["prop"] = argv[argv.index("-p") + 1]
return 0, "5s\n"
return 0, ""
derived_timeout(_m("x"), True, run) # START
assert seen["prop"] == "TimeoutStartUSec"
def test_derived_timeout_infinity_and_unreadable_use_cap():
from api.actuate import derived_timeout
assert derived_timeout(_m("x"), False, lambda a: (0, "infinity\n"), cap=300.0) == 300.0
assert derived_timeout(_m("x"), False, lambda a: (1, ""), cap=300.0) == 300.0 # show failed
assert derived_timeout(_m("x"), False, lambda a: (None, ""), cap=300.0) == 300.0 # couldn't run
def test_derived_timeout_lxc_returns_cap():
from api.actuate import derived_timeout
called = []
m = _m("l", runtime="lxc", lxc="l")
assert derived_timeout(m, False, lambda a: (called.append(a), (0, "9s"))[1], cap=250.0) == 250.0
assert not called # short-circuits: no systemctl show for an LXC module
def test_derived_timeout_clamps_to_floor_and_cap():
from api.actuate import derived_timeout
# tiny unit timeout -> floor wins; huge -> cap wins.
assert derived_timeout(_m("x"), False, lambda a: (0, "1s"), floor=10.0, margin=0.0) == 10.0
assert derived_timeout(_m("x"), False, lambda a: (0, "10min"), cap=120.0, margin=0.0) == 120.0