Push builds have been RED on the lint step since ~build 209 from accumulated formatting drift. This is the mechanical cleanup: ruff format + ruff --fix (UP038 isinstance unions, SIM105 contextlib.suppress, UP031 f-strings, SIM115 tempfile context manager), shfmt -i 2 -ci, nixpkgs-fmt/statix/deadnix (merged attrsets, dropped unused lib args), yamllint, and shell quoting fixes in tests/lasuite-docs/setup_custom_tests.sh. No behaviour changes intended; lint: PASS, unit tests: 138 passed.
452 lines
19 KiB
Python
452 lines
19 KiB
Python
#!/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/<recipe>.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/<id>/<file>.
|
|
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 = (
|
|
'<svg class="flower" width="26" height="26" viewBox="0 0 28 28">'
|
|
'<g fill="#f0b429">'
|
|
+ "".join(
|
|
f'<ellipse cx="14" cy="5.5" rx="2.6" ry="5.5" transform="rotate({a} 14 14)"/>'
|
|
for a in range(0, 360, 45)
|
|
)
|
|
+ '</g><circle cx="14" cy="14" r="5" fill="#7a4f1d"/></svg>'
|
|
)
|
|
|
|
|
|
def _level_pill(level):
|
|
"""The big corner LEVEL badge (R5). '—' (grey) when no results.json level yet."""
|
|
if level is None:
|
|
return '<span class="lvl" style="background:#8b949e">level —</span>'
|
|
return f'<span class="lvl" style="background:{level_color(level)}">level {int(level)}</span>'
|
|
|
|
|
|
def _flags_html(flags):
|
|
out = []
|
|
if flags.get("clean_teardown"):
|
|
out.append('<span title="clean teardown">✔ teardown</span>')
|
|
if flags.get("no_secret_leak"):
|
|
out.append('<span title="no secret leak">✔ no-leak</span>')
|
|
return f'<div class="flags">{"".join(out)}</div>' 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'<a class="shot" href="/runs/{num}/summary.png" '
|
|
f'style="background-image:url(/runs/{num}/screenshot.png)" '
|
|
f'title="view summary card"><span>{_level_pill(r["level"])}</span></a>'
|
|
)
|
|
else:
|
|
shot = (
|
|
f'<a class="shot" href="{run_url}" title="open run">'
|
|
f'<span class="ph">no screenshot</span>{_level_pill(r["level"])}</a>'
|
|
)
|
|
cap = (
|
|
f'<div class="cap">{html.escape(r["level_cap_reason"])}</div>'
|
|
if r["level_cap_reason"]
|
|
else ""
|
|
)
|
|
return (
|
|
f'<div class="card">{shot}<div class="body">'
|
|
f'<div class="name">{html.escape(r["recipe"])}</div>'
|
|
f'<div class="row"><span class="pill" style="background:{color}">{html.escape(r["status"])}</span>'
|
|
f'<code>{html.escape(r["version"])}</code></div>'
|
|
f"{cap}{_flags_html(r['flags'])}"
|
|
f'<div class="foot"><a href="{run_url}">run #{num} · {_ago(r["finished"])}</a>'
|
|
f'<a href="/recipe/{html.escape(r["recipe"])}">history →</a></div>'
|
|
f"</div></div>"
|
|
)
|
|
|
|
|
|
def _page(title, inner):
|
|
return (
|
|
f'<!doctype html><html><head><meta charset="utf-8"><title>{html.escape(title)}</title>'
|
|
f'<meta name="viewport" content="width=device-width,initial-scale=1">'
|
|
f'<meta http-equiv="refresh" content="30"><style>{_PAGE_CSS}</style></head>'
|
|
f'<body><div class="wrap">{inner}</div></body></html>'
|
|
)
|
|
|
|
|
|
def render_overview(rows):
|
|
cards = "\n".join(_card(r) for r in rows) or '<p class="sub">no recipe runs yet</p>'
|
|
inner = (
|
|
f"<h1>{_FLOWER} cc-ci — Co-op Cloud recipe CI</h1>"
|
|
'<p class="sub">Latest <code>!testme</code> run per enrolled recipe — level, status, version, '
|
|
"app screenshot. Click a card for its summary card; “history” for past runs. "
|
|
"Auto-refreshes every 30s.</p>"
|
|
f'<div class="grid">{cards}</div>'
|
|
)
|
|
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'<b style="color:{level_color(r["level"])}">L{int(r["level"])}</b>'
|
|
)
|
|
shot = f'<a href="/runs/{r["number"]}/summary.png">card</a>' if r["has_screenshot"] else "—"
|
|
trs.append(
|
|
f'<tr><td><a href="{html.escape(r["url"])}">#{r["number"]}</a></td>'
|
|
f'<td><span class="pill" style="background:{color}">{html.escape(r["status"])}</span></td>'
|
|
f"<td>{lvl}</td><td><code>{html.escape(r['version'])}</code></td>"
|
|
f'<td>{_ago(r["finished"])}</td><td>{shot}</td></tr>'
|
|
)
|
|
body = "\n".join(trs) or '<tr><td colspan="6">no runs for this recipe yet</td></tr>'
|
|
inner = (
|
|
f"<h1>{_FLOWER} {html.escape(recipe)} — run history</h1>"
|
|
'<p class="sub"><a href="/">← all recipes</a> · every <code>!testme</code> run, newest first.</p>'
|
|
"<table><thead><tr><th>Run</th><th>Status</th><th>Level</th><th>Version</th>"
|
|
"<th>When</th><th>Card</th></tr></thead><tbody>"
|
|
f"{body}</tbody></table>"
|
|
)
|
|
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'<svg xmlns="http://www.w3.org/2000/svg" width="{w}" height="20" role="img" '
|
|
f'aria-label="{html.escape(label)}: {html.escape(msg)}">'
|
|
f'<rect width="{lw}" height="20" fill="#555"/>'
|
|
f'<rect x="{lw}" width="{mw}" height="20" fill="{color}"/>'
|
|
f'<g fill="#fff" font-family="Verdana,Geneva,sans-serif" font-size="11">'
|
|
f'<text x="6" y="14">{html.escape(label)}</text>'
|
|
f'<text x="{lw + 6}" y="14">{html.escape(msg)}</text></g></svg>'
|
|
)
|
|
|
|
|
|
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: <recipe> | level N', coloured by level —
|
|
embeddable in a recipe README (`/badge/<recipe>.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/<run_id>/<file> — 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()
|