Files
local-ci-cd-system/src/ci_orchestrator/operations/executions.py
T
Simone 7d1f4e2784 feat(web): row-count selector on the Executions card
Adds a "Show" selector (10 / 25 / 50 / 100) to the card header and drops the
default row count from 15 to 10. The chosen value rides on the card's own
polling URL (`/executions?limit=N`), so it survives the 5s htmx refresh with no
client-side state; a config default outside those four is rendered as an extra
option instead of disappearing. The query is parsed permissively — a stale or
hand-edited polling URL clamps or falls back to the config default rather than
422-ing a card that would otherwise render.

Ordering is now explicit rather than inherited from whatever Gitea returns:
newest first, a not-yet-started (queued) run above everything, ties broken on
Gitea's run id so matrix runs sharing a timestamp keep a stable order.
2026-07-25 15:36:21 +02:00

469 lines
16 KiB
Python

"""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
# MUST be set explicitly. The Gitea instance this talks to sits behind
# Cloudflare, which answers urllib's default ``Python-urllib/3.x`` agent with a
# 403 ("error code: 1010") before the request ever reaches Gitea -- an opaque
# failure that looks exactly like a token problem. Any ordinary agent string
# passes.
_USER_AGENT = "ci-orchestrator (+local CI dashboard)"
# One API call per window at most, however many pollers ask.
_TTL_SEC = 10.0
_RUNNING = "running"
_QUEUED = "queued"
_UNKNOWN = "unknown"
# Shown when Gitea accepts the token but refuses both listing endpoints: the
# token carries no API read scope. Names both fixes, since which one applies
# depends on whether the operator can add scopes to the OAuth2 app.
_SCOPE_HINT = (
"Gitea accepted the token but refused it (HTTP 403): it carries no API "
"read scope. Add read:repository (or read:admin) to [web.oidc] scopes and "
"log in again, or configure a keyring PAT via [web] gitea_token_cred."
)
# 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.
``refused`` is the wider "this token will not work" signal (``401`` *or* a
final ``403``, i.e. insufficient scope): the web layer uses it to fall back
from the operator's login token to the configured PAT, which is the only
way a PAT can help while a scope-less login token is in play.
"""
runs: list[Execution]
unmatched_vms: list[LiveVm]
source: str
error: str | None
auth_failed: bool = False
refused: 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, refused)
_CacheEntry = tuple[float, list[dict[str, Any]], str, str | None, bool, 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 _workflow_name(path: str) -> str:
"""Workflow file name out of Gitea's ``path``.
Gitea sends either a plain path (``.gitea/workflows/lint.yml``) or the
``<file>@<ref>`` form (``lint.yml@refs/heads/main``). Splitting on ``/``
alone would turn the latter into ``"main"``, so the ref is dropped first.
"""
return path.split("@", 1)[0].rsplit("/", 1)[-1]
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"),
# Gitea reports ``run_attempt = 0`` for a first (never-retried) run,
# while the job id the workflow builds uses github.run_attempt, which
# starts at 1 -- so 0 must read as 1 or no VM would ever match.
attempt=_int(payload, "run_attempt", 1) or 1,
workflow=_workflow_name(_get(payload, "path")),
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",
"User-Agent": _USER_AGENT,
},
)
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 _http_detail(exc: urllib.error.HTTPError, *, limit: int = 160) -> str:
"""Whatever the server said about the refusal, trimmed for a card notice.
Worth surfacing: Gitea's own body names the missing scope
(``required=[read:user], token scope=...``), and a Cloudflare interception
in front of it says something else entirely. Guessing from the status code
alone sent this exact investigation down the wrong path once already.
Unreadable bodies degrade to ``""`` -- this must never mask the real error.
"""
try:
raw = exc.read().decode("utf-8", errors="replace").strip()
except Exception:
return ""
if not raw:
return ""
try:
parsed = json.loads(raw)
except json.JSONDecodeError:
detail = " ".join(raw.split())
else:
message = parsed.get("message") if isinstance(parsed, dict) else None
detail = str(message) if message else " ".join(raw.split())
return detail[:limit]
def _fetch_runs(
base_url: str, token: str, limit: int
) -> tuple[list[dict[str, Any]], str, str | None, bool, bool]:
"""Admin endpoint first, user endpoint as the non-admin fallback.
Returns ``(runs, source, error, auth_failed, refused)``; 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
refused = False
for source, url in (("admin", admin_url), ("user", user_url)):
try:
return _fetch(url, token), source, None, False, False
except urllib.error.HTTPError as exc:
last_error = f"Gitea API returned HTTP {exc.code}. {_http_detail(exc)}".strip()
if exc.code == 401:
auth_failed = True
refused = True
break
if exc.code in (403, 404) and source == "admin":
# Token is not admin-scoped: try the per-user listing.
continue
if exc.code == 403:
# Both endpoints refused an otherwise-valid token: Gitea
# authenticated it and then rejected its scope. Say so --
# "HTTP 403" alone sends the operator hunting the wrong thing.
last_error = _SCOPE_HINT
refused = True
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, refused
def _cached_runs(
base_url: str, token: str, limit: int
) -> tuple[list[dict[str, Any]], str, str | None, bool, 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, refused = entry
return runs, source, error, auth_failed, refused
runs, source, error, auth_failed, refused = _fetch_runs(base_url, token, limit)
_CACHE[key] = (now, runs, source, error, auth_failed, refused)
return runs, source, error, auth_failed, refused
def _recency_key(run: Execution) -> tuple[datetime, int]:
"""Sort key for "newest first" (used with ``sorted(..., reverse=True)``).
A run that has not started yet (queued) is the newest thing there is, so it
sorts above every started run. ``run_id`` breaks ties — Gitea hands ids out
in creation order — which keeps the order stable when several runs share a
timestamp, as matrix jobs routinely do.
"""
return (run.started or datetime.max.replace(tzinfo=UTC), run.run_id)
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, refused = _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,
refused=refused,
)
runs, unmatched = _attach_vms(
sorted(
(_to_execution(item, moment) for item in raw_runs),
key=_recency_key,
reverse=True,
),
vms,
)
return ExecutionsView(
runs=runs, unmatched_vms=unmatched, source=source, error=None
)
__all__ = [
"Execution",
"ExecutionsView",
"clear_cache",
"list_executions",
]