diff --git a/CLAUDE.md b/CLAUDE.md index 2f76876..5706799 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -92,9 +92,9 @@ The host **never executes build tools directly** — it only orchestrates VM lif | `commands/wait.py` | `wait-ready` | | `commands/monitor.py` | `monitor disk / runner` | | `commands/report.py` | `report job` | -| `operations/vms.py`, `cache.py`, `host.py`, `audit.py` | Pure logic layer shared by the CLI and the web UI — dataclasses only, zero `click`/`print`. `commands/*` and `web/routes.py` both call these; never duplicate logic in either caller. | +| `operations/vms.py`, `cache.py`, `host.py`, `audit.py`, `executions.py` | Pure logic layer shared by the CLI and the web UI — dataclasses only, zero `click`/`print`. `commands/*` and `web/routes.py` both call these; never duplicate logic in either caller. | | `commands/web.py` | `web serve` — `ci web serve --host --port` launches the dashboard (`uvicorn`/`fastapi` imported lazily so the core CLI stays dep-light without the `[web]` extra) | -| `web/app.py`, `auth.py`, `routes.py`, `security.py` | FastAPI dashboard: read-only VM/cache/disk/runner view, Gitea OIDC login (Authlib, fail-closed allowlist), CSRF-protected confirm-gated mutating actions (VM remove/cleanup, cache purge, template backup), JSONL audit trail. In-process only — imports `operations/` + `backends.load_backend` directly, never shells the CLI. See `docs/WEB-UI.md`. | +| `web/app.py`, `auth.py`, `routes.py`, `security.py` | FastAPI dashboard: recent-executions/cache/disk/runner view, Gitea OIDC login (Authlib, fail-closed allowlist), CSRF-protected confirm-gated mutating actions (VM remove/cleanup, cache purge, template backup), JSONL audit trail. In-process only — imports `operations/` + `backends.load_backend` directly, never shells the CLI. See `docs/WEB-UI.md`. | **Key design rule**: Application code (especially `commands/job.py`) must import `backends.load_backend` only — never `WorkstationVmrunBackend` directly. This keeps the Phase D ESXi extension path open. @@ -108,6 +108,14 @@ the design history (steps W0–W7, all closed). Deployed live on this host as OIDC auth active — never bind `0.0.0.0`, and never bind non-loopback without `[web.oidc]` configured (enforced at startup by `commands/web.py`). +The dashboard's main table is the **Executions** card (`operations/executions.py`, +`GET /executions`): recent Gitea Actions runs, live ones included, with the +build VMs grouped under the run that spawned them (job id +`{run_id}-{run_attempt}` ↔ clone name). It reuses the operator's OIDC access +token for the Gitea API — held **server-side only** in `app.state.gitea_tokens`, +never in the session cookie, which is signed but not encrypted and travels over +plain HTTP on the LAN. Optional keyring PAT fallback: `[web] gitea_token_cred`. + ### Transport selection `commands/job.py` reads `guestOS = "..."` from the cloned VMX. `ubuntu-*` → Linux/SSH (`transport/ssh.py`); anything else → Windows/WinRM (`transport/winrm.py`). diff --git a/config.example.toml b/config.example.toml index fbea7b3..883f1fb 100644 --- a/config.example.toml +++ b/config.example.toml @@ -98,15 +98,28 @@ retention_days = 30 # allowed_users = ["simone"] # Gitea usernames allowed to log in # session_secret_cred = "WebSessionSecret" # keyring KEY NAME, not the secret # +# Executions card (recent Gitea Actions runs, live ones included). It normally +# reuses the access token from the operator's own OIDC login, held server-side +# only -- so neither key below is required. Set them to cover the cases that +# token cannot: a service restart (which forgets the logged-in tokens) or a +# non-admin operator. +# executions_limit = 15 # how many runs to list (1..100) +# gitea_token_cred = "GiteaApiToken" # keyring KEY NAME of a fallback PAT +# # Gitea OIDC client registration: Gitea -> Settings -> Applications -> OAuth2, # redirect URI must exactly match `redirect_url` below (host+port+path). # +# `scopes` needs an API read scope on top of the OIDC ones for the Executions +# card: `read:repository` lists the operator's own repos, `read:admin` (admin +# accounts only) lists every repo on the instance. Adding a scope requires one +# fresh login to re-consent. +# # [web.oidc] # issuer = "https://gitea.lan" # client_id = "ci-web" # redirect_url = "http://192.168.1.50:8800/auth/callback" # client_secret_cred = "WebOidcClientSecret" # keyring KEY NAME, not the secret -# scopes = ["openid", "profile"] +# scopes = ["openid", "profile", "read:repository"] # # Optional: override the default systemd-unit allow-list the dashboard's # service-management panel (Stage D) is allowed to start/stop/restart. Leave diff --git a/docs/WEB-UI.md b/docs/WEB-UI.md index 78b8463..fe98197 100644 --- a/docs/WEB-UI.md +++ b/docs/WEB-UI.md @@ -1,10 +1,11 @@ # Web UI — Management Dashboard -Server-rendered dashboard (`ci web serve`) for the local CI/CD system: live -VMs, cargo cache, disk, and act_runner status, plus confirm-gated destructive -actions (VM remove/cleanup, cache purge, template backup) with an audit -trail. In-process only — it imports `operations/` and `backends.load_backend` -directly and never shells out to the CLI. +Server-rendered dashboard (`ci web serve`) for the local CI/CD system: recent +CI executions with their live VMs (§8), cargo cache, disk, and act_runner +status, plus confirm-gated destructive actions (VM remove/cleanup, cache +purge, template backup) with an audit trail. In-process only — it imports +`operations/` and `backends.load_backend` directly and never shells out to +the CLI. See `plans/web-ui-management-plan.md` (steps W0–W7) for the design history and `config.example.toml`'s `[web]` / `[web.oidc]` section for every @@ -105,6 +106,11 @@ application **before** enabling `[web.oidc]`: 5. Add the Gitea username(s) allowed to log in to `[web].allowed_users`. **The allowlist is fail-closed**: an empty list denies everyone, it never means "allow all". +6. For the Executions card (§8), add an API read scope to + `[web.oidc].scopes` — `read:repository`, plus `read:admin` if the account + is a Gitea admin and you want runs from every repo on the instance. New + scopes take effect after one fresh login (Gitea shows the consent screen + again); existing sessions keep working with the old scopes until then. --- @@ -123,8 +129,10 @@ systemctl status ci-web.service journalctl -u ci-web.service -f journalctl -u ci-web.service -n 100 --no-pager -# Health check (unauthenticated, always 200) -curl -s http://127.0.0.1:8800/healthz +# Health check (unauthenticated, always 200). Use the address the service is +# bound to -- uvicorn listens on `[web].bind_host` only, so on this host +# (192.168.10.11) a loopback request gets connection-refused, not a 200. +curl -s http://192.168.10.11:8800/healthz ``` Once code changes: `sudo bash setup-web-linux.sh --host ` @@ -239,7 +247,7 @@ allow_stop}` tables. A malformed row is skipped, never crashes startup. - **`vmware-host` stop/restart interrupts running jobs.** The confirm step shows the current count of active VMs ("N active VM(s) — this interrupts running jobs") when stopping or restarting `vmware-host.service`, computed - from the same VM backend the Live VMs panel uses — best-effort, degrades + from the same VM backend the Executions card uses — best-effort, degrades to no warning if the backend is unavailable, never blocks the action. - **Every action is audited exactly once per stage** (`service.start` / `service.stop` / `service.restart`, `target` = the unit name), same as @@ -272,7 +280,55 @@ on the live host with `sudo visudo -c` before relying on the panel. --- -## 8. Troubleshooting +## 8. Executions card (recent Gitea Actions runs) + +The dashboard's top wide table is **Executions**: the most recent workflow +runs, the live ones included, each with its status, workflow, branch/commit, +trigger, duration, and the build VM(s) that run spawned (with their `Remove` +button). It replaced the old read-only "Live VMs" table — a VM alone could +never answer "did the last build pass?". + +Runs come from Gitea's Actions API: `/api/v1/admin/actions/runs` (every repo +on the instance, admin token) with `/api/v1/user/actions/runs` as the +non-admin fallback. `[web].executions_limit` (default 15) caps how many. +Results are cached for 10s, so the card's 5s refresh costs Gitea at most one +call per 10s window; the VM columns are re-read on every refresh. + +**Where the API token comes from.** Normally nowhere extra: the dashboard +reuses the access token from your own OIDC login, so what the card shows is +exactly what your Gitea account can see. That token is kept **server-side +only** (an in-process map keyed by username) and dropped on logout — it is +never written into the session cookie, which is signed but *not* encrypted and +travels over plain HTTP on the LAN. An expired token is refreshed once +automatically; a refusal that refresh cannot fix shows a "log in again" link +in the card. + +Optional fallback: store a Gitea PAT in the keyring and name it in +`[web].gitea_token_cred`. It is used only when no logged-in token is +available — after a service restart (which empties the in-process map while +session cookies stay valid), or for an operator whose own token lacks the +scope. Set it exactly like the other secrets: + +```bash +sudo -u ci-runner /opt/ci/venv/bin/python -m ci_orchestrator creds set \ + --target GiteaApiToken --user gitea-api +``` + +**Degraded states** never take the card down: no token, an expired token, or +an unreachable Gitea each render a warning notice *and still list every +running VM* below it, so the dashboard is never less useful than the VM table +it replaced. + +**Orphaned VMs are not handled in this card.** A powered-on VM whose run is no +longer in the window (or that no run ever claimed) keeps a row of its own +flagged `no run`, with its `Remove` button — but the orphan *sweep* lives in +the **Maintenance** card ("Cleanup orphaned VMs") and the orphan *count* in +the status-header VMs tile. Those two are the places to look at orphans; the +Executions card only avoids hiding them. + +--- + +## 9. Troubleshooting **Login loop (bounces back to `/login` after authenticating with Gitea)** : The registered Redirect URI in Gitea does not exactly match diff --git a/plans/web-ui-management-plan.md b/plans/web-ui-management-plan.md index 4f23197..056d7bf 100644 --- a/plans/web-ui-management-plan.md +++ b/plans/web-ui-management-plan.md @@ -34,6 +34,8 @@ Stato passi (spunta man mano): - [x] **W6** — Azioni mutanti + safety: remove VM, cleanup orfane, purge cache, backup. Confirm gate 2-step (preview dry-run → execute) + CSRF sincronizzatore + audit (preview+execute) + path-containment (clone dir **strettamente** sotto build_vms) + 409 job-race guard. `SessionMiddleware` sempre attivo (secret effimero in open mode) per CSRF. HTMX partials. - [x] **W7** — UI dark Gitea-style (asset vendorizzati: htmx 2.0.3 locale, `app.css` scritto a mano) + installer ripetibile idempotente (`setup-web-linux.sh`, `deploy/systemd/ci-web.service`, `docs/WEB-UI.md`) + validazione finale (611 test, coverage 93.98%, mypy/ruff puliti). Smoke manuale sull'host: TODO operatore (installa + login Gitea + 4 card + dry-run cleanup + `/audit`). +- [x] **Stage F** — Card **Executions** al posto di "Live VMs": run recenti da Gitea Actions (`operations/executions.py`, `GET /executions`), quelli live inclusi, con le VM raggruppate sotto il run che le ha generate (job id `{run_id}-{run_attempt}` ↔ nome clone). Token API = access token del login OIDC tenuto **solo lato server** (`app.state.gitea_tokens`, mai nel cookie di sessione: firmato ma non cifrato, HTTP in chiaro sulla LAN), refresh una volta su 401, PAT keyring opzionale (`[web] gitea_token_cred`) come fallback. Cache TTL 10 s sull'API, VM rilette a ogni poll. Ogni stato degradato mostra un notice e continua a elencare le VM accese. Spec: `docs/superpowers/specs/2026-07-25-executions-card-design.md`. + **Definition of done globale**: `ci web serve` gira come `ci-web.service`, login via Gitea, dashboard dark mostra stato live, le 4 azioni (kill VM / cleanup orfane / purge cache / backup) funzionano con conferma + audit, suite test verde con coverage ≥ 90 % e `mypy --strict` pulito. --- diff --git a/src/ci_orchestrator/config.py b/src/ci_orchestrator/config.py index 67fd360..3a68fb8 100644 --- a/src/ci_orchestrator/config.py +++ b/src/ci_orchestrator/config.py @@ -117,6 +117,14 @@ class WebConfig: management panel is allowed to start/stop/restart. Left unset, the default applies unchanged. See ``_parse_managed_services`` for the permissive-parse contract (a malformed row is skipped, never raised). + + ``gitea_token_cred`` (Executions card) is the **keyring key name** of an + optional Gitea API token used to list recent Actions runs. It is only a + fallback: the dashboard normally reuses the access token from the + operator's own OIDC login (held server-side, never in the session + cookie). Left unset, the card degrades to listing live VMs whenever no + logged-in token is available. ``executions_limit`` caps how many runs the + card asks Gitea for. """ enabled: bool = False @@ -127,6 +135,8 @@ class WebConfig: session_secret_cred: str = "WebSessionSecret" oidc: WebOidcConfig | None = None gitea_url: str | None = None + gitea_token_cred: str | None = None + executions_limit: int = 15 managed_services: tuple[ManagedService, ...] = field( default_factory=lambda: MANAGED_SERVICES ) @@ -279,6 +289,8 @@ def _parse_web_config(web_section: dict[str, Any]) -> WebConfig: allowed_users = web_section.get("allowed_users") audit_log = web_section.get("audit_log") gitea_url = web_section.get("gitea_url") + gitea_token_cred = web_section.get("gitea_token_cred") + executions_limit = web_section.get("executions_limit") return WebConfig( enabled=bool(web_section.get("enabled", False)), @@ -290,9 +302,21 @@ def _parse_web_config(web_section: dict[str, Any]) -> WebConfig: managed_services=_parse_managed_services(web_section.get("managed_services")), oidc=_parse_web_oidc(web_section.get("oidc", {}) or {}), gitea_url=str(gitea_url) if gitea_url else None, + gitea_token_cred=str(gitea_token_cred) if gitea_token_cred else None, + # A non-numeric or nonsensical limit falls back to the default rather + # than raising at startup over a cosmetic setting. + executions_limit=_parse_executions_limit(executions_limit), ) +def _parse_executions_limit(raw: Any) -> int: + """Clamp ``[web] executions_limit`` to 1..100; anything odd -> the default.""" + default = WebConfig().executions_limit + if isinstance(raw, bool) or not isinstance(raw, int): + return default + return max(1, min(raw, 100)) + + def load_config( config_path: Path | None = None, env: dict[str, str] | None = None, diff --git a/src/ci_orchestrator/operations/executions.py b/src/ci_orchestrator/operations/executions.py new file mode 100644 index 0000000..56186f5 --- /dev/null +++ b/src/ci_orchestrator/operations/executions.py @@ -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___``, so the run's + ``-`` 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", +] diff --git a/src/ci_orchestrator/web/app.py b/src/ci_orchestrator/web/app.py index 010a280..8f1e482 100644 --- a/src/ci_orchestrator/web/app.py +++ b/src/ci_orchestrator/web/app.py @@ -44,6 +44,7 @@ from ci_orchestrator.backends import load_backend from ci_orchestrator.backends.errors import BackendError from ci_orchestrator.config import gitea_base_url from ci_orchestrator.operations.cache import CacheStatus, cargo_cache_status +from ci_orchestrator.operations.executions import ExecutionsView, list_executions from ci_orchestrator.operations.host import ( DiskStatus, HostResources, @@ -64,7 +65,13 @@ from ci_orchestrator.operations.inventory import ( from ci_orchestrator.operations.services import ServiceState, service_states from ci_orchestrator.operations.vms import LiveVm, list_live_vms from ci_orchestrator.web import filters -from ci_orchestrator.web.auth import _read_secret, configure_auth, require_user +from ci_orchestrator.web.auth import ( + _read_secret, + configure_auth, + gitea_token_for, + refresh_gitea_token, + require_user, +) from ci_orchestrator.web.security import issue_csrf if TYPE_CHECKING: # pragma: no cover @@ -137,6 +144,56 @@ def _fetch_cache_status(config: Config) -> CacheStatus | None: return None +def _pat_fallback(config: Config) -> str | None: + """The optional keyring PAT for Gitea, or ``None`` when not configured. + + Covers the cases the login token cannot: a process restart (which empties + the server-side token map while sessions stay valid) and a non-admin + operator. A missing/unreadable keyring entry is not an error -- the card + degrades to listing VMs. + """ + cred = config.web.gitea_token_cred + if not cred: + return None + try: + return _read_secret(cred) + except Exception: # degrade, never 500 over an optional secret + return None + + +async def _fetch_executions( + request: Request, config: Config, vms: list[LiveVm] +) -> ExecutionsView: + """Recent Gitea runs with ``vms`` attached. Never raises. + + Token precedence: the access token from this operator's own OIDC login + (held server-side, never in the cookie -- see ``web/auth.py``), then the + optional keyring PAT. A ``401`` triggers exactly one refresh attempt + before the card falls back to its degraded state. + """ + username = request.session.get("user") + stored = gitea_token_for(request.app, username) + if stored is not None and stored.expired and username: + stored = await refresh_gitea_token(request, str(username)) or stored + token = stored.access_token if stored is not None else _pat_fallback(config) + + try: + view = list_executions(config, vms, token=token) + except Exception: # the operations layer already degrades; belt and braces + return ExecutionsView( + runs=[], unmatched_vms=vms, source="none", error="Executions unavailable." + ) + + if view.auth_failed and stored is not None and username: + refreshed = await refresh_gitea_token(request, str(username)) + if refreshed is not None: + try: + return list_executions(config, vms, token=refreshed.access_token) + except Exception: + return view + return view + + def _fetch_disk_status(config: Config) -> DiskStatus | None: """Disk usage for ``config.paths.root``, or ``None`` if unreadable.""" try: @@ -368,10 +425,10 @@ def healthz() -> dict[str, str]: return {"status": "ok"} -def dashboard( +async def dashboard( request: Request, backend: VmBackend | None = Depends(get_backend) ) -> HTMLResponse: - """Full dashboard: VM live, cache, disk, runner, and Stage-B info cards.""" + """Full dashboard: executions, cache, disk, runner, and Stage-B info cards.""" config: Config = request.app.state.config templates: Jinja2Templates = request.app.state.templates vms = _fetch_live_vms(backend, config) @@ -383,6 +440,7 @@ def dashboard( template_rows = _fetch_templates(config) context = { "vms": vms, + "executions": await _fetch_executions(request, config, vms), "backend_available": backend_available, "cache": cache, "disk": disk, @@ -406,18 +464,26 @@ def dashboard( return templates.TemplateResponse(request, "dashboard.html", context) -def vms_partial( +async def executions_partial( request: Request, backend: VmBackend | None = Depends(get_backend) ) -> HTMLResponse: - """``GET /vms`` -- polled every 5s by the dashboard via htmx.""" + """``GET /executions`` -- polled every 5s by the dashboard via htmx. + + Replaces the old ``GET /vms`` card: run-centric, with the live VMs grouped + under the run that spawned them. The runs come from a 10s-TTL cache + (``operations.executions``) while the VM data is read fresh on every poll, + so this cadence costs Gitea at most one API call per TTL window. + """ config: Config = request.app.state.config templates: Jinja2Templates = request.app.state.templates + vms = _fetch_live_vms(backend, config) context = { - "vms": _fetch_live_vms(backend, config), + "vms": vms, + "executions": await _fetch_executions(request, config, vms), "backend_available": backend is not None, "csrf_token": issue_csrf(request), } - return templates.TemplateResponse(request, "partials/vms.html", context) + return templates.TemplateResponse(request, "partials/executions.html", context) def cache_partial(request: Request) -> HTMLResponse: @@ -493,7 +559,7 @@ def services_partial(request: Request) -> HTMLResponse: Needs a fresh ``csrf_token`` on every render (unlike the read-only ``host``/``vmware``/``runners`` cards) -- the panel's start/stop/restart forms are mutating, like the per-row forms in - ``vms_partial``/``templates_partial``/``files_partial``. + ``executions_partial``/``templates_partial``/``files_partial``. """ config: Config = request.app.state.config templates: Jinja2Templates = request.app.state.templates @@ -558,9 +624,9 @@ def create_app(config: Config) -> FastAPI: app.get("/healthz")(healthz) app.get("/", response_class=HTMLResponse, dependencies=[Depends(require_user)])(dashboard) - app.get("/vms", response_class=HTMLResponse, dependencies=[Depends(require_user)])( - vms_partial - ) + app.get( + "/executions", response_class=HTMLResponse, dependencies=[Depends(require_user)] + )(executions_partial) app.get("/cache", response_class=HTMLResponse, dependencies=[Depends(require_user)])( cache_partial ) @@ -617,6 +683,7 @@ __all__ = [ "HealthSummary", "create_app", "dashboard", + "executions_partial", "files_partial", "get_backend", "healthz", diff --git a/src/ci_orchestrator/web/auth.py b/src/ci_orchestrator/web/auth.py index 999c216..e147d32 100644 --- a/src/ci_orchestrator/web/auth.py +++ b/src/ci_orchestrator/web/auth.py @@ -25,10 +25,24 @@ appear in diagnostics. **Allowlist is fail-closed.** After a successful OIDC login the username is checked against ``config.web.allowed_users``; an *empty* allowlist denies everyone (misconfiguration must never mean "allow all"). + +**The Gitea access token NEVER goes into the session.** The login token is +kept for reuse against Gitea's API (the Executions card), but +``SessionMiddleware`` stores the session in a cookie that is *signed, not +encrypted*, and this service is deployed over plain HTTP on the LAN +(``https_only=False``) -- a bearer token in there would be readable in the +browser and on the wire. It therefore lives only in the server-side map on +``app.state.gitea_tokens``, keyed by username (see :func:`store_gitea_token`), +and is dropped on logout. A process restart empties the map: the card then +degrades to "re-authenticate" until the next login, which is the intended +trade-off. """ from __future__ import annotations +import logging +import time +from dataclasses import dataclass from typing import TYPE_CHECKING, Any from authlib.integrations.starlette_client import OAuth @@ -41,10 +55,101 @@ from ci_orchestrator.credentials import KeyringCredentialStore if TYPE_CHECKING: # pragma: no cover from ci_orchestrator.config import Config +logger = logging.getLogger(__name__) + #: Userinfo claims checked, in priority order, to derive the login username. _USERNAME_CLAIMS = ("preferred_username", "login", "username") +@dataclass(frozen=True) +class GiteaToken: + """A Gitea OAuth2 token held server-side for API reuse. + + Never rendered, never logged, never put in the session -- see this + module's docstring for why. + """ + + access_token: str + refresh_token: str | None = None + expires_at: float | None = None + + @property + def expired(self) -> bool: + """True once the access token is past its expiry (30s of slack).""" + return self.expires_at is not None and time.time() >= self.expires_at - 30 + + +def _token_map(app: FastAPI) -> dict[str, GiteaToken]: + """The per-process username -> token map, created on first use.""" + existing = getattr(app.state, "gitea_tokens", None) + if isinstance(existing, dict): + return existing + fresh: dict[str, GiteaToken] = {} + app.state.gitea_tokens = fresh + return fresh + + +def store_gitea_token(app: FastAPI, username: str, token: dict[str, Any]) -> None: + """Keep the access/refresh token of a fresh login for later API calls. + + A token dict without a usable ``access_token`` stores nothing (the login + itself still succeeds -- the Executions card simply degrades). + """ + access = token.get("access_token") + if not isinstance(access, str) or not access: + return + refresh = token.get("refresh_token") + expires_at = token.get("expires_at") + _token_map(app)[username] = GiteaToken( + access_token=access, + refresh_token=refresh if isinstance(refresh, str) and refresh else None, + expires_at=float(expires_at) if isinstance(expires_at, int | float) else None, + ) + + +def gitea_token_for(app: FastAPI, username: str | None) -> GiteaToken | None: + """The stored token for ``username``, or ``None`` if there is none.""" + if not username: + return None + return _token_map(app).get(username) + + +def drop_gitea_token(app: FastAPI, username: str | None) -> None: + """Forget a stored token (logout, or a refusal that refresh cannot fix).""" + if username: + _token_map(app).pop(username, None) + + +async def refresh_gitea_token(request: Request, username: str) -> GiteaToken | None: + """Exchange the stored refresh token for a new access token. + + Returns the new token, or ``None`` when there is nothing to refresh with + or Gitea refuses -- in which case the stored entry is dropped so the card + asks for a fresh login instead of retrying a dead token every poll. + """ + app: FastAPI = request.app + current = gitea_token_for(app, username) + oauth: OAuth | None = getattr(app.state, "oauth", None) + if current is None or current.refresh_token is None or oauth is None: + return None + try: + token: dict[str, Any] = await oauth.gitea.fetch_access_token( + grant_type="refresh_token", refresh_token=current.refresh_token + ) + except Exception as exc: # Authlib raises a wide family here + logger.warning("Gitea token refresh failed for %s: %s", username, exc) + drop_gitea_token(app, username) + return None + # Gitea rotates the refresh token; keep the old one if it does not send one. + if not token.get("refresh_token"): + token["refresh_token"] = current.refresh_token + store_gitea_token(app, username, token) + refreshed = gitea_token_for(app, username) + if refreshed is None: + drop_gitea_token(app, username) + return refreshed + + def _read_secret(cred_name: str) -> str: """Read a secret from the OS keyring by key *name* -- NEVER from TOML. @@ -162,11 +267,19 @@ async def auth_callback(request: Request) -> RedirectResponse: ) request.session["user"] = username + # Reused for Gitea API reads (Executions card). Server-side only: the + # session cookie is signed but not encrypted -- see the module docstring. + store_gitea_token(request.app, username, token) return RedirectResponse("/", status_code=status.HTTP_303_SEE_OTHER) async def logout(request: Request) -> RedirectResponse: - """``GET /logout`` -- clear the signed session and send the user to ``/login``.""" + """``GET /logout`` -- clear the signed session and send the user to ``/login``. + + Also forgets the server-side Gitea token, so logging out really does end + this process's ability to act as that user against Gitea. + """ + drop_gitea_token(request.app, request.session.get("user")) request.session.clear() return RedirectResponse("/login", status_code=status.HTTP_303_SEE_OTHER) @@ -196,10 +309,15 @@ def configure_auth(app: FastAPI, config: Config) -> None: __all__ = [ + "GiteaToken", "auth_callback", "configure_auth", + "drop_gitea_token", + "gitea_token_for", "login", "logout", + "refresh_gitea_token", "register_oauth", "require_user", + "store_gitea_token", ] diff --git a/src/ci_orchestrator/web/filters.py b/src/ci_orchestrator/web/filters.py index bd1ab52..ee48f18 100644 --- a/src/ci_orchestrator/web/filters.py +++ b/src/ci_orchestrator/web/filters.py @@ -31,6 +31,16 @@ _SECONDS_PER_MINUTE = 60 _SECONDS_PER_HOUR = 3600 _SECONDS_PER_DAY = 86400 +#: Badge class per Executions-card run status (see :func:`run_badge`). +_RUN_BADGES: dict[str, str] = { + "running": "badge-info", + "queued": "badge-muted", + "success": "badge-success", + "failure": "badge-danger", + "cancelled": "badge-muted", + "skipped": "badge-muted", +} + def humanbytes(n: int | float) -> str: """Render a byte count as ``"11.8 MB"``-style text (base-1024, decimal display). @@ -158,6 +168,17 @@ def thousands(n: int | float) -> str: return f"{int(n):,}" +def run_badge(status: str | None) -> str: + """Badge CSS class for an :class:`~ci_orchestrator.operations.executions.Execution` status. + + Colour is never the only signal -- the template always prints the status + word next to the badge (same rule as the status-header health tiles), so + an unmapped status degrading to the neutral ``badge-muted`` still reads + correctly. + """ + return _RUN_BADGES.get(status or "", "badge-muted") + + def clone_job(name: str | None) -> ParsedClone: """Parse a VM/clone name into its ``job_id`` + ``started`` (Stage C1). @@ -180,6 +201,7 @@ def register(templates: Jinja2Templates) -> None: templates.env.filters["shorten_path"] = shorten_path templates.env.filters["thousands"] = thousands templates.env.filters["clone_job"] = clone_job + templates.env.filters["run_badge"] = run_badge __all__ = [ @@ -189,6 +211,7 @@ __all__ = [ "localtime", "register", "relative_time", + "run_badge", "shorten_path", "thousands", ] diff --git a/src/ci_orchestrator/web/static/app.css b/src/ci_orchestrator/web/static/app.css index d08f995..3487daa 100644 --- a/src/ci_orchestrator/web/static/app.css +++ b/src/ci_orchestrator/web/static/app.css @@ -376,6 +376,11 @@ tr.row-alert td { color: var(--danger); } .badge-success { background: rgba(63, 185, 80, 0.15); color: var(--success); border: 1px solid rgba(63, 185, 80, 0.4); } .badge-danger { background: rgba(248, 81, 73, 0.15); color: var(--danger); border: 1px solid rgba(248, 81, 73, 0.4); } .badge-warn { background: rgba(210, 153, 34, 0.15); color: var(--warn); border: 1px solid rgba(210, 153, 34, 0.4); } +/* Executions card: "running" (in flight, nothing wrong) and the neutral + * states (queued/cancelled/skipped/unknown) that must not read as either + * success or failure. */ +.badge-info { background: rgba(65, 131, 196, 0.15); color: var(--accent); border: 1px solid rgba(65, 131, 196, 0.4); } +.badge-muted { background: var(--panel-2); color: var(--text-dim); border: 1px solid var(--border); } /* ── buttons ──────────────────────────────────────────────────────────── */ diff --git a/src/ci_orchestrator/web/templates/dashboard.html b/src/ci_orchestrator/web/templates/dashboard.html index f409b57..e42c10e 100644 --- a/src/ci_orchestrator/web/templates/dashboard.html +++ b/src/ci_orchestrator/web/templates/dashboard.html @@ -7,20 +7,21 @@ {% include "partials/status_header.html" %} {# Layout ordering (Stage E). Narrative top-to-bottom: infrastructure health -> - what's running now (VMs) -> assets (templates + their backups) -> build - outputs (artifacts) -> managed services -> the dedicated Maintenance/danger - card last. The wide, many-column tables (vms / templates / files / services - / maintenance) span the full grid row via ``.card--wide``; the four compact - info cards flow in one breathing-room row at the top, ordered Host -> - VMware -> Runners -> Cargo cache, followed by the VMs table and then the - Templates table. + what the CI has been doing (Executions: recent runs, live ones included, + with their VMs) -> assets (templates + their backups) -> build outputs + (artifacts) -> managed services -> the dedicated Maintenance/danger card + last. The wide, many-column tables (executions / templates / files / + services / maintenance) span the full grid row via ``.card--wide``; the four + compact info cards flow in one breathing-room row at the top, ordered Host + -> VMware -> Runners -> Cargo cache, followed by the Executions table and + then the Templates table. #}
{% include "partials/host.html" %}{# Host (CPU/RAM/load) #} {% include "partials/vmware.html" %}{# VMware modules #} {% include "partials/runners.html" %}{# Runners #} {% include "partials/cache.html" %}{# Cargo cache #} - {% include "partials/vms.html" %} + {% include "partials/executions.html" %} {% include "partials/templates.html" %} {% include "partials/files.html" %} {% include "partials/services.html" %} diff --git a/src/ci_orchestrator/web/templates/partials/executions.html b/src/ci_orchestrator/web/templates/partials/executions.html new file mode 100644 index 0000000..a97ff44 --- /dev/null +++ b/src/ci_orchestrator/web/templates/partials/executions.html @@ -0,0 +1,128 @@ +{# + Executions card. Replaces the old "Live VMs" table: one row per recent Gitea + Actions run (the live ones included), with the build VM(s) that run spawned + grouped into its VM cell, Remove action and all. + + Degraded states (no token, expired token, Gitea unreachable) render a notice + and still list every running VM below, so this card is never less useful + than the VM table it replaces. Orphan VMs are NOT handled here: the sweep + lives in the Maintenance card and the count in the status-header VMs tile; + ``executions.unmatched_vms`` only exists so a powered-on VM whose run has + scrolled out of the window (or that no run ever claimed) keeps its row and + its Remove button. +#} +
+

Executions

+ {% if not backend_available %} + + {% endif %} + {% if executions.error %} + + {% endif %} + {% if executions.runs or executions.unmatched_vms %} +
+ + + + + + + + + + + + + + + {% for run in executions.runs %} + + + + + + + + + + + {% endfor %} + {% for vm in executions.unmatched_vms %} + + {% set parsed = vm.name | clone_job %} + + + + + + + + + + {% endfor %} + +
RunWorkflowBranch / commitTriggerStatusDurationVMActions
+ {% if run.html_url %} + #{{ run.number }}{% if run.attempt > 1 %}.{{ run.attempt }}{% endif %} + {% else %} + #{{ run.number }}{% if run.attempt > 1 %}.{{ run.attempt }}{% endif %} + {% endif %} + {% if run.repo %}
{{ run.repo }}{% endif %} +
{{ run.workflow or "—" }} + {{ run.branch or "—" }} + {% if run.commit %}
{{ run.commit[:7] }}{% endif %} +
+ {{ run.event or "—" }} + {% if run.actor %}
{{ run.actor }}{% endif %} +
{{ run.status }} + {{ run.duration_sec | duration }} + {% if run.started %}
{{ run.started | relative_time }}{% endif %} +
+ {% for vm in run.vms %} +
+ {{ vm.name }} +
{{ vm.ip or "no ip" }} · up {{ vm.uptime_sec | duration }} +
+ {% else %} + — + {% endfor %} +
+ {% for vm in run.vms %} +
+ + +
+ {% endfor %} +
+ {{ parsed.job_id or "no run" }} + ——— + {% if vm.is_orphan %} + orphan + {% else %} + running + {% endif %} + + {% if parsed.started %} + {{ parsed.started | relative_time }} + {% else %} + — + {% endif %} + + {{ vm.name }} +
{{ vm.ip or "no ip" }} · up {{ vm.uptime_sec | duration }} +
+
+ + +
+
+
+ {% else %} +

No recent executions{% if not executions.error %} and no VMs running{% endif %}.

+ {% endif %} +
diff --git a/src/ci_orchestrator/web/templates/partials/icons.html b/src/ci_orchestrator/web/templates/partials/icons.html index 3f0dd0a..7654eb3 100644 --- a/src/ci_orchestrator/web/templates/partials/icons.html +++ b/src/ci_orchestrator/web/templates/partials/icons.html @@ -76,4 +76,10 @@ + + + + + + diff --git a/src/ci_orchestrator/web/templates/partials/vms.html b/src/ci_orchestrator/web/templates/partials/vms.html deleted file mode 100644 index fe7debe..0000000 --- a/src/ci_orchestrator/web/templates/partials/vms.html +++ /dev/null @@ -1,58 +0,0 @@ -
-

Live VMs

- {% if not backend_available %} - - {% endif %} - {% if vms %} -
- - - - - - - - - - - - - - - {% for vm in vms %} - {% set parsed = vm.name | clone_job %} - - - - - - - - - - - {% endfor %} - -
NameJobVMXIPGuest OSUptimeStatusActions
{{ vm.name }} - {{ parsed.job_id or "—" }} - {% if parsed.started %} -
{{ parsed.started | relative_time }} - {% endif %} -
{{ vm.vmx | shorten_path(2) }}{{ vm.ip or "-" }}{{ vm.guest_os or "-" }}{{ vm.uptime_sec | duration }} - {% if vm.is_orphan %} - orphan - {% else %} - running - {% endif %} - -
- - -
-
-
- {% else %} -

No VMs currently running.

- {% endif %} -
diff --git a/tests/python/test_operations_executions.py b/tests/python/test_operations_executions.py new file mode 100644 index 0000000..0bdf7cd --- /dev/null +++ b/tests/python/test_operations_executions.py @@ -0,0 +1,567 @@ +"""Tests for the pure ``operations.executions`` layer (Executions card). + +The module under test talks to the Gitea Actions API over +``urllib.request.urlopen``, which is monkeypatched throughout: no test ever +touches the network. Zero ``click``/``print`` in the module itself — every +call returns an :class:`ExecutionsView`, and *no* failure mode raises. +""" + +from __future__ import annotations + +import json +import urllib.error +from datetime import UTC, datetime +from io import BytesIO +from pathlib import Path +from typing import Any + +import pytest + +import ci_orchestrator.operations.executions as ex +from ci_orchestrator.config import ( + BackendConfig, + CargoCacheConfig, + Config, + Paths, + WebConfig, + WebOidcConfig, +) +from ci_orchestrator.operations.executions import Execution, ExecutionsView, list_executions +from ci_orchestrator.operations.vms import LiveVm + +_NOW = datetime(2026, 7, 25, 12, 0, 0, tzinfo=UTC) + + +def _make_config( + tmp_path: Path, + *, + gitea_url: str | None = "https://gitea.example", + limit: int = 15, +) -> Config: + return Config( + paths=Paths( + root=tmp_path, + templates=tmp_path / "templates", + build_vms=tmp_path / "build-vms", + artifacts=tmp_path / "artifacts", + keys=tmp_path / "keys", + ), + backend=BackendConfig(), + cargo_cache=CargoCacheConfig( + enabled=True, root=tmp_path / "cache", retention_days=30 + ), + web=WebConfig(gitea_url=gitea_url, executions_limit=limit), + ) + + +def _run( + *, + run_id: int = 412, + number: int = 12, + attempt: int = 1, + status: str = "success", + conclusion: str = "success", + started: str | None = "2026-07-25T11:50:00Z", + completed: str | None = "2026-07-25T11:57:20Z", + branch: str = "main", + repo: str = "simone/local-ci-cd-system", + path: str = ".gitea/workflows/build-ns7zip.yml", +) -> dict[str, Any]: + """One ``ActionWorkflowRun`` as Gitea 1.26 serialises it.""" + return { + "id": run_id, + "run_number": number, + "run_attempt": attempt, + "status": status, + "conclusion": conclusion, + "started_at": started, + "completed_at": completed, + "head_branch": branch, + "head_sha": "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678", + "event": "push", + "path": path, + "display_title": "feat: something", + "html_url": f"https://gitea.example/{repo}/actions/runs/{number}", + "repository": {"full_name": repo}, + "trigger_actor": {"login": "Simone"}, + } + + +class _FakeResponse: + """Minimal ``urlopen`` context manager returning a JSON body.""" + + def __init__(self, payload: object) -> None: + self._body = BytesIO(json.dumps(payload).encode("utf-8")) + + def __enter__(self) -> BytesIO: + return self._body + + def __exit__(self, *_exc: object) -> None: + return None + + +def _http_error(code: int) -> urllib.error.HTTPError: + return urllib.error.HTTPError( + url="https://gitea.example", code=code, msg="nope", hdrs=None, fp=None + ) + + +def _install_urlopen( + monkeypatch: pytest.MonkeyPatch, handler: Any +) -> list[tuple[str, str | None]]: + """Patch ``urlopen``; return the (url, authorization) log it appends to.""" + calls: list[tuple[str, str | None]] = [] + + def _fake_urlopen(request: Any, timeout: float | None = None) -> Any: + calls.append((request.full_url, request.get_header("Authorization"))) + result = handler(request.full_url) + if isinstance(result, Exception): + raise result + return _FakeResponse(result) + + monkeypatch.setattr(ex.urllib.request, "urlopen", _fake_urlopen) + return calls + + +def _admin_ok(runs: list[dict[str, Any]]) -> Any: + def handler(url: str) -> Any: + if "/admin/actions/runs" in url: + return {"total_count": len(runs), "workflow_runs": runs} + raise AssertionError(f"unexpected url: {url}") + + return handler + + +def _vm(name: str, *, ip: str = "192.168.79.201", uptime: int = 120) -> LiveVm: + return LiveVm( + name=name, + vmx=f"/var/lib/ci/build-vms/{name}/{name}.vmx", + ip=ip, + guest_os="ubuntu-64", + uptime_sec=uptime, + is_orphan=False, + ) + + +@pytest.fixture(autouse=True) +def _clear_cache() -> Any: + """Every test starts with an empty TTL cache (it is module-level state).""" + ex.clear_cache() + yield + ex.clear_cache() + + +# ── happy path ────────────────────────────────────────────────────────────── + + +def test_admin_payload_parsed_into_executions( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + calls = _install_urlopen(monkeypatch, _admin_ok([_run()])) + + view = list_executions(_make_config(tmp_path), [], token="tok", now=_NOW) + + assert view.error is None + assert view.source == "admin" + assert view.auth_failed is False + assert view.runs == [ + Execution( + run_id=412, + number=12, + attempt=1, + workflow="build-ns7zip.yml", + repo="simone/local-ci-cd-system", + branch="main", + commit="a1b2c3d4e5f60718293a4b5c6d7e8f9012345678", + event="push", + actor="Simone", + status="success", + started=datetime(2026, 7, 25, 11, 50, tzinfo=UTC), + completed=datetime(2026, 7, 25, 11, 57, 20, tzinfo=UTC), + duration_sec=440, + html_url="https://gitea.example/simone/local-ci-cd-system/actions/runs/12", + vms=[], + ) + ] + # Bearer auth against the admin endpoint, with the configured limit. + assert calls == [ + ( + "https://gitea.example/api/v1/admin/actions/runs?limit=15", + "Bearer tok", + ) + ] + + +def test_limit_argument_overrides_config( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + calls = _install_urlopen(monkeypatch, _admin_ok([])) + + list_executions(_make_config(tmp_path), [], token="tok", limit=3, now=_NOW) + + assert calls[0][0].endswith("limit=3") + + +def test_running_run_duration_counts_from_now( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + running = _run(status="in_progress", conclusion="", completed=None) + _install_urlopen(monkeypatch, _admin_ok([running])) + + view = list_executions(_make_config(tmp_path), [], token="tok", now=_NOW) + + assert view.runs[0].status == "running" + assert view.runs[0].completed is None + # 11:50:00 -> 12:00:00 + assert view.runs[0].duration_sec == 600 + + +def test_run_without_start_has_no_duration( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + queued = _run(status="waiting", conclusion="", started=None, completed=None) + _install_urlopen(monkeypatch, _admin_ok([queued])) + + view = list_executions(_make_config(tmp_path), [], token="tok", now=_NOW) + + assert view.runs[0].status == "queued" + assert view.runs[0].started is None + assert view.runs[0].duration_sec is None + + +@pytest.mark.parametrize( + ("status", "conclusion", "expected"), + [ + ("in_progress", "", "running"), + ("running", "", "running"), + ("waiting", "", "queued"), + ("queued", "", "queued"), + ("blocked", "", "queued"), + ("completed", "success", "success"), + ("completed", "failure", "failure"), + ("completed", "cancelled", "cancelled"), + ("completed", "skipped", "skipped"), + ("success", "success", "success"), + ("failure", "failure", "failure"), + ("completed", "neutral", "unknown"), + ("", "", "unknown"), + ], +) +def test_status_mapping( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + status: str, + conclusion: str, + expected: str, +) -> None: + _install_urlopen( + monkeypatch, _admin_ok([_run(status=status, conclusion=conclusion)]) + ) + + view = list_executions(_make_config(tmp_path), [], token="tok", now=_NOW) + + assert view.runs[0].status == expected + + +def test_missing_fields_degrade_per_field( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A sparse run object must still produce a row, never an exception.""" + _install_urlopen(monkeypatch, _admin_ok([{"id": 9}])) + + view = list_executions(_make_config(tmp_path), [], token="tok", now=_NOW) + + row = view.runs[0] + assert (row.run_id, row.number, row.attempt) == (9, 0, 1) + assert (row.workflow, row.repo, row.branch, row.commit) == ("", "", "", "") + assert (row.event, row.actor, row.html_url) == ("", "", "") + assert row.status == "unknown" + assert row.duration_sec is None + assert view.error is None + + +def test_actor_falls_back_to_actor_field( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + payload = _run() + del payload["trigger_actor"] + payload["actor"] = {"login": "someone-else"} + _install_urlopen(monkeypatch, _admin_ok([payload])) + + view = list_executions(_make_config(tmp_path), [], token="tok", now=_NOW) + + assert view.runs[0].actor == "someone-else" + + +def test_non_list_runs_payload_is_an_error( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _install_urlopen(monkeypatch, lambda _url: {"workflow_runs": "nope"}) + + view = list_executions(_make_config(tmp_path), [], token="tok", now=_NOW) + + assert view.runs == [] + assert view.error is not None + assert view.source == "none" + + +# ── endpoint fallback ─────────────────────────────────────────────────────── + + +@pytest.mark.parametrize("code", [403, 404]) +def test_admin_denied_falls_back_to_user_endpoint( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, code: int +) -> None: + def handler(url: str) -> Any: + if "/admin/" in url: + return _http_error(code) + return {"workflow_runs": [_run()]} + + calls = _install_urlopen(monkeypatch, handler) + + view = list_executions(_make_config(tmp_path), [], token="tok", now=_NOW) + + assert view.source == "user" + assert view.error is None + assert len(view.runs) == 1 + assert [url for url, _auth in calls] == [ + "https://gitea.example/api/v1/admin/actions/runs?limit=15", + "https://gitea.example/api/v1/user/actions/runs?limit=15", + ] + + +def test_both_endpoints_denied_degrades( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _install_urlopen(monkeypatch, lambda _url: _http_error(403)) + + view = list_executions(_make_config(tmp_path), [], token="tok", now=_NOW) + + assert view.runs == [] + assert view.source == "none" + assert view.error is not None + assert "403" in view.error + + +# ── degraded paths ────────────────────────────────────────────────────────── + + +def test_missing_token_lists_vms_only(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + calls = _install_urlopen(monkeypatch, _admin_ok([_run()])) + vm = _vm("Clone_412-1_20260725_115000_linux") + + view = list_executions(_make_config(tmp_path), [vm], token=None, now=_NOW) + + assert view == ExecutionsView( + runs=[], + unmatched_vms=[vm], + source="none", + error="Gitea token not configured.", + auth_failed=False, + ) + assert calls == [] # no HTTP attempted without a token + + +def test_missing_gitea_url_lists_vms_only( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + calls = _install_urlopen(monkeypatch, _admin_ok([_run()])) + config = _make_config(tmp_path, gitea_url=None) + + view = list_executions(config, [_vm("Clone_1-1_20260725_115000_x")], token="tok", now=_NOW) + + assert view.runs == [] + assert view.error is not None + assert len(view.unmatched_vms) == 1 + assert calls == [] + + +def test_gitea_url_derived_from_oidc_issuer( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """No explicit ``gitea_url``: the OIDC issuer is the fallback base URL.""" + calls = _install_urlopen(monkeypatch, _admin_ok([])) + config = _make_config(tmp_path, gitea_url=None) + config = Config( + paths=config.paths, + backend=config.backend, + cargo_cache=config.cargo_cache, + web=WebConfig( + oidc=WebOidcConfig( + issuer="https://gitea.example", + client_id="cid", + redirect_url="http://host/auth/callback", + ) + ), + ) + + view = list_executions(config, [], token="tok", now=_NOW) + + assert view.error is None + assert calls[0][0].startswith("https://gitea.example/api/v1/admin/") + + +def test_401_sets_auth_failed(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + _install_urlopen(monkeypatch, lambda _url: _http_error(401)) + + view = list_executions(_make_config(tmp_path), [], token="stale", now=_NOW) + + assert view.auth_failed is True + assert view.error is not None + assert view.runs == [] + + +def test_network_error_degrades(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + _install_urlopen(monkeypatch, lambda _url: urllib.error.URLError("no route to host")) + vm = _vm("Clone_412-1_20260725_115000_linux") + + view = list_executions(_make_config(tmp_path), [vm], token="tok", now=_NOW) + + assert view.runs == [] + assert view.unmatched_vms == [vm] + assert view.error is not None + assert view.auth_failed is False + + +def test_timeout_degrades(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + _install_urlopen(monkeypatch, lambda _url: TimeoutError("timed out")) + + view = list_executions(_make_config(tmp_path), [], token="tok", now=_NOW) + + assert view.runs == [] + assert view.error is not None + + +def test_malformed_json_degrades(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + class _BadBody: + def __enter__(self) -> Any: + return self + + def __exit__(self, *_exc: object) -> None: + return None + + def read(self) -> bytes: + return b"{not json" + + monkeypatch.setattr( + ex.urllib.request, "urlopen", lambda _req, timeout=None: _BadBody() + ) + + view = list_executions(_make_config(tmp_path), [], token="tok", now=_NOW) + + assert view.runs == [] + assert view.error is not None + + +# ── VM correlation ────────────────────────────────────────────────────────── + + +def test_vm_attached_to_its_run(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + _install_urlopen(monkeypatch, _admin_ok([_run(run_id=412, attempt=1)])) + vm = _vm("Clone_412-1_20260725_115000_linux") + + view = list_executions(_make_config(tmp_path), [vm], token="tok", now=_NOW) + + assert view.runs[0].vms == [vm] + assert view.unmatched_vms == [] + + +def test_matrix_run_collects_several_vms( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _install_urlopen(monkeypatch, _admin_ok([_run(run_id=412)])) + win = _vm("Clone_412-1-win_20260725_115000_a") + linux = _vm("Clone_412-1-linux_20260725_115005_b") + + view = list_executions(_make_config(tmp_path), [win, linux], token="tok", now=_NOW) + + assert view.runs[0].vms == [win, linux] + assert view.unmatched_vms == [] + + +def test_attempt_is_part_of_the_match( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A clone from attempt 1 must not attach to attempt 2 of the same run.""" + _install_urlopen(monkeypatch, _admin_ok([_run(run_id=412, attempt=2)])) + vm = _vm("Clone_412-1_20260725_115000_linux") + + view = list_executions(_make_config(tmp_path), [vm], token="tok", now=_NOW) + + assert view.runs[0].vms == [] + assert view.unmatched_vms == [vm] + + +def test_unparseable_and_stale_vms_are_unmatched( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _install_urlopen(monkeypatch, _admin_ok([_run(run_id=412)])) + stale = _vm("Clone_370-1_20260722_101500_linux") + weird = _vm("some-manually-made-vm") + + view = list_executions(_make_config(tmp_path), [stale, weird], token="tok", now=_NOW) + + assert view.runs[0].vms == [] + assert view.unmatched_vms == [stale, weird] + + +# ── TTL cache ─────────────────────────────────────────────────────────────── + + +def test_second_call_within_ttl_reuses_the_fetch( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + calls = _install_urlopen(monkeypatch, _admin_ok([_run(run_id=412)])) + clock = [1000.0] + monkeypatch.setattr(ex, "_monotonic", lambda: clock[0]) + config = _make_config(tmp_path) + vm = _vm("Clone_412-1_20260725_115000_linux") + + first = list_executions(config, [], token="tok", now=_NOW) + clock[0] += ex._TTL_SEC / 2 + second = list_executions(config, [vm], token="tok", now=_NOW) + + assert len(calls) == 1 + assert first.runs[0].vms == [] + # Cached *runs*, freshly correlated: the VM shows up without a new fetch. + assert second.runs[0].vms == [vm] + + +def test_expired_ttl_refetches(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + calls = _install_urlopen(monkeypatch, _admin_ok([_run()])) + clock = [1000.0] + monkeypatch.setattr(ex, "_monotonic", lambda: clock[0]) + config = _make_config(tmp_path) + + list_executions(config, [], token="tok", now=_NOW) + clock[0] += ex._TTL_SEC + 0.1 + list_executions(config, [], token="tok", now=_NOW) + + assert len(calls) == 2 + + +def test_cache_is_keyed_by_token_and_limit( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + calls = _install_urlopen(monkeypatch, _admin_ok([_run()])) + monkeypatch.setattr(ex, "_monotonic", lambda: 1000.0) + config = _make_config(tmp_path) + + list_executions(config, [], token="tok", now=_NOW) + list_executions(config, [], token="other", now=_NOW) + list_executions(config, [], token="other", limit=5, now=_NOW) + + assert len(calls) == 3 + + +def test_errors_are_cached_too(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """A Gitea outage must not turn every 5s poll into an HTTP attempt.""" + calls = _install_urlopen(monkeypatch, lambda _url: urllib.error.URLError("down")) + monkeypatch.setattr(ex, "_monotonic", lambda: 1000.0) + config = _make_config(tmp_path) + + first = list_executions(config, [], token="tok", now=_NOW) + second = list_executions(config, [], token="tok", now=_NOW) + + assert first.error == second.error + assert len(calls) == 1 diff --git a/tests/python/test_web_actions.py b/tests/python/test_web_actions.py index 6a47f29..d8877b9 100644 --- a/tests/python/test_web_actions.py +++ b/tests/python/test_web_actions.py @@ -387,7 +387,7 @@ def test_vm_remove_preview_then_execute_full_cycle( app.dependency_overrides[get_backend] = lambda: fake_backend client = TestClient(app) - resp = client.get("/vms") + resp = client.get("/executions") assert resp.status_code == 200 csrf = _csrf_from(resp.text) vmx_id = routes_module.encode_vmx_id(vmx) diff --git a/tests/python/test_web_app.py b/tests/python/test_web_app.py index 66595be..8d869e2 100644 --- a/tests/python/test_web_app.py +++ b/tests/python/test_web_app.py @@ -110,7 +110,7 @@ def test_dashboard_renders_all_four_cards( resp = client.get("/") assert resp.status_code == 200 - for needle in ("Live VMs", "Cargo cache", "Disk", "Runners"): + for needle in ("Executions", "Cargo cache", "Disk", "Runners"): assert needle in resp.text @@ -128,40 +128,40 @@ def test_dashboard_shows_backend_unavailable_notice_when_backend_is_none( assert "unavailable" in resp.text.lower() -# ── /vms ────────────────────────────────────────────────────────────────── +# ── /executions ────────────────────────────────────────────────────────────────── -def test_vms_route_renders_injected_vm_and_never_touches_real_vmrun(tmp_path: Path) -> None: +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("/vms") + resp = client.get("/executions") 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: +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("/vms") + resp = client.get("/executions") assert resp.status_code == 200 assert "unavailable" in resp.text.lower() -def test_vms_route_degrades_on_unexpected_backend_error(tmp_path: Path) -> None: +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("/vms") + resp = client.get("/executions") assert resp.status_code == 200 @@ -176,7 +176,7 @@ def test_get_backend_degrades_to_none_when_vmrun_missing( app = create_app(_make_config(tmp_path)) client = TestClient(app) - resp = client.get("/vms") + resp = client.get("/executions") assert resp.status_code == 200 assert "unavailable" in resp.text.lower() diff --git a/tests/python/test_web_auth.py b/tests/python/test_web_auth.py index abf6f7e..2e195cc 100644 --- a/tests/python/test_web_auth.py +++ b/tests/python/test_web_auth.py @@ -47,7 +47,7 @@ _OIDC_CRED_NAME = "TestWebOidcClientSecret" _FAKE_SESSION_SECRET = "sess-secret-do-not-leak-abc123" _FAKE_OIDC_CLIENT_SECRET = "oidc-client-secret-do-not-leak-xyz987" -_PROTECTED_ROUTES = ["/", "/vms", "/cache", "/host"] +_PROTECTED_ROUTES = ["/", "/executions", "/cache", "/host"] # ── shared fixtures / fakes ───────────────────────────────────────────────── diff --git a/tests/python/test_web_executions.py b/tests/python/test_web_executions.py new file mode 100644 index 0000000..eb02154 --- /dev/null +++ b/tests/python/test_web_executions.py @@ -0,0 +1,686 @@ +"""Tests for the Executions card: route, template, and token handling. + +Three separate contracts live here: + +1. ``GET /executions`` renders run rows with their VMs, and degrades to a + notice + the plain VM rows whenever Gitea cannot be reached. +2. The Gitea access token from an OIDC login is kept **server-side only** -- + never in the signed-but-unencrypted session cookie (SECURITY-CRITICAL, + see ``web/auth.py``'s module docstring). +3. A ``401`` from Gitea triggers exactly one refresh attempt. + +No network, no keyring, no real ``vmrun``: the backend is injected, the +keyring is monkeypatched, and Gitea is either stubbed at +``operations.executions.list_executions`` (template-level tests) or at +``urlopen`` (one end-to-end pass). +""" + +from __future__ import annotations + +import json +import shutil +import urllib.error +from collections import namedtuple +from datetime import UTC, datetime +from io import BytesIO +from pathlib import Path +from typing import Any + +import pytest +from fastapi.responses import RedirectResponse +from fastapi.testclient import TestClient + +import ci_orchestrator.operations.executions as ex +import ci_orchestrator.web.app as app_module +from ci_orchestrator.backends.protocol import VmHandle +from ci_orchestrator.config import ( + BackendConfig, + CargoCacheConfig, + Config, + Paths, + WebConfig, + WebOidcConfig, +) +from ci_orchestrator.credentials import Credential, KeyringCredentialStore +from ci_orchestrator.operations.executions import Execution, ExecutionsView +from ci_orchestrator.operations.vms import LiveVm +from ci_orchestrator.web.app import create_app, get_backend +from ci_orchestrator.web.auth import gitea_token_for, store_gitea_token + +_SESSION_CRED_NAME = "TestWebSessionSecret" +_OIDC_CRED_NAME = "TestWebOidcClientSecret" +_PAT_CRED_NAME = "TestGiteaApiToken" +_FAKE_PAT = "pat-do-not-leak-123" + + +# ── fixtures / fakes ──────────────────────────────────────────────────────── + + +def _install_fake_keyring(monkeypatch: pytest.MonkeyPatch) -> None: + secrets = { + _SESSION_CRED_NAME: "sess-secret", + _OIDC_CRED_NAME: "oidc-client-secret", + _PAT_CRED_NAME: _FAKE_PAT, + } + + def fake_get(self: KeyringCredentialStore, target: str) -> Credential: + if target not in secrets: + raise KeyError(target) + return Credential(username=target, password=secrets[target]) + + monkeypatch.setattr(KeyringCredentialStore, "get", fake_get) + + +def _quiet_host(monkeypatch: pytest.MonkeyPatch) -> None: + _DiskUsage = namedtuple("_DiskUsage", ["total", "used", "free"]) + gb = 1024**3 + monkeypatch.setattr( + shutil, "disk_usage", lambda _p: _DiskUsage(total=100 * gb, used=40 * gb, free=60 * gb) + ) + monkeypatch.setattr(shutil, "which", lambda _name: None) + + +def _make_config( + root: Path, + *, + oidc: bool = False, + gitea_url: str | None = "https://gitea.example.lan", + token_cred: str | None = None, +) -> Config: + oidc_config = None + if oidc: + oidc_config = WebOidcConfig( + issuer="https://gitea.example.lan", + client_id="ci-web", + redirect_url="http://127.0.0.1:8800/auth/callback", + client_secret_cred=_OIDC_CRED_NAME, + scopes=["openid", "profile", "read:repository"], + ) + 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 + ), + web=WebConfig( + enabled=True, + allowed_users=["Simone"], + session_secret_cred=_SESSION_CRED_NAME, + oidc=oidc_config, + gitea_url=gitea_url, + gitea_token_cred=token_cred, + ), + ) + + +class _FakeBackend: + """Stub ``VmBackend`` -- proves no real ``vmrun`` is ever invoked.""" + + def __init__(self, handles: list[VmHandle]) -> None: + self._handles = handles + + def list_running(self) -> list[VmHandle]: + return list(self._handles) + + def get_ip(self, _handle: VmHandle, timeout: float = 0.0) -> str | None: + return "192.168.79.201" + + +def _make_clone_vmx(build_vms: Path, name: str) -> Path: + vmx = build_vms / name / f"{name}.vmx" + vmx.parent.mkdir(parents=True, exist_ok=True) + vmx.write_text('guestOS = "ubuntu-64"\n', encoding="utf-8") + return vmx + + +def _vm(name: str) -> LiveVm: + return LiveVm( + name=name, + vmx=f"/var/lib/ci/build-vms/{name}/{name}.vmx", + ip="192.168.79.201", + guest_os="ubuntu-64", + uptime_sec=180, + is_orphan=False, + ) + + +def _execution(**overrides: Any) -> Execution: + base: dict[str, Any] = { + "run_id": 412, + "number": 12, + "attempt": 1, + "workflow": "build-ns7zip.yml", + "repo": "simone/local-ci-cd-system", + "branch": "main", + "commit": "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678", + "event": "push", + "actor": "Simone", + "status": "running", + "started": datetime(2026, 7, 25, 11, 50, tzinfo=UTC), + "completed": None, + "duration_sec": 600, + "html_url": "https://gitea.example.lan/simone/local-ci-cd-system/actions/runs/12", + "vms": [], + } + base.update(overrides) + return Execution(**base) + + +def _stub_view(monkeypatch: pytest.MonkeyPatch, view: ExecutionsView) -> list[str | None]: + """Replace the operations call; return the log of tokens it was given.""" + tokens: list[str | None] = [] + + def fake_list_executions( + _config: Config, _vms: list[LiveVm], *, token: str | None, **_kw: Any + ) -> ExecutionsView: + tokens.append(token) + return view + + monkeypatch.setattr(app_module, "list_executions", fake_list_executions) + return tokens + + +@pytest.fixture(autouse=True) +def _clear_executions_cache() -> Any: + ex.clear_cache() + yield + ex.clear_cache() + + +# ── 1. rendering ──────────────────────────────────────────────────────────── + + +def test_card_renders_run_row_with_its_vm( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + vm = _vm("Clone_412-1_20260725_115000_linux") + _stub_view( + monkeypatch, + ExecutionsView( + runs=[_execution(vms=[vm])], unmatched_vms=[], source="admin", error=None + ), + ) + app = create_app(_make_config(tmp_path)) + app.dependency_overrides[get_backend] = lambda: _FakeBackend([]) + client = TestClient(app) + + resp = client.get("/executions") + + assert resp.status_code == 200 + assert "#12" in resp.text + assert "build-ns7zip.yml" in resp.text + assert "simone/local-ci-cd-system" in resp.text + assert "a1b2c3d" in resp.text # commit shortened to 7 chars + assert "badge-info" in resp.text # running badge + assert ">running<" in resp.text # never colour-only + assert vm.name in resp.text + assert "Remove" in resp.text + + +def test_attempt_shown_only_when_greater_than_one( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _stub_view( + monkeypatch, + ExecutionsView( + runs=[_execution(attempt=2)], unmatched_vms=[], source="admin", error=None + ), + ) + app = create_app(_make_config(tmp_path)) + app.dependency_overrides[get_backend] = lambda: _FakeBackend([]) + client = TestClient(app) + + resp = client.get("/executions") + + assert "#12.2" in resp.text + + +@pytest.mark.parametrize( + ("status", "badge"), + [ + ("success", "badge-success"), + ("failure", "badge-danger"), + ("queued", "badge-muted"), + ("cancelled", "badge-muted"), + ("unknown", "badge-muted"), + ], +) +def test_status_badges( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, status: str, badge: str +) -> None: + _stub_view( + monkeypatch, + ExecutionsView( + runs=[_execution(status=status)], unmatched_vms=[], source="admin", error=None + ), + ) + app = create_app(_make_config(tmp_path)) + app.dependency_overrides[get_backend] = lambda: _FakeBackend([]) + client = TestClient(app) + + resp = client.get("/executions") + + assert badge in resp.text + assert f">{status}<" in resp.text + + +def test_empty_state_when_no_runs_and_no_vms( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _stub_view( + monkeypatch, + ExecutionsView(runs=[], unmatched_vms=[], source="admin", error=None), + ) + app = create_app(_make_config(tmp_path)) + app.dependency_overrides[get_backend] = lambda: _FakeBackend([]) + client = TestClient(app) + + resp = client.get("/executions") + + assert "No recent executions" in resp.text + assert "" not in resp.text + + +def test_unmatched_vm_keeps_its_row_and_remove_button( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A powered-on VM with no listed run must not vanish from the dashboard.""" + vm = _vm("Clone_370-1_20260722_101500_linux") + _stub_view( + monkeypatch, + ExecutionsView(runs=[], unmatched_vms=[vm], source="admin", error=None), + ) + app = create_app(_make_config(tmp_path)) + app.dependency_overrides[get_backend] = lambda: _FakeBackend([]) + client = TestClient(app) + + resp = client.get("/executions") + + assert vm.name in resp.text + assert "370-1" in resp.text # job id parsed out of the clone name + assert "Remove" in resp.text + + +def test_end_to_end_against_a_stubbed_gitea( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """No stubbing of the operations layer: only ``urlopen`` is faked.""" + build_vms = tmp_path / "build-vms" + vmx = _make_clone_vmx(build_vms, "Clone_412-1_20260725_115000_abc") + payload = { + "workflow_runs": [ + { + "id": 412, + "run_number": 12, + "run_attempt": 1, + "status": "in_progress", + "head_branch": "main", + "path": ".gitea/workflows/build-ns7zip.yml", + "repository": {"full_name": "simone/local-ci-cd-system"}, + "html_url": "https://gitea.example.lan/run/12", + } + ] + } + + class _Resp: + def __enter__(self) -> BytesIO: + return BytesIO(json.dumps(payload).encode("utf-8")) + + def __exit__(self, *_exc: object) -> None: + return None + + monkeypatch.setattr(ex.urllib.request, "urlopen", lambda _r, timeout=None: _Resp()) + _install_fake_keyring(monkeypatch) + app = create_app(_make_config(tmp_path, token_cred=_PAT_CRED_NAME)) + app.dependency_overrides[get_backend] = lambda: _FakeBackend( + [VmHandle(identifier=str(vmx))] + ) + client = TestClient(app) + + resp = client.get("/executions") + + assert resp.status_code == 200 + assert "#12" in resp.text + assert "build-ns7zip.yml" in resp.text + # The clone is attached to its run, so it is not listed as "no run". + assert "no run" not in resp.text + + +# ── 2. degraded states ────────────────────────────────────────────────────── + + +def test_no_token_shows_notice_and_still_lists_vms( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + build_vms = tmp_path / "build-vms" + vmx = _make_clone_vmx(build_vms, "Clone_412-1_20260725_115000_abc") + app = create_app(_make_config(tmp_path)) # no token_cred, no login + app.dependency_overrides[get_backend] = lambda: _FakeBackend( + [VmHandle(identifier=str(vmx))] + ) + client = TestClient(app) + + resp = client.get("/executions") + + assert resp.status_code == 200 + assert "token not configured" in resp.text + assert "Clone_412-1_20260725_115000_abc" in resp.text + assert "Remove" in resp.text + + +def test_gitea_unreachable_degrades(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + ex.urllib.request, + "urlopen", + lambda _r, timeout=None: (_ for _ in ()).throw(urllib.error.URLError("down")), + ) + _install_fake_keyring(monkeypatch) + app = create_app(_make_config(tmp_path, token_cred=_PAT_CRED_NAME)) + app.dependency_overrides[get_backend] = lambda: _FakeBackend([]) + client = TestClient(app) + + resp = client.get("/executions") + + assert resp.status_code == 200 + assert "unreachable" in resp.text + + +def test_auth_failure_offers_a_relogin_link( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _stub_view( + monkeypatch, + ExecutionsView( + runs=[], + unmatched_vms=[], + source="none", + error="Gitea API returned HTTP 401.", + auth_failed=True, + ), + ) + app = create_app(_make_config(tmp_path)) + app.dependency_overrides[get_backend] = lambda: _FakeBackend([]) + client = TestClient(app) + + resp = client.get("/executions") + + assert 'href="/login"' in resp.text + + +def test_operations_layer_blowing_up_does_not_500( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + def boom(*_args: Any, **_kwargs: Any) -> ExecutionsView: + raise RuntimeError("unexpected") + + monkeypatch.setattr(app_module, "list_executions", boom) + app = create_app(_make_config(tmp_path)) + app.dependency_overrides[get_backend] = lambda: _FakeBackend([]) + client = TestClient(app) + + resp = client.get("/executions") + + assert resp.status_code == 200 + assert "Executions unavailable" in resp.text + + +def test_missing_pat_keyring_entry_degrades( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A configured-but-absent keyring PAT must not 500 the card.""" + _install_fake_keyring(monkeypatch) + app = create_app(_make_config(tmp_path, token_cred="NoSuchCred")) + app.dependency_overrides[get_backend] = lambda: _FakeBackend([]) + client = TestClient(app) + + resp = client.get("/executions") + + assert resp.status_code == 200 + assert "token not configured" in resp.text + + +def test_pat_is_used_when_configured(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + tokens = _stub_view( + monkeypatch, + ExecutionsView(runs=[], unmatched_vms=[], source="admin", error=None), + ) + _install_fake_keyring(monkeypatch) + app = create_app(_make_config(tmp_path, token_cred=_PAT_CRED_NAME)) + app.dependency_overrides[get_backend] = lambda: _FakeBackend([]) + client = TestClient(app) + + client.get("/executions") + + assert tokens == [_FAKE_PAT] + + +def test_old_vms_route_is_gone(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + app = create_app(_make_config(tmp_path)) + app.dependency_overrides[get_backend] = lambda: _FakeBackend([]) + client = TestClient(app) + + assert client.get("/vms").status_code == 404 + + +# ── 3. token handling (SECURITY-CRITICAL) ────────────────────────────────── + + +class _StubOAuthClient: + """Fake Authlib ``gitea`` client -- no network, chosen token/claims.""" + + def __init__(self, token: dict[str, Any], refreshed: dict[str, Any] | None = None) -> None: + self.token = token + self.refreshed = refreshed + self.refresh_calls: list[dict[str, Any]] = [] + + async def authorize_redirect( + self, request: Any, redirect_uri: str | None = None, **kwargs: Any + ) -> RedirectResponse: + return RedirectResponse("https://gitea.example.lan/authorize", status_code=302) + + async def authorize_access_token(self, request: Any, **kwargs: Any) -> dict[str, Any]: + return self.token + + async def userinfo(self, **kwargs: Any) -> dict[str, Any]: + return {"preferred_username": "Simone"} + + async def fetch_access_token(self, **kwargs: Any) -> dict[str, Any]: + self.refresh_calls.append(kwargs) + if self.refreshed is None: + raise RuntimeError("refresh refused") + return self.refreshed + + +def _login(app: Any, client: TestClient) -> None: + """Drive the OIDC callback with the stubbed client.""" + resp = client.get("/auth/callback?code=x&state=y", follow_redirects=False) + assert resp.status_code == 303 + + +def test_login_keeps_token_server_side_and_out_of_the_cookie( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """SECURITY: the session cookie is signed, NOT encrypted -- no token in it.""" + _install_fake_keyring(monkeypatch) + secret = "gitea-access-token-do-not-leak" + app = create_app(_make_config(tmp_path, oidc=True)) + app.state.oauth.gitea = _StubOAuthClient( + {"access_token": secret, "refresh_token": "r3fr3sh", "expires_at": 9999999999} + ) + app.dependency_overrides[get_backend] = lambda: _FakeBackend([]) + client = TestClient(app) + + _login(app, client) + + stored = gitea_token_for(app, "Simone") + assert stored is not None + assert stored.access_token == secret + assert stored.refresh_token == "r3fr3sh" + # Nothing token-shaped anywhere in the cookie jar. + cookies = "".join(f"{name}={value}" for name, value in client.cookies.items()) + assert secret not in cookies + assert "r3fr3sh" not in cookies + + +def test_logout_drops_the_stored_token( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _install_fake_keyring(monkeypatch) + app = create_app(_make_config(tmp_path, oidc=True)) + app.state.oauth.gitea = _StubOAuthClient({"access_token": "tok"}) + app.dependency_overrides[get_backend] = lambda: _FakeBackend([]) + client = TestClient(app) + _login(app, client) + assert gitea_token_for(app, "Simone") is not None + + client.get("/logout", follow_redirects=False) + + assert gitea_token_for(app, "Simone") is None + + +def test_login_token_is_used_for_the_card( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _install_fake_keyring(monkeypatch) + tokens = _stub_view( + monkeypatch, + ExecutionsView(runs=[], unmatched_vms=[], source="admin", error=None), + ) + _quiet_host(monkeypatch) + app = create_app(_make_config(tmp_path, oidc=True, token_cred=_PAT_CRED_NAME)) + app.state.oauth.gitea = _StubOAuthClient({"access_token": "login-token"}) + app.dependency_overrides[get_backend] = lambda: _FakeBackend([]) + client = TestClient(app) + _login(app, client) + + client.get("/executions") + + # The login token wins over the configured PAT fallback. + assert tokens == ["login-token"] + + +def test_401_triggers_one_refresh_then_retries( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _install_fake_keyring(monkeypatch) + app = create_app(_make_config(tmp_path, oidc=True)) + stub = _StubOAuthClient( + {"access_token": "stale", "refresh_token": "r3fr3sh"}, + refreshed={"access_token": "fresh"}, + ) + app.state.oauth.gitea = stub + app.dependency_overrides[get_backend] = lambda: _FakeBackend([]) + client = TestClient(app) + _login(app, client) + + seen: list[str | None] = [] + + def fake_list_executions( + _config: Config, _vms: list[LiveVm], *, token: str | None, **_kw: Any + ) -> ExecutionsView: + seen.append(token) + if token == "stale": + return ExecutionsView( + runs=[], + unmatched_vms=[], + source="none", + error="Gitea API returned HTTP 401.", + auth_failed=True, + ) + return ExecutionsView( + runs=[_execution()], unmatched_vms=[], source="admin", error=None + ) + + monkeypatch.setattr(app_module, "list_executions", fake_list_executions) + + resp = client.get("/executions") + + assert seen == ["stale", "fresh"] + assert len(stub.refresh_calls) == 1 + assert "#12" in resp.text + assert gitea_token_for(app, "Simone") is not None + + +def test_failed_refresh_drops_the_token_and_degrades( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _install_fake_keyring(monkeypatch) + app = create_app(_make_config(tmp_path, oidc=True)) + app.state.oauth.gitea = _StubOAuthClient( + {"access_token": "stale", "refresh_token": "r3fr3sh"}, refreshed=None + ) + app.dependency_overrides[get_backend] = lambda: _FakeBackend([]) + client = TestClient(app) + _login(app, client) + _stub_view( + monkeypatch, + ExecutionsView( + runs=[], + unmatched_vms=[], + source="none", + error="Gitea API returned HTTP 401.", + auth_failed=True, + ), + ) + + resp = client.get("/executions") + + assert resp.status_code == 200 + assert 'href="/login"' in resp.text + assert gitea_token_for(app, "Simone") is None + + +def test_expired_token_is_refreshed_before_use( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _install_fake_keyring(monkeypatch) + app = create_app(_make_config(tmp_path, oidc=True)) + stub = _StubOAuthClient( + {"access_token": "old", "refresh_token": "r3fr3sh", "expires_at": 1}, + refreshed={"access_token": "renewed"}, + ) + app.state.oauth.gitea = stub + app.dependency_overrides[get_backend] = lambda: _FakeBackend([]) + client = TestClient(app) + _login(app, client) + tokens = _stub_view( + monkeypatch, + ExecutionsView(runs=[], unmatched_vms=[], source="admin", error=None), + ) + + client.get("/executions") + + assert tokens == ["renewed"] + + +def test_token_without_access_token_is_not_stored( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A login that yields no usable token still logs in; the card degrades.""" + _install_fake_keyring(monkeypatch) + app = create_app(_make_config(tmp_path, oidc=True)) + app.state.oauth.gitea = _StubOAuthClient({"userinfo": {"preferred_username": "Simone"}}) + app.dependency_overrides[get_backend] = lambda: _FakeBackend([]) + client = TestClient(app) + + _login(app, client) + + assert gitea_token_for(app, "Simone") is None + + +def test_store_and_lookup_helpers_are_per_username(tmp_path: Path) -> None: + app = create_app(_make_config(tmp_path)) + store_gitea_token(app, "a", {"access_token": "tok-a"}) + store_gitea_token(app, "b", {"access_token": "tok-b"}) + + assert gitea_token_for(app, "a") is not None + assert gitea_token_for(app, "b") is not None + assert gitea_token_for(app, "a").access_token == "tok-a" # type: ignore[union-attr] + assert gitea_token_for(app, None) is None + assert gitea_token_for(app, "nobody") is None diff --git a/tests/python/test_web_stage_c.py b/tests/python/test_web_stage_c.py index dfa0cbe..b3f05b1 100644 --- a/tests/python/test_web_stage_c.py +++ b/tests/python/test_web_stage_c.py @@ -75,10 +75,10 @@ def _make_clone_vmx(build_vms: Path, clone_name: str) -> Path: # ── VM table Job column (C1) ────────────────────────────────────────────── -def test_vms_table_renders_job_column_header(tmp_path: Path) -> None: - # The Job column header lives inside the VM table, which only renders when - # at least one VM is running (an empty backend shows the "no VMs" notice - # and no table at all), so seed one clone before asserting on the header. +def test_executions_table_renders_run_column_header(tmp_path: Path) -> None: + # The Run column header lives inside the Executions table, which only + # renders when there is something to show (no runs and no VMs shows a + # notice and no table at all), so seed one clone before asserting. build_vms = tmp_path / "build-vms" build_vms.mkdir(parents=True) vmx = _make_clone_vmx(build_vms, "Clone_build-7_20250101_120000_deadbe") @@ -91,7 +91,7 @@ def test_vms_table_renders_job_column_header(tmp_path: Path) -> None: resp = client.get("/") assert resp.status_code == 200 - assert "" in resp.text + assert "" in resp.text def test_vms_table_renders_parsed_job_id_and_started(tmp_path: Path) -> None: @@ -104,7 +104,7 @@ def test_vms_table_renders_parsed_job_id_and_started(tmp_path: Path) -> None: app.dependency_overrides[get_backend] = lambda: _FakeBackend([VmHandle(identifier=str(vmx))]) client = TestClient(app) - resp = client.get("/vms") + resp = client.get("/executions") assert resp.status_code == 200 assert "build-42" in resp.text @@ -120,7 +120,7 @@ def test_vms_table_unparseable_name_shows_placeholder_not_error(tmp_path: Path) app.dependency_overrides[get_backend] = lambda: _FakeBackend([VmHandle(identifier=str(vmx))]) client = TestClient(app) - resp = client.get("/vms") + resp = client.get("/executions") assert resp.status_code == 200 assert "—" in resp.text # em-dash placeholder for a missing job_id @@ -178,7 +178,9 @@ def test_dashboard_nav_omits_gitea_link_when_unconfigured(tmp_path: Path) -> Non resp = client.get("/") assert resp.status_code == 200 - assert "Gitea" not in resp.text + # Not "Gitea" anywhere: the Executions card names Gitea in its + # degraded notice. Only the nav link-out must be absent. + assert ">Gitea None:
JobRun