The dashboard could only show what was powered on this instant, so it could
not answer "did the last build pass?" — and no local execution history exists
to answer it from (`report job` reads a PowerShell-era JSONL path that nothing
writes on this host, and the recent artifact dirs are empty).
Recent runs now come from Gitea's Actions API (`operations/executions.py`):
`/api/v1/admin/actions/runs` for every repo on the instance, falling back to
`/api/v1/user/actions/runs` when the token is not admin-scoped. One list call
per refresh, cached 10s, so the card's 5s htmx poll costs Gitea at most one
call per window. Live VMs are grouped under the run that spawned them — the
job id is `{run_id}-{run_attempt}[-suffix]` and the clone name carries it — so
a matrix run collects several VMs on one row. Every failure mode (no token,
401, unreachable Gitea, malformed payload) degrades to a notice plus the plain
VM rows, each keeping its Remove button, so the card is never less useful than
the table it replaces. Orphans stay where they were: the sweep in Maintenance,
the count in the status-header tile.
Auth reuses the access token from the operator's own OIDC login, which
`auth.py` previously discarded. SECURITY: that token is kept server-side only
(`app.state.gitea_tokens`, dropped on logout) and never written into the
session — `SessionMiddleware` signs the session cookie but does not encrypt
it, and this service is deployed over plain HTTP on the LAN, so a bearer token
in there would be readable in the browser and on the wire. An expired token is
refreshed once; `[web] gitea_token_cred` names an optional keyring PAT for the
cases a login token cannot cover (service restart, non-admin operator).
`GET /vms` and `partials/vms.html` are gone; `POST /vms/{id}/remove` and
`POST /vms/cleanup` are untouched.
Design: docs/superpowers/specs/2026-07-25-executions-card-design.md
411 lines
14 KiB
Python
411 lines
14 KiB
Python
"""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.operations.cache import CacheStatus
|
|
from ci_orchestrator.operations.host import DiskStatus, RunnerStatus
|
|
from ci_orchestrator.operations.vms import LiveVm
|
|
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 ("Executions", "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()
|
|
|
|
|
|
# ── /executions ──────────────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_executions_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("/executions")
|
|
|
|
assert resp.status_code == 200
|
|
assert "job1" in resp.text
|
|
assert fake.list_running_calls == 1
|
|
|
|
|
|
def test_executions_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("/executions")
|
|
|
|
assert resp.status_code == 200
|
|
assert "unavailable" in resp.text.lower()
|
|
|
|
|
|
def test_executions_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("/executions")
|
|
|
|
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("/executions")
|
|
|
|
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_runners_route_renders_runner_status(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
# Stage E: the /runners partial carries only the runner statuses -- the
|
|
# disk card was folded into the status-header Disk tile.
|
|
_quiet_host(monkeypatch)
|
|
app = create_app(_make_config(tmp_path))
|
|
client = TestClient(app)
|
|
|
|
resp = client.get("/runners")
|
|
|
|
assert resp.status_code == 200
|
|
assert "Runners" in resp.text
|
|
assert "act-runner.service" in resp.text
|
|
assert "act-runner-rust.service" in resp.text
|
|
|
|
|
|
def test_runners_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("/runners")
|
|
|
|
assert resp.status_code == 200
|
|
|
|
|
|
def test_runners_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("/runners")
|
|
|
|
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
|
|
|
|
|
|
# ── _health_summary (A3) ─────────────────────────────────────────────────
|
|
|
|
_ACTIVE_RUNNER = RunnerStatus(name="act-runner.service", active=True, sub_state="running")
|
|
_DEAD_RUNNER = RunnerStatus(name="act-runner-rust.service", active=False, sub_state="dead")
|
|
_LIVE_VM = LiveVm(
|
|
name="job1", vmx="/x/job1.vmx", ip="10.0.0.5", guest_os="ubuntu-64",
|
|
uptime_sec=120, is_orphan=False,
|
|
)
|
|
_ORPHAN_VM = LiveVm(
|
|
name="job2", vmx="/x/job2.vmx", ip=None, guest_os="ubuntu-64",
|
|
uptime_sec=99999, is_orphan=True,
|
|
)
|
|
_CACHE = CacheStatus(
|
|
root=Path("/cache"), total_bytes=1024, entry_count=1, oldest_age_days=1, retention_days=30
|
|
)
|
|
|
|
|
|
def test_health_summary_backend_unavailable_is_vm_unknown() -> None:
|
|
health = app_module._health_summary(
|
|
[], backend_available=False, cache=None, disk=None, runners=[]
|
|
)
|
|
assert health.vm_class == "tile-unknown"
|
|
assert health.vm_label == "unknown"
|
|
|
|
|
|
def test_health_summary_orphan_present_is_vm_warn() -> None:
|
|
health = app_module._health_summary(
|
|
[_LIVE_VM, _ORPHAN_VM], backend_available=True, cache=None, disk=None, runners=[]
|
|
)
|
|
assert health.vm_count == 2
|
|
assert health.orphan_count == 1
|
|
assert health.vm_class == "tile-warn"
|
|
assert health.vm_label == "orphaned"
|
|
|
|
|
|
def test_health_summary_no_orphans_is_vm_ok() -> None:
|
|
health = app_module._health_summary(
|
|
[_LIVE_VM], backend_available=True, cache=None, disk=None, runners=[]
|
|
)
|
|
assert health.vm_class == "tile-ok"
|
|
assert health.vm_label == "ok"
|
|
|
|
|
|
def test_health_summary_no_runners_is_unknown() -> None:
|
|
health = app_module._health_summary(
|
|
[], backend_available=True, cache=None, disk=None, runners=[]
|
|
)
|
|
assert health.runners_total == 0
|
|
assert health.runner_class == "tile-unknown"
|
|
|
|
|
|
def test_health_summary_all_runners_active_is_ok() -> None:
|
|
health = app_module._health_summary(
|
|
[], backend_available=True, cache=None, disk=None, runners=[_ACTIVE_RUNNER]
|
|
)
|
|
assert health.runners_active == 1
|
|
assert health.runners_total == 1
|
|
assert health.runner_class == "tile-ok"
|
|
|
|
|
|
def test_health_summary_a_down_runner_is_bad() -> None:
|
|
health = app_module._health_summary(
|
|
[], backend_available=True, cache=None, disk=None,
|
|
runners=[_ACTIVE_RUNNER, _DEAD_RUNNER],
|
|
)
|
|
assert health.runners_active == 1
|
|
assert health.runners_total == 2
|
|
assert health.runner_class == "tile-bad"
|
|
assert health.runner_label == "down"
|
|
|
|
|
|
def test_health_summary_disk_none_is_unknown() -> None:
|
|
health = app_module._health_summary(
|
|
[], backend_available=True, cache=None, disk=None, runners=[]
|
|
)
|
|
assert health.disk_used_pct is None
|
|
assert health.disk_class == "tile-unknown"
|
|
|
|
|
|
def test_health_summary_disk_above_critical_is_bad() -> None:
|
|
disk = DiskStatus(path=Path("/"), total_gb=100.0, free_gb=5.0, used_pct=90.0)
|
|
health = app_module._health_summary(
|
|
[], backend_available=True, cache=None, disk=disk, runners=[]
|
|
)
|
|
assert health.disk_class == "tile-bad"
|
|
assert health.disk_label == "critical"
|
|
|
|
|
|
def test_health_summary_disk_above_elevated_is_warn() -> None:
|
|
disk = DiskStatus(path=Path("/"), total_gb=100.0, free_gb=25.0, used_pct=75.0)
|
|
health = app_module._health_summary(
|
|
[], backend_available=True, cache=None, disk=disk, runners=[]
|
|
)
|
|
assert health.disk_class == "tile-warn"
|
|
assert health.disk_label == "elevated"
|
|
|
|
|
|
def test_health_summary_disk_healthy_is_ok() -> None:
|
|
disk = DiskStatus(path=Path("/"), total_gb=100.0, free_gb=60.0, used_pct=40.0)
|
|
health = app_module._health_summary(
|
|
[], backend_available=True, cache=None, disk=disk, runners=[]
|
|
)
|
|
assert health.disk_class == "tile-ok"
|
|
assert health.disk_label == "ok"
|
|
|
|
|
|
def test_health_summary_cache_none_is_unknown() -> None:
|
|
health = app_module._health_summary(
|
|
[], backend_available=True, cache=None, disk=None, runners=[]
|
|
)
|
|
assert health.cache_bytes is None
|
|
assert health.cache_class == "tile-unknown"
|
|
|
|
|
|
def test_health_summary_cache_present_is_ok() -> None:
|
|
health = app_module._health_summary(
|
|
[], backend_available=True, cache=_CACHE, disk=None, runners=[]
|
|
)
|
|
assert health.cache_bytes == 1024
|
|
assert health.cache_class == "tile-ok"
|
|
|
|
|
|
def test_dashboard_includes_status_header_tiles(
|
|
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
|
|
assert 'id="status-header"' in resp.text
|