Mechanical, semantics-preserving cleanup so the codebase passes the new lint stage:
- ruff format: all 32 Python files (wraps long signatures, normalizes quotes/blank lines).
- nixpkgs-fmt: modules/drone-runner.nix.
- shfmt (-i 2 -ci): scripts/*.sh.
Lint fixes (reviewed, behavior-preserving — no test weakened):
- ruff SIM105: try/except-pass -> contextlib.suppress (abra.py app_config rm; lifecycle.py janitor).
- ruff SIM115: open().read() -> with open() (run_recipe_ci.py redaction-values + gitea-token).
- statix: merge repeated sops `secrets.*` keys into one `secrets = { ... }` (comments kept);
empty fn pattern `{ ... }:` -> `_:` (packages.nix).
- deadnix: drop unused lambda args (flake `self`; configuration.nix `lib`; overlay `final` -> `_`).
Verified on cc-ci: `scripts/lint.sh` -> lint: PASS; nixosConfigurations.cc-ci evaluates;
all Python byte-compiles. The deployed bridge/dashboard/runner source changes hash (reformat),
so cc-ci will be rebuilt to the new closure in W2 before the cold D1-D10 re-verification.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
197 lines
7.0 KiB
Python
197 lines
7.0 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 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"))
|
|
|
|
|
|
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": []}
|
|
|
|
_COLORS = {
|
|
"success": "#3fb950",
|
|
"failure": "#f85149",
|
|
"error": "#f85149",
|
|
"running": "#d29922",
|
|
"pending": "#d29922",
|
|
"killed": "#8b949e",
|
|
}
|
|
|
|
|
|
def log(*a):
|
|
print(*a, file=sys.stderr, flush=True)
|
|
|
|
|
|
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 latest_per_recipe():
|
|
"""Latest recipe-CI build per recipe (event=custom builds carry the RECIPE param)."""
|
|
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
|
|
latest = {}
|
|
for b in builds or []:
|
|
if b.get("event") != "custom":
|
|
continue
|
|
recipe = (b.get("params") or {}).get("RECIPE")
|
|
if not recipe:
|
|
continue
|
|
# The cc-ci repo's own name isn't a recipe under test (e.g. an Adversary !testme on the
|
|
# cc-ci PR); don't list it as a recipe row.
|
|
if recipe == CI_REPO.rsplit("/", 1)[-1]:
|
|
continue
|
|
if recipe not in latest or b.get("number", 0) > latest[recipe].get("number", 0):
|
|
latest[recipe] = b
|
|
rows = []
|
|
for recipe, b in sorted(latest.items()):
|
|
ref = (b.get("params") or {}).get("REF") or ""
|
|
rows.append(
|
|
{
|
|
"recipe": recipe,
|
|
"status": b.get("status", "unknown"),
|
|
"number": b.get("number"),
|
|
"ref": ref[:8],
|
|
"finished": b.get("finished") or 0,
|
|
"url": f"{DRONE_URL}/{CI_REPO}/{b.get('number')}",
|
|
}
|
|
)
|
|
return rows
|
|
|
|
|
|
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"
|
|
|
|
|
|
def render_overview(rows):
|
|
trs = []
|
|
for r in rows:
|
|
color = _COLORS.get(r["status"], "#8b949e")
|
|
trs.append(
|
|
f'<tr><td><b>{html.escape(r["recipe"])}</b></td>'
|
|
f'<td><span class="badge" style="background:{color}">{html.escape(r["status"])}</span></td>'
|
|
f'<td><code>{html.escape(r["ref"]) or "—"}</code></td>'
|
|
f'<td>{_ago(r["finished"])}</td>'
|
|
f'<td><a href="{html.escape(r["url"])}">run #{r["number"]}</a></td></tr>'
|
|
)
|
|
body = "\n".join(trs) or '<tr><td colspan="5">no recipe runs yet</td></tr>'
|
|
return f"""<!doctype html><html><head><meta charset="utf-8">
|
|
<title>cc-ci — Co-op Cloud recipe CI</title>
|
|
<meta http-equiv="refresh" content="30">
|
|
<style>
|
|
body{{font-family:system-ui,sans-serif;background:#0d1117;color:#c9d1d9;margin:2rem auto;max-width:900px;padding:0 1rem}}
|
|
h1{{font-size:1.4rem}} a{{color:#58a6ff}} table{{border-collapse:collapse;width:100%;margin-top:1rem}}
|
|
th,td{{text-align:left;padding:.5rem .75rem;border-bottom:1px solid #21262d}}
|
|
th{{color:#8b949e;font-weight:600;font-size:.85rem;text-transform:uppercase}}
|
|
.badge{{color:#fff;padding:.1rem .5rem;border-radius:.5rem;font-size:.8rem;font-weight:600}}
|
|
.sub{{color:#8b949e;font-size:.85rem}}
|
|
</style></head><body>
|
|
<h1>cc-ci — Co-op Cloud recipe CI</h1>
|
|
<p class="sub">Latest <code>!testme</code> run per enrolled recipe. Per-run logs live in Drone.
|
|
Auto-refreshes every 30s.</p>
|
|
<table><thead><tr><th>Recipe</th><th>Status</th><th>Ref</th><th>Last run</th><th>Run</th></tr></thead>
|
|
<tbody>{body}</tbody></table>
|
|
</body></html>"""
|
|
|
|
|
|
def render_badge(recipe, status):
|
|
color = _COLORS.get(status, "#8b949e")
|
|
label, msg = "cc-ci", status
|
|
lw, mw = 44, max(40, 7 * len(msg) + 10)
|
|
w = lw + mw
|
|
return f"""<svg xmlns="http://www.w3.org/2000/svg" width="{w}" height="20" role="img">
|
|
<rect width="{lw}" height="20" fill="#555"/><rect x="{lw}" width="{mw}" height="20" fill="{color}"/>
|
|
<g fill="#fff" font-family="Verdana,sans-serif" font-size="11">
|
|
<text x="6" y="14">{html.escape(label)}</text>
|
|
<text x="{lw + 6}" y="14">{html.escape(msg)}</text></g></svg>"""
|
|
|
|
|
|
class Handler(BaseHTTPRequestHandler):
|
|
def _send(self, code, body, ctype="text/html; charset=utf-8"):
|
|
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()
|
|
self.wfile.write(data)
|
|
|
|
def do_GET(self):
|
|
path = self.path.split("?")[0].rstrip("/") or "/"
|
|
if path in ("/healthz", "/dashboard/healthz"):
|
|
return self._send(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)
|
|
status = row["status"] if row else "unknown"
|
|
return self._send(200, render_badge(recipe, status), "image/svg+xml")
|
|
if path == "/":
|
|
return self._send(200, render_overview(recipes_cached()))
|
|
return self._send(404, "not found", "text/plain")
|
|
|
|
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()
|