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.
469 lines
16 KiB
Python
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",
|
|
]
|