Files
local-ci-cd-system/tests/python/test_operations_services.py
Simone e56eae95c7 feat(web): service management panel over a sudo allow-list (stage D)
Adds operations/services.py: a fixed ManagedService allow-list (act-runner, act-runner-rust, vmware-host, ci-web) with find_service/service_states/control_service. control_service builds an argument list only (never shell=True), validates the action against ALLOWED_ACTIONS (ValueError->400), refuses stop on restart-only services (ci-web, allow_stop=False) without building a command, and defers ci-web's own restart via 'sudo -n systemd-run --on-active=2 systemctl restart ci-web.service' so the HTTP response returns first.

Route POST /services/{service_id}/{action} in web/routes.py mirrors vms_remove: require_user + require_csrf + two-step confirm gate + one audit entry per stage; unknown id / bad action / disallowed-stop short-circuit to 400 before any subprocess or audit; the vmware-host confirm step warns with the active-VM count. New /services read partial with state-aware Start/Stop/Restart buttons, polled every 15s.

deploy/sudoers.d/ci-web grants ci-runner exact-command NOPASSWD for exactly these nine systemctl lines plus the systemd-run deferral — no wildcards, no bare systemctl, no ci-web stop; setup-web-linux.sh installs it mode 0440 and validates with 'visudo -cf', rolling back on failure. Config gains [web].managed_services override; docs/WEB-UI.md documents the panel + security model.

Gates: 1044 tests pass (46 new security/unit tests), coverage 94.20%, ruff + mypy --strict clean. Not yet deployed: sudoers install + prod-venv reinstall + ci-web restart are a human privilege-boundary step.
2026-07-24 12:11:36 +02:00

370 lines
13 KiB
Python

