feat(web): read-only dashboard app + ci web serve (W4)

This commit is contained in:
2026-07-23 17:41:06 +02:00
parent 07263e20fb
commit ce37ec2e3b
12 changed files with 791 additions and 0 deletions
+150
View File
@@ -0,0 +1,150 @@
"""Tests for ``ci web serve`` (W4 of the web-UI plan).
Two concerns:
1. Behaviour: ``ci web serve --help`` works and ``serve`` builds the app and
calls ``uvicorn.run`` with the right host/port (``uvicorn.run`` is
monkeypatched -- no real server is ever started). ``CI_ROOT``/``CI_CONFIG``
are pointed at an empty tmp dir so the test never depends on this host's
real ``/var/lib/ci/config.toml``.
2. Architecture: ``commands/web.py`` must import only ``click``/stdlib at
module scope. ``fastapi``/``uvicorn``/``ci_orchestrator.web`` may only be
imported lazily, inside the ``serve`` function body -- this keeps
``ci --help`` working when the optional ``[web]`` extra is not installed.
"""
from __future__ import annotations
import ast
import inspect
import sys
from pathlib import Path
from typing import Any
import pytest
from click.testing import CliRunner
import ci_orchestrator.commands.web as web_module
from ci_orchestrator.__main__ import cli
from ci_orchestrator.config import Config
@pytest.fixture(autouse=True)
def _isolated_config_env(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""Point config resolution at an empty tmp dir (no config.toml present).
Every test in this file exercises ``ci web serve``, which calls
``load_config()``; without this, the real ``/var/lib/ci/config.toml`` on
this host (see CLAUDE.md) could leak in and make assertions flaky.
"""
monkeypatch.setenv("CI_ROOT", str(tmp_path))
monkeypatch.setenv("CI_CONFIG", str(tmp_path / "does-not-exist.toml"))
def _install_fake_uvicorn(monkeypatch: pytest.MonkeyPatch) -> list[dict[str, Any]]:
calls: list[dict[str, Any]] = []
class _FakeUvicorn:
@staticmethod
def run(app: object, **kwargs: Any) -> None:
calls.append({"app": app, **kwargs})
monkeypatch.setitem(sys.modules, "uvicorn", _FakeUvicorn())
return calls
def test_web_serve_help_works() -> None:
runner = CliRunner()
result = runner.invoke(cli, ["web", "serve", "--help"])
assert result.exit_code == 0
assert "--host" in result.output
assert "--port" in result.output
def test_web_serve_calls_uvicorn_run_with_host_and_port(monkeypatch: pytest.MonkeyPatch) -> None:
calls = _install_fake_uvicorn(monkeypatch)
runner = CliRunner()
result = runner.invoke(cli, ["web", "serve", "--host", "127.0.0.1", "--port", "9999"])
assert result.exit_code == 0, result.output
assert len(calls) == 1
assert calls[0]["host"] == "127.0.0.1"
assert calls[0]["port"] == 9999
assert calls[0]["app"] is not None
def test_web_serve_defaults_host_and_port_from_config(monkeypatch: pytest.MonkeyPatch) -> None:
calls = _install_fake_uvicorn(monkeypatch)
runner = CliRunner()
result = runner.invoke(cli, ["web", "serve"])
assert result.exit_code == 0, result.output
assert len(calls) == 1
assert calls[0]["host"] == "127.0.0.1"
assert calls[0]["port"] == 8800
def test_web_serve_host_flag_overrides_config_default_only(
monkeypatch: pytest.MonkeyPatch,
) -> None:
calls = _install_fake_uvicorn(monkeypatch)
runner = CliRunner()
result = runner.invoke(cli, ["web", "serve", "--port", "8123"])
assert result.exit_code == 0, result.output
assert calls[0]["host"] == "127.0.0.1" # config default, untouched
assert calls[0]["port"] == 8123 # flag wins
def test_web_serve_never_binds_0_0_0_0_by_default() -> None:
"""Sanity: the un-overridden default host must never be 0.0.0.0."""
from ci_orchestrator.config import WebConfig
assert WebConfig().bind_host == "127.0.0.1"
# ── architecture: commands/web.py stays dep-light ───────────────────────────
def test_commands_web_module_has_no_top_level_heavy_imports() -> None:
"""Static check: no ``fastapi``/``uvicorn``/``ci_orchestrator.web`` import
at module (top) level -- only inside function bodies. This is what keeps
``ci --help`` working when the optional ``[web]`` extra is not installed.
"""
source = inspect.getsource(web_module)
tree = ast.parse(source)
forbidden_prefixes = ("fastapi", "uvicorn", "ci_orchestrator.web")
for node in tree.body: # module (top) level statements only
if isinstance(node, ast.Import):
for alias in node.names:
assert not alias.name.startswith(
forbidden_prefixes
), f"top-level `import {alias.name}` in commands/web.py"
elif isinstance(node, ast.ImportFrom):
module = node.module or ""
assert not module.startswith(
forbidden_prefixes
), f"top-level `from {module} import ...` in commands/web.py"
def test_commands_web_module_object_carries_no_fastapi_or_uvicorn_attribute() -> None:
"""Belt-and-braces: the module object itself has no fastapi/uvicorn name bound."""
assert not hasattr(web_module, "FastAPI")
assert not hasattr(web_module, "uvicorn")
assert not hasattr(web_module, "create_app")
def test_web_group_registered_on_root_cli() -> None:
assert "web" in cli.commands
assert "serve" in cli.commands["web"].commands # type: ignore[attr-defined]
def test_config_import_does_not_require_web_extra() -> None:
"""``Config``/``load_config`` (imported lazily inside serve) is core, not [web]."""
assert Config is not None
+270
View File
@@ -0,0 +1,270 @@
"""Tests for the read-only (W4) web dashboard app.
In-process only: routes call ``operations/`` + a backend injected via
FastAPI dependency override -- no real ``vmrun`` subprocess is ever
spawned. ``shutil.disk_usage``/``shutil.which`` are monkeypatched so no
real host state (disk, systemd) leaks into the assertions either.
"""
from __future__ import annotations
import shutil
from collections import namedtuple
from pathlib import Path
from typing import Any
import pytest
from fastapi.testclient import TestClient
import ci_orchestrator.web.app as app_module
from ci_orchestrator.backends.errors import BackendNotAvailable
from ci_orchestrator.backends.protocol import VmHandle
from ci_orchestrator.config import BackendConfig, CargoCacheConfig, Config, Paths
from ci_orchestrator.web.app import create_app, get_backend
_DiskUsage = namedtuple("_DiskUsage", ["total", "used", "free"])
def _fake_disk_usage(_path: Any) -> _DiskUsage:
gb = 1024**3
return _DiskUsage(total=100 * gb, used=40 * gb, free=60 * gb)
def _make_config(root: Path) -> Config:
return Config(
paths=Paths(
root=root,
templates=root / "templates",
build_vms=root / "build-vms",
artifacts=root / "artifacts",
keys=root / "keys",
),
backend=BackendConfig(),
cargo_cache=CargoCacheConfig(
enabled=True, root=root / "cache" / "cargo", retention_days=30
),
)
class _FakeBackend:
"""Stub ``VmBackend`` -- proves no real ``vmrun`` is ever invoked."""
def __init__(self, handles: list[VmHandle], ip: str | None = "192.168.79.201") -> None:
self._handles = handles
self._ip = ip
self.list_running_calls = 0
self.get_ip_calls = 0
def list_running(self) -> list[VmHandle]:
self.list_running_calls += 1
return self._handles
def get_ip(self, handle: VmHandle, timeout: float = 0.0) -> str | None:
self.get_ip_calls += 1
return self._ip
class _BoomBackend:
"""Backend whose ``list_running`` raises something other than ``BackendError``."""
def list_running(self) -> list[VmHandle]:
raise RuntimeError("boom")
def get_ip(self, handle: VmHandle, timeout: float = 0.0) -> str | None:
return None
def _quiet_host(monkeypatch: pytest.MonkeyPatch) -> None:
"""No real disk/systemctl calls from ``operations.host`` during a test."""
monkeypatch.setattr(shutil, "disk_usage", _fake_disk_usage)
monkeypatch.setattr(shutil, "which", lambda _name: None)
# ── /healthz ──────────────────────────────────────────────────────────────
def test_healthz_returns_ok(tmp_path: Path) -> None:
app = create_app(_make_config(tmp_path))
client = TestClient(app)
resp = client.get("/healthz")
assert resp.status_code == 200
assert resp.json() == {"status": "ok"}
# ── / (dashboard) ────────────────────────────────────────────────────────
def test_dashboard_renders_all_four_cards(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
_quiet_host(monkeypatch)
app = create_app(_make_config(tmp_path))
app.dependency_overrides[get_backend] = lambda: _FakeBackend([])
client = TestClient(app)
resp = client.get("/")
assert resp.status_code == 200
for needle in ("Live VMs", "Cargo cache", "Disk", "Runners"):
assert needle in resp.text
def test_dashboard_shows_backend_unavailable_notice_when_backend_is_none(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
_quiet_host(monkeypatch)
app = create_app(_make_config(tmp_path))
app.dependency_overrides[get_backend] = lambda: None
client = TestClient(app)
resp = client.get("/")
assert resp.status_code == 200
assert "unavailable" in resp.text.lower()
# ── /vms ──────────────────────────────────────────────────────────────────
def test_vms_route_renders_injected_vm_and_never_touches_real_vmrun(tmp_path: Path) -> None:
vmx = tmp_path / "build-vms" / "job1" / "job1.vmx"
fake = _FakeBackend([VmHandle(identifier=str(vmx), name="job1")])
app = create_app(_make_config(tmp_path))
app.dependency_overrides[get_backend] = lambda: fake
client = TestClient(app)
resp = client.get("/vms")
assert resp.status_code == 200
assert "job1" in resp.text
assert fake.list_running_calls == 1
def test_vms_route_degrades_when_backend_is_none(tmp_path: Path) -> None:
app = create_app(_make_config(tmp_path))
app.dependency_overrides[get_backend] = lambda: None
client = TestClient(app)
resp = client.get("/vms")
assert resp.status_code == 200
assert "unavailable" in resp.text.lower()
def test_vms_route_degrades_on_unexpected_backend_error(tmp_path: Path) -> None:
app = create_app(_make_config(tmp_path))
app.dependency_overrides[get_backend] = lambda: _BoomBackend()
client = TestClient(app)
resp = client.get("/vms")
assert resp.status_code == 200
def test_get_backend_degrades_to_none_when_vmrun_missing(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
def _raise(_config: Config) -> Any:
raise BackendNotAvailable("vmrun not found")
monkeypatch.setattr(app_module, "load_backend", _raise)
app = create_app(_make_config(tmp_path))
client = TestClient(app)
resp = client.get("/vms")
assert resp.status_code == 200
assert "unavailable" in resp.text.lower()
# ── /cache ────────────────────────────────────────────────────────────────
def test_cache_route_renders_status(tmp_path: Path) -> None:
app = create_app(_make_config(tmp_path))
client = TestClient(app)
resp = client.get("/cache")
assert resp.status_code == 200
assert "Cargo cache" in resp.text
def test_cache_route_degrades_when_operations_raises(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
def _boom(_config: Config) -> Any:
raise RuntimeError("boom")
monkeypatch.setattr(app_module, "cargo_cache_status", _boom)
app = create_app(_make_config(tmp_path))
client = TestClient(app)
resp = client.get("/cache")
assert resp.status_code == 200
# ── /host ─────────────────────────────────────────────────────────────────
def test_host_route_renders_disk_and_runner_status(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
_quiet_host(monkeypatch)
app = create_app(_make_config(tmp_path))
client = TestClient(app)
resp = client.get("/host")
assert resp.status_code == 200
assert "Disk" in resp.text
assert "Runners" in resp.text
assert "act-runner.service" in resp.text
assert "act-runner-rust.service" in resp.text
def test_host_route_degrades_when_disk_status_raises(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
def _boom(_path: Path) -> Any:
raise RuntimeError("boom")
monkeypatch.setattr(app_module, "disk_status", _boom)
monkeypatch.setattr(shutil, "which", lambda _name: None)
app = create_app(_make_config(tmp_path))
client = TestClient(app)
resp = client.get("/host")
assert resp.status_code == 200
def test_host_route_degrades_when_runner_status_raises(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
def _boom(_names: list[str]) -> Any:
raise RuntimeError("boom")
_quiet_host(monkeypatch)
monkeypatch.setattr(app_module, "runner_status", _boom)
app = create_app(_make_config(tmp_path))
client = TestClient(app)
resp = client.get("/host")
assert resp.status_code == 200
# ── static mount ─────────────────────────────────────────────────────────
def test_static_mount_serves_app_css(tmp_path: Path) -> None:
app = create_app(_make_config(tmp_path))
client = TestClient(app)
resp = client.get("/static/app.css")
assert resp.status_code == 200