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:
@@ -61,6 +61,7 @@ from ci_orchestrator.operations.inventory import (
|
||||
list_backups,
|
||||
list_templates,
|
||||
)
|
||||
from ci_orchestrator.operations.services import ServiceState, service_states
|
||||
from ci_orchestrator.operations.vms import LiveVm, list_live_vms
|
||||
from ci_orchestrator.web import filters
|
||||
from ci_orchestrator.web.auth import _read_secret, configure_auth, require_user
|
||||
@@ -202,6 +203,20 @@ def _fetch_artifacts(config: Config) -> list[FileEntry] | None:
|
||||
return None
|
||||
|
||||
|
||||
def _fetch_service_states(config: Config) -> list[ServiceState]:
|
||||
"""Managed systemd unit states (Stage D), or ``[]`` if the query could not run at all.
|
||||
|
||||
``service_states`` itself already degrades a single unit to
|
||||
``active=False, sub_state="unknown"`` on a ``systemctl`` hiccup (see
|
||||
``operations/host.py::runner_status``) -- this ``except Exception`` is a
|
||||
defensive second layer, matching every other ``_fetch_*`` wrapper here.
|
||||
"""
|
||||
try:
|
||||
return service_states(config.web.managed_services)
|
||||
except Exception: # degrade, never 500
|
||||
return []
|
||||
|
||||
|
||||
# ────────────────────────────────────────────────────────── status header
|
||||
|
||||
|
||||
@@ -335,6 +350,7 @@ def dashboard(
|
||||
"template_names": template_names,
|
||||
"backups": _fetch_backups(config),
|
||||
"artifacts": _fetch_artifacts(config),
|
||||
"services": _fetch_service_states(config),
|
||||
"health": _health_summary(
|
||||
vms,
|
||||
backend_available=backend_available,
|
||||
@@ -415,6 +431,23 @@ def files_partial(request: Request) -> HTMLResponse:
|
||||
return templates.TemplateResponse(request, "partials/files.html", context)
|
||||
|
||||
|
||||
def services_partial(request: Request) -> HTMLResponse:
|
||||
"""``GET /services`` (Stage D) -- polled every 15s by the dashboard via htmx.
|
||||
|
||||
Needs a fresh ``csrf_token`` on every render (unlike ``resources``/
|
||||
``vmware``/``templates``/``files``, which are pure read-only cards) --
|
||||
the panel's start/stop/restart forms are mutating, same as
|
||||
``vms_partial``/``host_partial``.
|
||||
"""
|
||||
config: Config = request.app.state.config
|
||||
templates: Jinja2Templates = request.app.state.templates
|
||||
context = {
|
||||
"services": _fetch_service_states(config),
|
||||
"csrf_token": issue_csrf(request),
|
||||
}
|
||||
return templates.TemplateResponse(request, "partials/services.html", context)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────── factory
|
||||
|
||||
|
||||
@@ -470,6 +503,9 @@ def create_app(config: Config) -> FastAPI:
|
||||
app.get("/files", response_class=HTMLResponse, dependencies=[Depends(require_user)])(
|
||||
files_partial
|
||||
)
|
||||
app.get("/services", response_class=HTMLResponse, dependencies=[Depends(require_user)])(
|
||||
services_partial
|
||||
)
|
||||
register_action_routes(app)
|
||||
|
||||
# SessionMiddleware is ALWAYS installed (W6) -- CSRF needs
|
||||
@@ -509,6 +545,7 @@ __all__ = [
|
||||
"get_backend",
|
||||
"healthz",
|
||||
"resources_partial",
|
||||
"services_partial",
|
||||
"templates_partial",
|
||||
"vmware_partial",
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user