"""Tests for the pure ``operations.services`` layer (Stage D of the web-UI
redesign) -- SECURITY-CRITICAL: this module builds the exact argv that ends
up behind ``sudo`` on the live host.
No real ``systemctl``/``sudo``/``systemd-run`` subprocess is ever invoked --
every ``subprocess.run`` call is monkeypatched at the name this module
imported it under (mirrors ``test_operations_host.py``'s convention).
"""
from __future__ import annotations
import subprocess
from typing import Any
import pytest
import ci_orchestrator.operations.host as host_module
import ci_orchestrator.operations.services as services_module
from ci_orchestrator.operations.services import (
ALLOWED_ACTIONS,
MANAGED_SERVICES,
ManagedService,
ServiceState,
control_service,
find_service,
service_states,
)
class _RunResult:
def __init__(self, stdout: str = "", stderr: str = "", returncode: int = 0) -> None:
self.stdout = stdout
self.stderr = stderr
self.returncode = returncode
def _spy(real: Any) -> Any:
"""Wrap ``real`` so ``.calls`` records every ``(args, kwargs)`` call, still delegating."""
def wrapper(*args: Any, **kwargs: Any) -> Any:
wrapper.calls.append((args, kwargs)) # type: ignore[attr-defined]
return real(*args, **kwargs)
wrapper.calls = [] # type: ignore[attr-defined]
return wrapper
# ── MANAGED_SERVICES / find_service ─────────────────────────────────────────
def test_managed_services_shape() -> None:
ids = {s.id for s in MANAGED_SERVICES}
assert ids == {"act-runner", "act-runner-rust", "vmware-host", "ci-web"}
by_id = {s.id: s for s in MANAGED_SERVICES}
assert by_id["ci-web"].allow_stop is False
assert by_id["ci-web"].unit == "ci-web.service"
assert by_id["act-runner"].allow_stop is True
assert by_id["vmware-host"].allow_stop is True
def test_find_service_returns_allowlist_member() -> None:
svc = find_service("act-runner")
assert svc is not None
assert svc.unit == "act-runner.service"
def test_find_service_unknown_id_returns_none() -> None:
assert find_service("does-not-exist") is None
def test_find_service_uses_custom_services_tuple() -> None:
custom = (ManagedService("x", "x.service", "X", True),)
assert find_service("x", custom) is not None
# Not present in the default list, only in the custom one.
assert find_service("x", MANAGED_SERVICES) is None
# ── service_states ───────────────────────────────────────────────────────────
def test_service_states_reflects_active_and_inactive(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(host_module.shutil, "which", lambda _n: "/usr/bin/systemctl")
def _run(cmd: list[str], **_kw: Any) -> _RunResult:
unit = cmd[-1]
if cmd[1] == "is-active":
return _RunResult(stdout="active\n" if unit == "act-runner.service" else "inactive\n")
return _RunResult(stdout="running\n" if unit == "act-runner.service" else "dead\n")
monkeypatch.setattr(host_module.subprocess, "run", _run)
services = (
ManagedService("act-runner", "act-runner.service", "Runner", True),
ManagedService("ci-web", "ci-web.service", "Web UI", False),
)
states = service_states(services)
assert states == [
ServiceState(service=services[0], active=True, sub_state="running"),
ServiceState(service=services[1], active=False, sub_state="dead"),
]
def test_service_states_degrades_to_unknown_when_systemctl_missing(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(host_module.shutil, "which", lambda _n: None)
states = service_states(MANAGED_SERVICES)
assert len(states) == len(MANAGED_SERVICES)
assert all(not s.active and s.sub_state == "unknown" for s in states)
def test_service_states_degrades_to_unknown_on_subprocess_timeout(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(host_module.shutil, "which", lambda _n: "/usr/bin/systemctl")
def _boom(*_a: Any, **_kw: Any) -> Any:
raise subprocess.TimeoutExpired(cmd="systemctl", timeout=10.0)
monkeypatch.setattr(host_module.subprocess, "run", _boom)
states = service_states(MANAGED_SERVICES)
assert all(not s.active and s.sub_state == "unknown" for s in states)
def test_service_states_never_uses_shell_true(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(host_module.shutil, "which", lambda _n: "/usr/bin/systemctl")
seen_kwargs: list[dict[str, Any]] = []
def _run(cmd: list[str], **kw: Any) -> _RunResult:
seen_kwargs.append(kw)
return _RunResult(stdout="active\n")
monkeypatch.setattr(host_module.subprocess, "run", _run)
service_states(MANAGED_SERVICES)
assert seen_kwargs # sanity: the fake was actually invoked
assert all(not kw.get("shell") for kw in seen_kwargs)
# ── control_service: validation ──────────────────────────────────────────────
def test_control_service_unsupported_action_raises_value_error(
monkeypatch: pytest.MonkeyPatch,
) -> None:
spy = _spy(subprocess.run)
monkeypatch.setattr(services_module.subprocess, "run", spy)
service = find_service("act-runner")
assert service is not None
with pytest.raises(ValueError):
control_service(service, "reboot")
assert spy.calls == [] # type: ignore[attr-defined]
def test_control_service_stop_refused_for_disallowed_service_builds_no_command(
monkeypatch: pytest.MonkeyPatch,
) -> None:
spy = _spy(subprocess.run)
monkeypatch.setattr(services_module.subprocess, "run", spy)
ci_web = find_service("ci-web")
assert ci_web is not None
result = control_service(ci_web, "stop")
assert result.service_id == "ci-web"
assert result.action == "stop"
assert result.ok is False
assert result.command == []
assert "restart-only" in result.detail or "disabled" in result.detail
assert spy.calls == [] # type: ignore[attr-defined]
def test_control_service_stop_refused_even_in_dry_run(monkeypatch: pytest.MonkeyPatch) -> None:
"""The stop-refusal check must win over dry_run -- no command is built either way."""
spy = _spy(subprocess.run)
monkeypatch.setattr(services_module.subprocess, "run", spy)
ci_web = find_service("ci-web")
assert ci_web is not None
result = control_service(ci_web, "stop", dry_run=True)
assert result.ok is False
assert result.command == []
assert spy.calls == [] # type: ignore[attr-defined]
# ── control_service: dry_run argv, incl. the ci-web restart special case ───
@pytest.mark.parametrize("action", ALLOWED_ACTIONS)
def test_control_service_dry_run_argv_for_every_normal_service(action: str) -> None:
for service in MANAGED_SERVICES:
if service.id == "ci-web":
continue # covered separately below (special-cased argv)
if action == "stop" and not service.allow_stop:
continue # covered by the refusal tests above
result = control_service(service, action, dry_run=True)
assert result.ok is True
assert result.command == ["sudo", "-n", "systemctl", action, service.unit]
assert result.detail.startswith("would run:")
def test_control_service_dry_run_ci_web_start_and_stop_use_plain_systemctl() -> None:
ci_web = find_service("ci-web")
assert ci_web is not None
start_result = control_service(ci_web, "start", dry_run=True)
assert start_result.command == ["sudo", "-n", "systemctl", "start", "ci-web.service"]
def test_control_service_dry_run_ci_web_restart_uses_systemd_run_deferred() -> None:
ci_web = find_service("ci-web")
assert ci_web is not None
result = control_service(ci_web, "restart", dry_run=True)
assert result.ok is True
assert result.command == [
"sudo",
"-n",
"systemd-run",
"--on-active=2",
"systemctl",
"restart",
"ci-web.service",
]
assert "systemd-run" in result.detail
assert "--on-active=2" in result.detail
def test_control_service_dry_run_never_executes_anything(monkeypatch: pytest.MonkeyPatch) -> None:
spy = _spy(subprocess.run)
monkeypatch.setattr(services_module.subprocess, "run", spy)
service = find_service("act-runner")
assert service is not None
control_service(service, "restart", dry_run=True)
assert spy.calls == [] # type: ignore[attr-defined]
# ── control_service: real execution ──────────────────────────────────────────
def test_control_service_real_run_success_builds_arg_list(monkeypatch: pytest.MonkeyPatch) -> None:
seen: list[tuple[Any, ...]] = []
def _run(cmd: list[str], **kw: Any) -> _RunResult:
seen.append(tuple(cmd))
assert not kw.get("shell")
return _RunResult(returncode=0)
monkeypatch.setattr(services_module.subprocess, "run", _run)
service = find_service("act-runner")
assert service is not None
result = control_service(service, "restart", dry_run=False)
assert result.ok is True
assert seen == [("sudo", "-n", "systemctl", "restart", "act-runner.service")]
assert seen[0][0:3] == ("sudo", "-n", "systemctl")
assert result.command == ["sudo", "-n", "systemctl", "restart", "act-runner.service"]
def test_control_service_ci_web_restart_real_run_uses_systemd_run(
monkeypatch: pytest.MonkeyPatch,
) -> None:
seen: list[list[str]] = []
def _run(cmd: list[str], **_kw: Any) -> _RunResult:
seen.append(cmd)
return _RunResult(returncode=0)
monkeypatch.setattr(services_module.subprocess, "run", _run)
ci_web = find_service("ci-web")
assert ci_web is not None
result = control_service(ci_web, "restart", dry_run=False)
assert result.ok is True
assert "systemd-run" in seen[0]
assert "--on-active=2" in seen[0]
assert "scheduled" in result.detail
def test_control_service_real_run_nonzero_exit_is_not_ok(monkeypatch: pytest.MonkeyPatch) -> None:
def _run(cmd: list[str], **_kw: Any) -> _RunResult:
return _RunResult(returncode=1, stderr="Unit act-runner.service not found.\n")
monkeypatch.setattr(services_module.subprocess, "run", _run)
service = find_service("act-runner")
assert service is not None
result = control_service(service, "start", dry_run=False)
assert result.ok is False
assert "not found" in result.detail
assert "1" in result.detail
def test_control_service_real_run_nonzero_exit_no_stderr_still_reports_exit_code(
monkeypatch: pytest.MonkeyPatch,
) -> None:
def _run(cmd: list[str], **_kw: Any) -> _RunResult:
return _RunResult(returncode=1, stderr="")
monkeypatch.setattr(services_module.subprocess, "run", _run)
service = find_service("act-runner")
assert service is not None
result = control_service(service, "start", dry_run=False)
assert result.ok is False
assert "exit 1" in result.detail
def test_control_service_timeout_degrades_to_error_never_raises(
monkeypatch: pytest.MonkeyPatch,
) -> None:
def _boom(*_a: Any, **_kw: Any) -> Any:
raise subprocess.TimeoutExpired(cmd="sudo", timeout=15.0)
monkeypatch.setattr(services_module.subprocess, "run", _boom)
service = find_service("act-runner")
assert service is not None
result = control_service(service, "restart", dry_run=False)
assert result.ok is False
assert "sudo" in result.detail or "restart" in result.detail.lower()
def test_control_service_oserror_degrades_to_error_never_raises(
monkeypatch: pytest.MonkeyPatch,
) -> None:
def _boom(*_a: Any, **_kw: Any) -> Any:
raise OSError("sudo: command not found")
monkeypatch.setattr(services_module.subprocess, "run", _boom)
service = find_service("act-runner")
assert service is not None
result = control_service(service, "restart", dry_run=False)
assert result.ok is False
assert "sudo: command not found" in result.detail
def test_control_service_stderr_tail_is_truncated(monkeypatch: pytest.MonkeyPatch) -> None:
huge_stderr = "x" * 5000
def _run(cmd: list[str], **_kw: Any) -> _RunResult:
return _RunResult(returncode=1, stderr=huge_stderr)
monkeypatch.setattr(services_module.subprocess, "run", _run)
service = find_service("act-runner")
assert service is not None
result = control_service(service, "start", dry_run=False)
assert len(result.detail) < len(huge_stderr) + 100