#!/usr/bin/env python3 """cc-ci results dashboard (§4.5, D7). A small stdlib HTTP service served at `ci.commoninternet.net` (root; the comment-bridge keeps the more-specific `/hook` route). It polls the Drone API for the cc-ci repo's recipe-CI builds (event=custom, which carry the RECIPE build param), groups the latest run per recipe, and renders a YunoHost-CI-like overview: a table of recipes with a pass/fail/running status badge, last-tested ref, when, and a link to the canonical Drone run. Also serves an embeddable SVG badge per recipe at `/badge/.svg`. Read-only (Drone API token, never written to the page). Python stdlib only. Config (env): DRONE_URL, CI_REPO, DRONE_TOKEN_FILE, DASH_LISTEN (default 0.0.0.0:8080), POLL_INTERVAL (default 60), CACHE_TTL (default 30). """ import html import json import os import re import sys import time import urllib.error import urllib.request from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer DRONE_URL = os.environ.get("DRONE_URL", "https://drone.ci.commoninternet.net") CI_REPO = os.environ.get("CI_REPO", "recipe-maintainers/cc-ci") CACHE_TTL = int(os.environ.get("CACHE_TTL", "30")) # Phase 3 (R3/R6/U2.3): per-run artifacts (results.json, summary card PNG, app screenshot, level # badge) written by run_recipe_ci.py under this host dir, bind-mounted read-only into the dashboard # container (see nix/modules/dashboard.nix). Served at the stable URL /runs//. CCCI_RUNS_DIR = os.environ.get("CCCI_RUNS_DIR", "/var/lib/cc-ci-runs") # Strict allow-list of servable filenames → content type. NOTHING outside this set is served, so the # route cannot be used to read arbitrary files even before the path-traversal guard. _RUN_FILES = { "results.json": "application/json", "summary.png": "image/png", "screenshot.png": "image/png", "badge.svg": "image/svg+xml", "summary.html": "text/html; charset=utf-8", } _RUN_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$") def _read(path): with open(path) as fh: return fh.read().strip() DRONE_TOKEN = _read(os.environ["DRONE_TOKEN_FILE"]) _CACHE = {"ts": 0.0, "recipes": []} # Raw custom builds (newest-first), cached so the overview AND the per-recipe history page share one # Drone fetch within CACHE_TTL (U4 history reads the same list latest_per_recipe groups from). _BUILDS = {"ts": 0.0, "builds": []} _COLORS = { "success": "#3fb950", "failure": "#f85149", "error": "#f85149", "running": "#d29922", "pending": "#d29922", "killed": "#8b949e", } # Level → colour ramp, kept in sync with runner/harness/card.py LEVEL_COLOR (the dashboard is a # standalone stdlib service that doesn't import the runner harness, so the small map is duplicated). _LEVEL_COLOR = { 0: "#e5534b", 1: "#e0823d", 2: "#e0823d", 3: "#d9b343", 4: "#a0b93f", 5: "#57ab5a", 6: "#3fb950", } def level_color(level): try: return _LEVEL_COLOR.get(int(level), "#8b949e") except (TypeError, ValueError): return "#8b949e" def log(*a): print(*a, file=sys.stderr, flush=True) def _results_for(number): """Read a run's results.json from the bind-mounted runs dir (R5: the grid surfaces the real level/version/screenshot/flags from the artifact, not just Drone's pass/fail). Traversal-guarded like serve_run_file; returns {} on any miss so the overview degrades to Drone-only fields.""" if number in (None, ""): return {} base = os.path.realpath(CCCI_RUNS_DIR) real = os.path.realpath(os.path.join(base, str(number), "results.json")) if not real.startswith(base + os.sep): return {} try: with open(real) as fh: return json.load(fh) except (OSError, ValueError): return {} def _drone(path): req = urllib.request.Request( f"{DRONE_URL}{path}", headers={"Authorization": f"Bearer {DRONE_TOKEN}"} ) with urllib.request.urlopen(req, timeout=30) as resp: return json.loads(resp.read()) def _custom_recipe_builds(): """All event=custom recipe-CI builds (newest first), each carrying a real RECIPE param. The cc-ci repo's own name isn't a recipe under test (e.g. an Adversary `!testme` on the cc-ci PR) so it's filtered out. Cached (CACHE_TTL) and shared by the overview + history. None on fetch error.""" now = time.time() if now - _BUILDS["ts"] <= CACHE_TTL and _BUILDS["builds"]: return _BUILDS["builds"] try: builds = _drone(f"/api/repos/{CI_REPO}/builds?per_page=100") except (urllib.error.URLError, OSError, ValueError) as e: log("drone fetch failed", e) return None own = CI_REPO.rsplit("/", 1)[-1] out = [] for b in builds or []: if b.get("event") != "custom": continue recipe = (b.get("params") or {}).get("RECIPE") if not recipe or recipe == own: continue out.append(b) out.sort(key=lambda b: b.get("number", 0), reverse=True) _BUILDS["builds"] = out _BUILDS["ts"] = now return out def _build_row(b): """Project a Drone build (+ its results.json artifact, if present) into a display row. The level/ version/screenshot/flags come from the run's results.json so the grid mirrors the real artifact (R5/cardinal: never greener than the run); they're absent until U0+ artifacts exist for a run.""" ref = (b.get("params") or {}).get("REF") or "" res = _results_for(b.get("number")) return { "recipe": (b.get("params") or {}).get("RECIPE"), "status": b.get("status", "unknown"), "number": b.get("number"), "ref": ref[:8], "version": res.get("version") or ref[:12] or "—", "level": res.get("level"), "level_cap_reason": res.get("level_cap_reason") or "", "has_screenshot": bool(res.get("screenshot")), "flags": res.get("flags") or {}, "finished": b.get("finished") or 0, "url": f"{DRONE_URL}/{CI_REPO}/{b.get('number')}", } def latest_per_recipe(): """Latest recipe-CI build per recipe, augmented from results.json (R5). None on fetch error.""" builds = _custom_recipe_builds() if builds is None: return None latest = {} for b in builds: # newest-first → first seen per recipe is the latest recipe = (b.get("params") or {}).get("RECIPE") if recipe not in latest: latest[recipe] = b return [_build_row(latest[r]) for r in sorted(latest)] def history_for(recipe): """All runs for one recipe (newest first), augmented from results.json — the per-recipe history page (R5 'link to history'). [] if none / None on fetch error.""" builds = _custom_recipe_builds() if builds is None: return None return [_build_row(b) for b in builds if (b.get("params") or {}).get("RECIPE") == recipe] def recipes_cached(): now = time.time() if now - _CACHE["ts"] > CACHE_TTL: fresh = latest_per_recipe() if fresh is not None: _CACHE["recipes"] = fresh _CACHE["ts"] = now return _CACHE["recipes"] def _ago(ts): if not ts: return "—" d = int(time.time() - ts) if d < 60: return f"{d}s ago" if d < 3600: return f"{d // 60}m ago" if d < 86400: return f"{d // 3600}h ago" return f"{d // 86400}d ago" _PAGE_CSS = """ body{font-family:system-ui,-apple-system,sans-serif;background:#0d1117;color:#c9d1d9;margin:0;padding:0} .wrap{max-width:1100px;margin:0 auto;padding:1.5rem 1rem 3rem} h1{font-size:1.5rem;margin:.2rem 0;display:flex;align-items:center;gap:.5rem} a{color:#58a6ff;text-decoration:none} a:hover{text-decoration:underline} .sub{color:#8b949e;font-size:.9rem;margin:.3rem 0 1.2rem} .grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:1rem} .card{background:#161b22;border:1px solid #21262d;border-radius:.6rem;overflow:hidden;display:flex;flex-direction:column} .shot{position:relative;display:block;height:140px;background:#0d1117 center/cover no-repeat;border-bottom:1px solid #21262d} .shot .ph{display:flex;height:100%;align-items:center;justify-content:center;color:#484f58;font-size:.8rem} .lvl{position:absolute;top:.5rem;right:.5rem;color:#fff;font-weight:700;font-size:.8rem;padding:.15rem .5rem;border-radius:.5rem;box-shadow:0 1px 3px #0008} .body{padding:.7rem .8rem;display:flex;flex-direction:column;gap:.4rem;flex:1} .name{font-weight:700;font-size:1.05rem;color:#e6edf3} .row{display:flex;align-items:center;gap:.5rem;flex-wrap:wrap;font-size:.82rem} .pill{color:#fff;padding:.08rem .5rem;border-radius:.5rem;font-size:.75rem;font-weight:600} .cap{color:#8b949e;font-size:.75rem} code{background:#0d1117;border:1px solid #21262d;border-radius:.3rem;padding:0 .3rem;font-size:.78rem;color:#c9d1d9} .flags{display:flex;gap:.4rem;font-size:.72rem;color:#8b949e} .foot{margin-top:auto;display:flex;justify-content:space-between;font-size:.8rem;padding-top:.3rem;border-top:1px solid #21262d} table{border-collapse:collapse;width:100%;margin-top:1rem} th,td{text-align:left;padding:.5rem .7rem;border-bottom:1px solid #21262d;font-size:.88rem} th{color:#8b949e;font-weight:600;font-size:.8rem;text-transform:uppercase} .flower{flex:0 0 auto} """ # Inline sunflower (matches the summary card; no emoji font dependency in the page header). _FLOWER = ( '' '' + "".join( f'' for a in range(0, 360, 45) ) + '' ) def _level_pill(level): """The big corner LEVEL badge (R5). '—' (grey) when no results.json level yet.""" if level is None: return 'level —' return f'level {int(level)}' def _flags_html(flags): out = [] if flags.get("clean_teardown"): out.append('✔ teardown') if flags.get("no_secret_leak"): out.append('✔ no-leak') return f'
{"".join(out)}
' if out else "" def _card(r): color = _COLORS.get(r["status"], "#8b949e") num = r["number"] run_url = html.escape(r["url"]) # Screenshot thumbnail (clickable → full summary card). Placeholder when no screenshot captured. if r["has_screenshot"]: shot = ( f'{_level_pill(r["level"])}' ) else: shot = ( f'' f'no screenshot{_level_pill(r["level"])}' ) cap = f'
{html.escape(r["level_cap_reason"])}
' if r["level_cap_reason"] else "" return ( f'
{shot}
' f'
{html.escape(r["recipe"])}
' f'
{html.escape(r["status"])}' f'{html.escape(r["version"])}
' f"{cap}{_flags_html(r['flags'])}" f'' f"
" ) def _page(title, inner): return ( f'{html.escape(title)}' f'' f'' f'
{inner}
' ) def render_overview(rows): cards = "\n".join(_card(r) for r in rows) or '

