From af7dee60e58f342b9ee9863216fa391fef19df2e Mon Sep 17 00:00:00 2001 From: Simone Date: Sat, 25 Jul 2026 14:55:27 +0200 Subject: [PATCH] feat(web): Executions card replaces Live VMs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dashboard could only show what was powered on this instant, so it could not answer "did the last build pass?" — and no local execution history exists to answer it from (`report job` reads a PowerShell-era JSONL path that nothing writes on this host, and the recent artifact dirs are empty). Recent runs now come from Gitea's Actions API (`operations/executions.py`): `/api/v1/admin/actions/runs` for every repo on the instance, falling back to `/api/v1/user/actions/runs` when the token is not admin-scoped. One list call per refresh, cached 10s, so the card's 5s htmx poll costs Gitea at most one call per window. Live VMs are grouped under the run that spawned them — the job id is `{run_id}-{run_attempt}[-suffix]` and the clone name carries it — so a matrix run collects several VMs on one row. Every failure mode (no token, 401, unreachable Gitea, malformed payload) degrades to a notice plus the plain VM rows, each keeping its Remove button, so the card is never less useful than the table it replaces. Orphans stay where they were: the sweep in Maintenance, the count in the status-header tile. Auth reuses the access token from the operator's own OIDC login, which `auth.py` previously discarded. SECURITY: that token is kept server-side only (`app.state.gitea_tokens`, dropped on logout) and never written into the session — `SessionMiddleware` signs the session cookie but does not encrypt it, and this service is deployed over plain HTTP on the LAN, so a bearer token in there would be readable in the browser and on the wire. An expired token is refreshed once; `[web] gitea_token_cred` names an optional keyring PAT for the cases a login token cannot cover (service restart, non-admin operator). `GET /vms` and `partials/vms.html` are gone; `POST /vms/{id}/remove` and `POST /vms/cleanup` are untouched. Design: docs/superpowers/specs/2026-07-25-executions-card-design.md --- CLAUDE.md | 12 +- config.example.toml | 15 +- docs/WEB-UI.md | 74 +- plans/web-ui-management-plan.md | 2 + src/ci_orchestrator/config.py | 24 + src/ci_orchestrator/operations/executions.py | 379 ++++++++++ src/ci_orchestrator/web/app.py | 89 ++- src/ci_orchestrator/web/auth.py | 120 ++- src/ci_orchestrator/web/filters.py | 23 + src/ci_orchestrator/web/static/app.css | 5 + .../web/templates/dashboard.html | 17 +- .../web/templates/partials/executions.html | 128 ++++ .../web/templates/partials/icons.html | 6 + .../web/templates/partials/vms.html | 58 -- tests/python/test_operations_executions.py | 567 +++++++++++++++ tests/python/test_web_actions.py | 2 +- tests/python/test_web_app.py | 18 +- tests/python/test_web_auth.py | 2 +- tests/python/test_web_executions.py | 686 ++++++++++++++++++ tests/python/test_web_stage_c.py | 18 +- 20 files changed, 2136 insertions(+), 109 deletions(-) create mode 100644 src/ci_orchestrator/operations/executions.py create mode 100644 src/ci_orchestrator/web/templates/partials/executions.html delete mode 100644 src/ci_orchestrator/web/templates/partials/vms.html create mode 100644 tests/python/test_operations_executions.py create mode 100644 tests/python/test_web_executions.py 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