diff --git a/dashboard/dashboard.py b/dashboard/dashboard.py new file mode 100644 index 0000000..bb0ae9c --- /dev/null +++ b/dashboard/dashboard.py @@ -0,0 +1,182 @@ +#!/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 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 + 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'{html.escape(r["recipe"])}' + f'{html.escape(r["status"])}' + f'{html.escape(r["ref"]) or "—"}' + f'{_ago(r["finished"])}' + f'run #{r["number"]}' + ) + body = "\n".join(trs) or 'no recipe runs yet' + return f""" +cc-ci — Co-op Cloud recipe CI + + +

cc-ci — Co-op Cloud recipe CI

+

Latest !testme run per enrolled recipe. Per-run logs live in Drone. +Auto-refreshes every 30s.

+ +{body}
RecipeStatusRefLast runRun
+""" + + +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""" + + +{html.escape(label)} +{html.escape(msg)}""" + + +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() diff --git a/hosts/cc-ci/configuration.nix b/hosts/cc-ci/configuration.nix index 1670aa0..b37b076 100644 --- a/hosts/cc-ci/configuration.nix +++ b/hosts/cc-ci/configuration.nix @@ -13,6 +13,7 @@ ../../modules/drone.nix ../../modules/drone-runner.nix ../../modules/bridge.nix + ../../modules/dashboard.nix ../../modules/backupbot.nix ../../modules/harness.nix ]; diff --git a/modules/dashboard.nix b/modules/dashboard.nix new file mode 100644 index 0000000..847b523 --- /dev/null +++ b/modules/dashboard.nix @@ -0,0 +1,86 @@ +# Results dashboard (§4.5, D7): the YunoHost-CI-like overview at ci.commoninternet.net. Reads the +# Drone API (read-only) and renders latest-run-per-recipe + SVG badges. Packaged as a Nix-built OCI +# image and run as a swarm service on `proxy`, routed by traefik at Host(ci.commoninternet.net) — the +# comment-bridge's Host && PathPrefix(`/hook`) rule is longer, so /hook still wins (priority by rule +# length). Deployed by an idempotent-reconcile oneshot (same pattern as bridge/drone). +{ pkgs, ... }: +let + dashApp = pkgs.runCommand "cc-ci-dashboard-app" { } '' + mkdir -p $out/app + cp ${../dashboard/dashboard.py} $out/app/dashboard.py + ''; + + image = pkgs.dockerTools.buildLayeredImage { + name = "cc-ci-dashboard"; + tag = "latest"; + contents = [ pkgs.python3 pkgs.cacert dashApp ]; + config = { + Cmd = [ "${pkgs.python3}/bin/python3" "/app/dashboard.py" ]; + Env = [ "SSL_CERT_FILE=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt" ]; + ExposedPorts = { "8080/tcp" = { }; }; + }; + }; + + stack = pkgs.writeText "cc-ci-dashboard-stack.yml" '' + version: "3.8" + services: + app: + image: cc-ci-dashboard:latest + environment: + - DRONE_URL=https://drone.ci.commoninternet.net + - CI_REPO=recipe-maintainers/cc-ci + - DASH_LISTEN=0.0.0.0:8080 + - DRONE_TOKEN_FILE=/run/secrets/drone_token + secrets: + - drone_token + networks: + - proxy + deploy: + replicas: 1 + restart_policy: + condition: any + labels: + - "traefik.enable=true" + - "traefik.http.services.ccci-dashboard.loadbalancer.server.port=8080" + - "traefik.http.routers.ccci-dashboard.rule=Host(`ci.commoninternet.net`)" + - "traefik.http.routers.ccci-dashboard.entrypoints=web-secure" + - "traefik.http.routers.ccci-dashboard.tls=true" + networks: + proxy: + external: true + secrets: + drone_token: + external: true + name: cc_ci_dashboard_drone_token_v1 + ''; + + reconcile = pkgs.writeShellApplication { + name = "cc-ci-reconcile-dashboard"; + runtimeInputs = with pkgs; [ docker coreutils ]; + text = '' + if [ ! -r /run/secrets/bridge_drone_token ]; then + echo "FATAL: /run/secrets/bridge_drone_token missing (rebuild ordering?)" >&2 + exit 1 + fi + docker load -i ${image} + # Dashboard reads the Drone API read-only; reuse the same Drone token value as the bridge. + docker secret inspect cc_ci_dashboard_drone_token_v1 >/dev/null 2>&1 \ + || docker secret create cc_ci_dashboard_drone_token_v1 /run/secrets/bridge_drone_token >/dev/null + docker stack deploy --detach=true -c ${stack} ccci-dashboard + ''; + }; +in +{ + systemd.services.deploy-dashboard = { + description = "Reconcile the cc-ci results dashboard (overview + badges) swarm service"; + after = [ "deploy-proxy.service" "swarm-init.service" "docker.service" "network-online.target" ]; + requires = [ "swarm-init.service" "docker.service" ]; + wants = [ "network-online.target" ]; + wantedBy = [ "multi-user.target" ]; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + ExecStart = "${reconcile}/bin/cc-ci-reconcile-dashboard"; + }; + }; +}