feat(web): Executions card replaces Live VMs

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
This commit is contained in:
2026-07-25 14:55:27 +02:00
parent 0635236391
commit af7dee60e5
20 changed files with 2136 additions and 109 deletions
@@ -0,0 +1,379 @@
"""Pure Gitea-Actions "recent executions" operations shared by the CLI and web UI.
Feeds the dashboard's Executions card: the most recent workflow runs (the
live ones included) with the build VM(s) each one spawned attached to its row.
Zero ``click``, zero ``print`` — every call returns an :class:`ExecutionsView`.
Data source is the Gitea Actions API (1.24+):
* ``GET /api/v1/admin/actions/runs`` — every run across every repo in one
call; needs an admin-scoped token.
* ``GET /api/v1/user/actions/runs`` — the fallback when the token is not
admin: the runs of the repos that user can see.
Per-run job/step detail (``/runs/{run}/jobs``) is deliberately not fetched:
one list call per refresh, no fan-out.
**Never raises.** Missing token, unreachable host, HTTP error, malformed
payload — each degrades to a view carrying ``error`` plus the live VMs, so a
Gitea outage can never take the dashboard down with it. Results (errors
included) are cached for :data:`_TTL_SEC`, so the card's 5s htmx poll costs at
most one API call per TTL window; VM correlation is redone on every call, so
the VM columns stay live even on a cache hit.
"""
from __future__ import annotations
import json
import logging
import time
import urllib.error
import urllib.parse
import urllib.request
from dataclasses import dataclass, field, replace
from datetime import UTC, datetime
from typing import TYPE_CHECKING, Any
from ci_orchestrator.config import gitea_base_url
from ci_orchestrator.operations.vms import parse_clone_name
if TYPE_CHECKING: # pragma: no cover
from ci_orchestrator.config import Config
from ci_orchestrator.operations.vms import LiveVm
logger = logging.getLogger(__name__)
# Short: the dashboard polls every 5s and a slow Gitea must not hold a
# request open long enough to pile polls up on top of each other.
_TIMEOUT_SEC = 6.0
# One API call per window at most, however many pollers ask.
_TTL_SEC = 10.0
_RUNNING = "running"
_QUEUED = "queued"
_UNKNOWN = "unknown"
# Gitea's ``status`` field, for runs that have not reached a conclusion yet.
_STATUS_MAP = {
"in_progress": _RUNNING,
"running": _RUNNING,
"waiting": _QUEUED,
"queued": _QUEUED,
"blocked": _QUEUED,
}
# Its ``conclusion`` field once they have.
_CONCLUSION_MAP = {
"success": "success",
"failure": "failure",
"cancelled": "cancelled",
"skipped": "skipped",
}
@dataclass(frozen=True)
class Execution:
"""One workflow run, with the live VM(s) it spawned (if any)."""
run_id: int
number: int
attempt: int
workflow: str
repo: str
branch: str
commit: str
event: str
actor: str
status: str
started: datetime | None
completed: datetime | None
duration_sec: int | None
html_url: str
vms: list[LiveVm] = field(default_factory=list)
@dataclass(frozen=True)
class ExecutionsView:
"""Everything the Executions card renders, degraded states included.
``unmatched_vms`` holds the running VMs that belong to no listed run — a
run older than the window, or a genuine orphan. They are rendered as
trailing rows so the dashboard never loses sight of a powered-on VM (nor
of its Remove action) just because Gitea is unreachable.
``auth_failed`` distinguishes "the token was refused" (``401``) from any
other failure, so the caller can try refreshing it once before giving up.
"""
runs: list[Execution]
unmatched_vms: list[LiveVm]
source: str
error: str | None
auth_failed: bool = False
def _monotonic() -> float:
"""Indirection so tests can drive the TTL cache without sleeping."""
return time.monotonic()
# key -> (fetched_at, runs, source, error, auth_failed)
_CacheEntry = tuple[float, list[dict[str, Any]], str, str | None, bool]
_CACHE: dict[tuple[str, str, int], _CacheEntry] = {}
def clear_cache() -> None:
"""Drop the TTL cache (used by tests and after a token change)."""
_CACHE.clear()
def _get(payload: dict[str, Any], key: str, default: str = "") -> str:
value = payload.get(key)
return value if isinstance(value, str) else default
def _int(payload: dict[str, Any], key: str, default: int = 0) -> int:
value = payload.get(key)
return value if isinstance(value, int) and not isinstance(value, bool) else default
def _login(payload: dict[str, Any], *keys: str) -> str:
"""First ``{key: {"login": ...}}`` that actually carries a login."""
for key in keys:
holder = payload.get(key)
if isinstance(holder, dict):
login = holder.get("login")
if isinstance(login, str) and login:
return login
return ""
def _timestamp(payload: dict[str, Any], key: str) -> datetime | None:
"""Parse an API timestamp; Gitea's zero value (``0001-01-01``) is ``None``."""
raw = payload.get(key)
if not isinstance(raw, str) or not raw:
return None
try:
parsed = datetime.fromisoformat(raw.replace("Z", "+00:00"))
except ValueError:
return None
if parsed.year <= 1:
return None
if parsed.tzinfo is None:
return parsed.replace(tzinfo=UTC)
return parsed
def _map_status(status: str, conclusion: str) -> str:
"""Collapse Gitea's ``status`` + ``conclusion`` pair into one card state."""
mapped = _STATUS_MAP.get(status)
if mapped is not None:
return mapped
return _CONCLUSION_MAP.get(conclusion, _UNKNOWN)
def _duration(
status: str, started: datetime | None, completed: datetime | None, now: datetime
) -> int | None:
if started is None:
return None
end = completed if completed is not None else (now if status == _RUNNING else None)
if end is None:
return None
return max(int((end - started).total_seconds()), 0)
def _to_execution(payload: dict[str, Any], now: datetime) -> Execution:
"""Map one ``ActionWorkflowRun``. Every field degrades on its own."""
status = _map_status(_get(payload, "status"), _get(payload, "conclusion"))
started = _timestamp(payload, "started_at")
completed = _timestamp(payload, "completed_at")
repository = payload.get("repository")
repo = (
_get(repository, "full_name")
if isinstance(repository, dict)
else ""
)
return Execution(
run_id=_int(payload, "id"),
number=_int(payload, "run_number"),
attempt=_int(payload, "run_attempt", 1) or 1,
workflow=_get(payload, "path").rsplit("/", 1)[-1],
repo=repo,
branch=_get(payload, "head_branch"),
commit=_get(payload, "head_sha"),
event=_get(payload, "event"),
actor=_login(payload, "trigger_actor", "actor"),
status=status,
started=started,
completed=completed,
duration_sec=_duration(status, started, completed, now),
html_url=_get(payload, "html_url"),
)
def _fetch(url: str, token: str) -> list[dict[str, Any]]:
"""GET one runs endpoint. Raises on transport/HTTP/payload trouble."""
# URL is built from the configured Gitea base URL, never user input.
request = urllib.request.Request(
url, headers={"Authorization": f"Bearer {token}", "Accept": "application/json"}
)
with urllib.request.urlopen(request, timeout=_TIMEOUT_SEC) as response:
body = json.loads(response.read().decode("utf-8"))
runs = body.get("workflow_runs") if isinstance(body, dict) else None
if not isinstance(runs, list):
raise ValueError("unexpected payload: no 'workflow_runs' array")
return [item for item in runs if isinstance(item, dict)]
def _fetch_runs(
base_url: str, token: str, limit: int
) -> tuple[list[dict[str, Any]], str, str | None, bool]:
"""Admin endpoint first, user endpoint as the non-admin fallback.
Returns ``(runs, source, error, auth_failed)``; never raises.
"""
query = urllib.parse.urlencode({"limit": limit})
admin_url = f"{base_url}/api/v1/admin/actions/runs?{query}"
user_url = f"{base_url}/api/v1/user/actions/runs?{query}"
last_error: str | None = None
auth_failed = False
for source, url in (("admin", admin_url), ("user", user_url)):
try:
return _fetch(url, token), source, None, False
except urllib.error.HTTPError as exc:
last_error = f"Gitea API returned HTTP {exc.code}."
if exc.code == 401:
auth_failed = True
break
if exc.code in (403, 404) and source == "admin":
# Token is not admin-scoped: try the per-user listing.
continue
break
except (urllib.error.URLError, TimeoutError, OSError) as exc:
last_error = f"Gitea unreachable: {exc}."
break
except (json.JSONDecodeError, ValueError) as exc:
last_error = f"Unreadable Gitea response: {exc}"
break
logger.warning("executions fetch failed: %s", last_error)
return [], "none", last_error, auth_failed
def _cached_runs(
base_url: str, token: str, limit: int
) -> tuple[list[dict[str, Any]], str, str | None, bool]:
""":func:`_fetch_runs` behind the TTL cache (errors are cached too)."""
key = (base_url, token, limit)
now = _monotonic()
entry = _CACHE.get(key)
if entry is not None and now - entry[0] < _TTL_SEC:
_fetched_at, runs, source, error, auth_failed = entry
return runs, source, error, auth_failed
runs, source, error, auth_failed = _fetch_runs(base_url, token, limit)
_CACHE[key] = (now, runs, source, error, auth_failed)
return runs, source, error, auth_failed
def _attach_vms(
runs: list[Execution], vms: list[LiveVm]
) -> tuple[list[Execution], list[LiveVm]]:
"""Group live VMs under the run that spawned them.
The job id passed to ``job`` is ``{run_id}-{run_attempt}[-suffix]`` (see
``.gitea/actions/local-ci-build/action.yml``) and the clone is named
``Clone_<job_id>_<timestamp>_<suffix>``, so the run's
``<run_id>-<attempt>`` is a prefix of the parsed job id. A matrix run
therefore collects several VMs; anything left over is returned separately.
"""
by_prefix: dict[str, list[LiveVm]] = {}
unmatched: list[LiveVm] = []
prefixes = {f"{run.run_id}-{run.attempt}" for run in runs}
for vm in vms:
job_id = parse_clone_name(vm.name).job_id
match = None
if job_id is not None:
match = next(
(
prefix
for prefix in prefixes
if job_id == prefix or job_id.startswith(f"{prefix}-")
),
None,
)
if match is None:
unmatched.append(vm)
else:
by_prefix.setdefault(match, []).append(vm)
attached = [
replace(run, vms=by_prefix.get(f"{run.run_id}-{run.attempt}", []))
for run in runs
]
return attached, unmatched
def list_executions(
config: Config,
vms: list[LiveVm],
*,
token: str | None,
limit: int | None = None,
now: datetime | None = None,
) -> ExecutionsView:
"""Recent Gitea Actions runs, with ``vms`` grouped under their own run.
``token`` is a Gitea API bearer token — the web UI passes the access token
from the operator's OIDC login (kept server-side), falling back to an
optional keyring PAT. ``None`` (or no configured Gitea URL) is not an
error state to shout about: the view simply degrades to listing the live
VMs, exactly what the card it replaces showed.
"""
effective_limit = config.web.executions_limit if limit is None else limit
moment = now if now is not None else datetime.now(UTC)
base_url = gitea_base_url(config.web)
if base_url is None:
return ExecutionsView(
runs=[],
unmatched_vms=list(vms),
source="none",
error="Gitea URL not configured ([web] gitea_url or [web.oidc] issuer).",
)
if not token:
return ExecutionsView(
runs=[],
unmatched_vms=list(vms),
source="none",
error="Gitea token not configured.",
)
raw_runs, source, error, auth_failed = _cached_runs(
base_url.rstrip("/"), token, effective_limit
)
if error is not None:
return ExecutionsView(
runs=[],
unmatched_vms=list(vms),
source=source,
error=error,
auth_failed=auth_failed,
)
runs, unmatched = _attach_vms(
[_to_execution(item, moment) for item in raw_runs], vms
)
return ExecutionsView(
runs=runs, unmatched_vms=unmatched, source=source, error=None
)
__all__ = [
"Execution",
"ExecutionsView",
"clear_cache",
"list_executions",
]