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.
This commit is contained in:
@@ -10,6 +10,7 @@ from __future__ import annotations
|
||||
from pathlib import Path
|
||||
|
||||
from ci_orchestrator.config import WebConfig, WebOidcConfig, gitea_base_url, load_config
|
||||
from ci_orchestrator.operations.services import MANAGED_SERVICES, ManagedService
|
||||
|
||||
|
||||
def _paths_section() -> str:
|
||||
@@ -251,3 +252,88 @@ def test_gitea_base_url_none_when_issuer_unparseable() -> None:
|
||||
)
|
||||
|
||||
assert gitea_base_url(web) is None
|
||||
|
||||
|
||||
# ── [web].managed_services (Stage D) ────────────────────────────────────────
|
||||
|
||||
|
||||
def test_managed_services_absent_defaults_to_builtin_allowlist() -> None:
|
||||
assert WebConfig().managed_services == MANAGED_SERVICES
|
||||
|
||||
|
||||
def test_managed_services_absent_from_toml_defaults_to_builtin_allowlist(tmp_path: Path) -> None:
|
||||
cfg = load_config(config_path=_write_toml(tmp_path), env={})
|
||||
|
||||
assert cfg.web.managed_services == MANAGED_SERVICES
|
||||
|
||||
|
||||
def test_managed_services_override_parses_rows(tmp_path: Path) -> None:
|
||||
extra = (
|
||||
"\n[[web.managed_services]]\n"
|
||||
'id = "act-runner"\n'
|
||||
'unit = "act-runner.service"\n'
|
||||
'label = "Runner"\n'
|
||||
"allow_stop = true\n"
|
||||
"\n[[web.managed_services]]\n"
|
||||
'id = "ci-web"\n'
|
||||
'unit = "ci-web.service"\n'
|
||||
'label = "Web UI"\n'
|
||||
"allow_stop = false\n"
|
||||
)
|
||||
cfg = load_config(config_path=_write_toml(tmp_path, extra), env={})
|
||||
|
||||
assert cfg.web.managed_services == (
|
||||
ManagedService("act-runner", "act-runner.service", "Runner", True),
|
||||
ManagedService("ci-web", "ci-web.service", "Web UI", False),
|
||||
)
|
||||
|
||||
|
||||
def test_managed_services_malformed_row_is_skipped_not_raised(tmp_path: Path) -> None:
|
||||
extra = (
|
||||
"\n[[web.managed_services]]\n"
|
||||
'id = "act-runner"\n'
|
||||
'unit = "act-runner.service"\n'
|
||||
'label = "Runner"\n'
|
||||
"allow_stop = true\n"
|
||||
"\n[[web.managed_services]]\n"
|
||||
# Missing 'unit' -- this row must be skipped, not crash config loading.
|
||||
'id = "broken"\n'
|
||||
'label = "Broken"\n'
|
||||
"allow_stop = true\n"
|
||||
)
|
||||
cfg = load_config(config_path=_write_toml(tmp_path, extra), env={})
|
||||
|
||||
assert cfg.web.managed_services == (
|
||||
ManagedService("act-runner", "act-runner.service", "Runner", True),
|
||||
)
|
||||
|
||||
|
||||
def test_managed_services_all_rows_malformed_falls_back_to_default(tmp_path: Path) -> None:
|
||||
extra = (
|
||||
"\n[[web.managed_services]]\n"
|
||||
'id = "broken"\n'
|
||||
"allow_stop = true\n"
|
||||
)
|
||||
cfg = load_config(config_path=_write_toml(tmp_path, extra), env={})
|
||||
|
||||
assert cfg.web.managed_services == MANAGED_SERVICES
|
||||
|
||||
|
||||
def test_managed_services_wrong_type_for_allow_stop_is_skipped(tmp_path: Path) -> None:
|
||||
extra = (
|
||||
"\n[[web.managed_services]]\n"
|
||||
'id = "act-runner"\n'
|
||||
'unit = "act-runner.service"\n'
|
||||
'label = "Runner"\n'
|
||||
'allow_stop = "yes"\n' # string, not bool -- must be rejected
|
||||
)
|
||||
cfg = load_config(config_path=_write_toml(tmp_path, extra), env={})
|
||||
|
||||
assert cfg.web.managed_services == MANAGED_SERVICES
|
||||
|
||||
|
||||
def test_managed_services_empty_list_falls_back_to_default(tmp_path: Path) -> None:
|
||||
extra = "\n[web]\nmanaged_services = []\n"
|
||||
cfg = load_config(config_path=_write_toml(tmp_path, extra), env={})
|
||||
|
||||
assert cfg.web.managed_services == MANAGED_SERVICES
|
||||
|
||||
Reference in New Issue
Block a user