diff --git a/pyproject.toml b/pyproject.toml index e41690c..19ac506 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -65,6 +65,12 @@ ignore = [ [tool.ruff.lint.per-file-ignores] "tests/**" = ["B011", "SIM117"] +[tool.ruff.lint.flake8-bugbear] +# FastAPI's `Depends(...)` in a parameter default is the framework's own +# idiom (see web/app.py), not the mutable-default footgun B008 guards +# against -- it is evaluated once and cached by FastAPI's dependency cache. +extend-immutable-calls = ["fastapi.Depends"] + [tool.mypy] python_version = "3.11" strict = true diff --git a/src/ci_orchestrator/__main__.py b/src/ci_orchestrator/__main__.py index acaf56f..1821bc3 100644 --- a/src/ci_orchestrator/__main__.py +++ b/src/ci_orchestrator/__main__.py @@ -33,6 +33,7 @@ from ci_orchestrator.commands.wait import ( # noqa: F401 (intentional re-expor load_backend, wait_ready, ) +from ci_orchestrator.commands.web import web as web_cmd @click.group() @@ -55,6 +56,7 @@ cli.add_command(smoke) cli.add_command(validate) cli.add_command(creds) cli.add_command(vmware) +cli.add_command(web_cmd) if __name__ == "__main__": # pragma: no cover diff --git a/src/ci_orchestrator/commands/web.py b/src/ci_orchestrator/commands/web.py new file mode 100644 index 0000000..f8fad74 --- /dev/null +++ b/src/ci_orchestrator/commands/web.py @@ -0,0 +1,56 @@ +"""``web`` sub-commands -- ``ci web serve`` launches the dashboard app. + +**Import discipline (non-negotiable, see CLAUDE.md / the web-UI plan)**: this +module imports only ``click`` and stdlib at module scope. ``fastapi``, +``uvicorn`` and ``ci_orchestrator.web`` (which pulls in fastapi/jinja2) are +imported lazily, inside :func:`serve`'s body -- never at import time. This +keeps ``ci --help`` (and every other CLI command) working on a host where +the optional ``[web]`` extra is not installed. +""" + +from __future__ import annotations + +import click + + +@click.group("web") +def web() -> None: + """Web dashboard commands (read-only in this phase, no auth).""" + + +@web.command("serve") +@click.option( + "--host", + "host", + default=None, + help="Bind address (default: config web.bind_host, normally 127.0.0.1).", +) +@click.option( + "--port", + "port", + type=int, + default=None, + help="Bind port (default: config web.bind_port).", +) +def serve(host: str | None, port: int | None) -> None: + """Run the read-only web dashboard in the foreground (uvicorn). + + Binds to ``127.0.0.1`` by default (never ``0.0.0.0``) -- pass ``--host`` + explicitly (or set ``[web].bind_host`` in ``config.toml``) to expose it + on the LAN. + """ + # Heavy imports deferred to here on purpose -- see module docstring. + import uvicorn + + from ci_orchestrator.config import load_config + from ci_orchestrator.web.app import create_app + + config = load_config() + bind_host = host if host is not None else config.web.bind_host + bind_port = port if port is not None else config.web.bind_port + + app = create_app(config) + uvicorn.run(app, host=bind_host, port=bind_port) + + +__all__ = ["web"] diff --git a/src/ci_orchestrator/web/app.py b/src/ci_orchestrator/web/app.py new file mode 100644 index 0000000..e75d36d --- /dev/null +++ b/src/ci_orchestrator/web/app.py @@ -0,0 +1,185 @@ +"""FastAPI application factory for the read-only web dashboard (W4). + +**In-process only**: routes call ``operations/`` + ``backends.load_backend`` +directly and render Jinja2 templates. They never shell out to the CLI (see +``CLAUDE.md`` / the web-UI plan's architecture rules). + +**No auth in this step** (that is W5) -- the app binds ``127.0.0.1`` by +default via :class:`~ci_orchestrator.config.WebConfig`; ``create_app`` itself +never chooses the bind address, that is ``commands/web.py``'s job. + +**Backend may be unavailable**: :func:`get_backend` is a FastAPI dependency +that swallows :class:`~ci_orchestrator.backends.errors.BackendError` (raised +when ``vmrun`` is missing on this host) and degrades to ``None`` so a route +never 500s. Tests override it via ``app.dependency_overrides[get_backend]``. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import TYPE_CHECKING + +from fastapi import Depends, FastAPI, Request +from fastapi.responses import HTMLResponse +from fastapi.staticfiles import StaticFiles +from fastapi.templating import Jinja2Templates + +from ci_orchestrator.backends import load_backend +from ci_orchestrator.backends.errors import BackendError +from ci_orchestrator.operations.cache import CacheStatus, cargo_cache_status +from ci_orchestrator.operations.host import DiskStatus, RunnerStatus, disk_status, runner_status +from ci_orchestrator.operations.vms import LiveVm, list_live_vms + +if TYPE_CHECKING: # pragma: no cover + from ci_orchestrator.backends.protocol import VmBackend + from ci_orchestrator.config import Config + +_WEB_DIR = Path(__file__).resolve().parent + +#: The two real ``act_runner`` systemd units on the Linux host (see +#: CLAUDE.md's "Phase C"). A module constant per the W4 plan section. +RUNNER_SERVICE_NAMES: list[str] = ["act-runner.service", "act-runner-rust.service"] + + +# ─────────────────────────────────────────────────────────────── dependency + + +def get_backend(request: Request) -> VmBackend | None: + """FastAPI dependency: the VM backend for this request, or ``None``. + + Catches :class:`BackendError` (e.g. ``vmrun`` missing on this host -- + ``BackendNotAvailable``) so routes degrade to an empty/"unavailable" + state instead of raising. Overridable in tests via + ``app.dependency_overrides[get_backend]`` with a fake backend. + """ + config: Config = request.app.state.config + try: + return load_backend(config) + except BackendError: + return None + + +# ──────────────────────────────────────────────────────────── data fetchers + + +def _fetch_live_vms(backend: VmBackend | None, config: Config) -> list[LiveVm]: + """Render-ready VM list. Degrades to ``[]`` on any backend hiccup.""" + if backend is None: + return [] + try: + return list_live_vms(backend, config) + except Exception: # a route must never 500 on a backend hiccup + return [] + + +def _fetch_cache_status(config: Config) -> CacheStatus | None: + """Cargo cache snapshot, or ``None`` if it could not be read.""" + try: + return cargo_cache_status(config) + except Exception: # degrade, never 500 + return None + + +def _fetch_disk_status(config: Config) -> DiskStatus | None: + """Disk usage for ``config.paths.root``, or ``None`` if unreadable.""" + try: + return disk_status(config.paths.root) + except Exception: # degrade, never 500 + return None + + +def _fetch_runner_status() -> list[RunnerStatus]: + """Runner service statuses, or ``[]`` if the query could not run at all.""" + try: + return runner_status(RUNNER_SERVICE_NAMES) + except Exception: # degrade, never 500 + return [] + + +# ────────────────────────────────────────────────────────────────── routes + + +def healthz() -> dict[str, str]: + """Liveness probe for systemd/monitor. Always ``200``.""" + return {"status": "ok"} + + +def dashboard( + request: Request, backend: VmBackend | None = Depends(get_backend) +) -> HTMLResponse: + """Full dashboard: VM live, cache, disk, runner cards.""" + config: Config = request.app.state.config + templates: Jinja2Templates = request.app.state.templates + context = { + "vms": _fetch_live_vms(backend, config), + "backend_available": backend is not None, + "cache": _fetch_cache_status(config), + "disk": _fetch_disk_status(config), + "runners": _fetch_runner_status(), + } + return templates.TemplateResponse(request, "dashboard.html", context) + + +def vms_partial( + request: Request, backend: VmBackend | None = Depends(get_backend) +) -> HTMLResponse: + """``GET /vms`` -- polled every 5s by the dashboard via htmx.""" + config: Config = request.app.state.config + templates: Jinja2Templates = request.app.state.templates + context = { + "vms": _fetch_live_vms(backend, config), + "backend_available": backend is not None, + } + return templates.TemplateResponse(request, "partials/vms.html", context) + + +def cache_partial(request: Request) -> HTMLResponse: + """``GET /cache`` -- cargo cache status partial.""" + config: Config = request.app.state.config + templates: Jinja2Templates = request.app.state.templates + context = {"cache": _fetch_cache_status(config)} + return templates.TemplateResponse(request, "partials/cache.html", context) + + +def host_partial(request: Request) -> HTMLResponse: + """``GET /host`` -- disk + runner status partial.""" + config: Config = request.app.state.config + templates: Jinja2Templates = request.app.state.templates + context = { + "disk": _fetch_disk_status(config), + "runners": _fetch_runner_status(), + } + return templates.TemplateResponse(request, "partials/host.html", context) + + +# ─────────────────────────────────────────────────────────────────── factory + + +def create_app(config: Config) -> FastAPI: + """Build the read-only dashboard app. + + W4 scope: no auth, read-only routes only. Binding to a concrete address + (always ``127.0.0.1`` by default -- never ``0.0.0.0``) is + ``commands/web.py``'s job, not this factory's. + """ + app = FastAPI(title="ci-orchestrator web") + app.state.config = config + app.state.templates = Jinja2Templates(directory=str(_WEB_DIR / "templates")) + app.mount("/static", StaticFiles(directory=str(_WEB_DIR / "static")), name="static") + + app.get("/healthz")(healthz) + app.get("/", response_class=HTMLResponse)(dashboard) + app.get("/vms", response_class=HTMLResponse)(vms_partial) + app.get("/cache", response_class=HTMLResponse)(cache_partial) + app.get("/host", response_class=HTMLResponse)(host_partial) + + return app + + +__all__ = [ + "RUNNER_SERVICE_NAMES", + "create_app", + "dashboard", + "get_backend", + "healthz", +] diff --git a/src/ci_orchestrator/web/static/app.css b/src/ci_orchestrator/web/static/app.css new file mode 100644 index 0000000..cabb305 --- /dev/null +++ b/src/ci_orchestrator/web/static/app.css @@ -0,0 +1 @@ +/* placeholder -- real dark Gitea-style design tokens land in W7 */ diff --git a/src/ci_orchestrator/web/templates/base.html b/src/ci_orchestrator/web/templates/base.html new file mode 100644 index 0000000..b00dd81 --- /dev/null +++ b/src/ci_orchestrator/web/templates/base.html @@ -0,0 +1,18 @@ + + + + + + {% block title %}ci-orchestrator{% endblock %} + + + + + +
+ {% block content %}{% endblock %} +
+ + diff --git a/src/ci_orchestrator/web/templates/dashboard.html b/src/ci_orchestrator/web/templates/dashboard.html new file mode 100644 index 0000000..22b899e --- /dev/null +++ b/src/ci_orchestrator/web/templates/dashboard.html @@ -0,0 +1,15 @@ +{% extends "base.html" %} + +{% block title %}Dashboard - ci-orchestrator{% endblock %} + +{% block content %} +
+ {% include "partials/vms.html" %} +
+ +
+ {% include "partials/cache.html" %} +
+ +{% include "partials/host.html" %} +{% endblock %} diff --git a/src/ci_orchestrator/web/templates/partials/cache.html b/src/ci_orchestrator/web/templates/partials/cache.html new file mode 100644 index 0000000..3ad9a3d --- /dev/null +++ b/src/ci_orchestrator/web/templates/partials/cache.html @@ -0,0 +1,15 @@ +
+

Cargo cache

+ {% if cache %} +
+
Root
{{ cache.root }}
+
Total size
{{ cache.total_bytes }} bytes
+
Entries
{{ cache.entry_count }}
+
Oldest entry age
+
{{ cache.oldest_age_days ~ " day(s)" if cache.oldest_age_days is not none else "-" }}
+
Retention
{{ cache.retention_days }} day(s)
+
+ {% else %} +

Cargo cache status unavailable.

+ {% endif %} +
diff --git a/src/ci_orchestrator/web/templates/partials/host.html b/src/ci_orchestrator/web/templates/partials/host.html new file mode 100644 index 0000000..4f6e8e8 --- /dev/null +++ b/src/ci_orchestrator/web/templates/partials/host.html @@ -0,0 +1,39 @@ +
+

Disk

+ {% if disk %} +
+
Path
{{ disk.path }}
+
Total
{{ "%.1f"|format(disk.total_gb) }} GB
+
Free
{{ "%.1f"|format(disk.free_gb) }} GB
+
Used
{{ "%.1f"|format(disk.used_pct) }}%
+
+ {% else %} +

Disk status unavailable.

+ {% endif %} +
+ +
+

Runners

+ {% if runners %} + + + + + + + + + + {% for runner in runners %} + + + + + + {% endfor %} + +
ServiceActiveSub-state
{{ runner.name }}{{ "yes" if runner.active else "no" }}{{ runner.sub_state }}
+ {% else %} +

Runner status unavailable.

+ {% endif %} +
diff --git a/src/ci_orchestrator/web/templates/partials/vms.html b/src/ci_orchestrator/web/templates/partials/vms.html new file mode 100644 index 0000000..bf419ad --- /dev/null +++ b/src/ci_orchestrator/web/templates/partials/vms.html @@ -0,0 +1,34 @@ +
+

Live VMs

+ {% if not backend_available %} +

Backend unavailable (vmrun not found on this host).

+ {% endif %} + {% if vms %} + + + + + + + + + + + + + {% for vm in vms %} + + + + + + + + + {% endfor %} + +
NameVMXIPGuest OSUptime (s)Orphan?
{{ vm.name }}{{ vm.vmx }}{{ vm.ip or "-" }}{{ vm.guest_os or "-" }}{{ vm.uptime_sec if vm.uptime_sec is not none else "-" }}{{ "yes" if vm.is_orphan else "no" }}
+ {% else %} +

No VMs currently running.

+ {% endif %} +
diff --git a/tests/python/test_commands_web.py b/tests/python/test_commands_web.py new file mode 100644 index 0000000..44e304e --- /dev/null +++ b/tests/python/test_commands_web.py @@ -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 diff --git a/tests/python/test_web_app.py b/tests/python/test_web_app.py new file mode 100644 index 0000000..858144d --- /dev/null +++ b/tests/python/test_web_app.py @@ -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