no recipe runs yet

' inner = ( f"

{_FLOWER} cc-ci — Co-op Cloud recipe CI

" '

Latest !testme run per enrolled recipe — level, status, version, ' "app screenshot. Click a card for its summary card; “history” for past runs. " "Auto-refreshes every 30s.

" f'
{cards}
' ) return _page("cc-ci — Co-op Cloud recipe CI", inner) def render_history(recipe, rows): trs = [] for r in rows: color = _COLORS.get(r["status"], "#8b949e") lvl = "—" if r["level"] is None else f'L{int(r["level"])}' shot = f'card' if r["has_screenshot"] else "—" trs.append( f'#{r["number"]}' f'{html.escape(r["status"])}' f"{lvl}{html.escape(r['version'])}" f'{_ago(r["finished"])}{shot}' ) body = "\n".join(trs) or 'no runs for this recipe yet' inner = ( f'

{_FLOWER} {html.escape(recipe)} — run history

' '

← all recipes · every !testme run, newest first.

' "" "" f"{body}
RunStatusLevelVersionWhenCard
" ) return _page(f"{recipe} — cc-ci history", inner) def _badge_svg(label, msg, color): """Two-box shields-style SVG (grey label | coloured message). Stdlib-only, deterministic sizing.""" lw = max(44, 7 * len(label) + 12) mw = max(40, 7 * len(msg) + 12) w = lw + mw return ( f'' f'' f'' f'' f'{html.escape(label)}' f'{html.escape(msg)}' ) def render_badge(recipe, status): """Status fallback badge (used when a recipe has no results.json level yet).""" return _badge_svg("cc-ci", status, _COLORS.get(status, "#8b949e")) def render_level_badge(recipe, level): """Per-recipe latest-LEVEL badge (R6): 'cc-ci: | level N', coloured by level — embeddable in a recipe README (`/badge/.svg`) and shown on the dashboard.""" return _badge_svg(f"cc-ci: {recipe}", f"level {int(level)}", level_color(level)) def serve_run_file(run_id, fname): """Resolve a whitelisted per-run artifact to (content_type, bytes), or None if it must not / can not be served. Defends against path traversal three ways: the filename must be in the explicit allow-list (so no arbitrary name), the run_id must match a conservative charset (no `/`, no `..`), and the realpath of the target must still live inside CCCI_RUNS_DIR. Read-only.""" ctype = _RUN_FILES.get(fname) if ctype is None or not _RUN_ID_RE.match(run_id or ""): return None base = os.path.realpath(CCCI_RUNS_DIR) real = os.path.realpath(os.path.join(base, run_id, fname)) if not (real == base or real.startswith(base + os.sep)) or not os.path.isfile(real): return None with open(real, "rb") as fh: return ctype, fh.read() class Handler(BaseHTTPRequestHandler): def _route(self, path): """Resolve a request path to (code, body, content_type). Shared by GET and HEAD so they never diverge. `body` is bytes/str for GET; HEAD sends only the status + headers.""" if path in ("/healthz", "/dashboard/healthz"): return 200, "ok", "text/plain" if path.startswith("/badge/") and path.endswith(".svg"): recipe = path[len("/badge/") : -len(".svg")] row = next((r for r in recipes_cached() if r["recipe"] == recipe), None) # R6: per-recipe LATEST-LEVEL badge (from results.json). Fall back to a status badge when # the recipe has no level yet (never ran / failed before emitting results.json). if row and row.get("level") is not None: return 200, render_level_badge(recipe, row["level"]), "image/svg+xml" return 200, render_badge(recipe, row["status"] if row else "unknown"), "image/svg+xml" if path.startswith("/runs/"): # /runs// — stable URL for a run's results.json / summary.png / screenshot / # badge (R3/R6). Whitelisted + traversal-guarded by serve_run_file. parts = path[len("/runs/") :].split("/") if len(parts) == 2: got = serve_run_file(parts[0], parts[1]) if got is not None: return 200, got[1], got[0] return 404, "not found", "text/plain" if path.startswith("/recipe/"): recipe = path[len("/recipe/") :] if _RUN_ID_RE.match(recipe): rows = history_for(recipe) or [] return 200, render_history(recipe, rows), "text/html; charset=utf-8" return 404, "not found", "text/plain" if path == "/": return 200, render_overview(recipes_cached()), "text/html; charset=utf-8" return 404, "not found", "text/plain" def _send(self, code, body, ctype="text/html; charset=utf-8", head_only=False): data = body.encode() if isinstance(body, str) else body self.send_response(code) self.send_header("Content-Type", ctype) self.send_header("Content-Length", str(len(data))) self.end_headers() if not head_only: self.wfile.write(data) def do_GET(self): path = self.path.split("?")[0].rstrip("/") or "/" code, body, ctype = self._route(path) self._send(code, body, ctype) def do_HEAD(self): # Same routing as GET, headers only (no body) — enables cheap existence checks, e.g. the # comment-bridge deciding image-vs-text fallback for the PR comment (U3). path = self.path.split("?")[0].rstrip("/") or "/" code, body, ctype = self._route(path) self._send(code, body, ctype, head_only=True) def log_message(self, *a): pass def main(): host, _, port = os.environ.get("DASH_LISTEN", "0.0.0.0:8080").rpartition(":") srv = ThreadingHTTPServer((host or "0.0.0.0", int(port)), Handler) log(f"dashboard listening on {host or '0.0.0.0'}:{port}") srv.serve_forever() if __name__ == "__main__": main()