feat(3 U2.3): serve per-run artifacts at /runs/<id>/<file> (whitelisted, traversal-guarded) + bind-mount runs dir RO into dashboard

This commit is contained in:
autonomic-bot
2026-05-31 07:12:32 +00:00
parent 6322065082
commit fa56f6bcaa
2 changed files with 50 additions and 0 deletions

View File

@ -15,6 +15,7 @@ POLL_INTERVAL (default 60), CACHE_TTL (default 30).
import html
import json
import os
import re
import sys
import time
import urllib.error
@ -25,6 +26,21 @@ 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:
@ -159,6 +175,22 @@ def render_badge(recipe, status):
<text x="{lw + 6}" y="14">{html.escape(msg)}</text></g></svg>"""
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 _send(self, code, body, ctype="text/html; charset=utf-8"):
data = body.encode() if isinstance(body, str) else body
@ -177,6 +209,15 @@ class Handler(BaseHTTPRequestHandler):
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.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 self._send(200, got[1], got[0])
return self._send(404, "not found", "text/plain")
if path == "/":
return self._send(200, render_overview(recipes_cached()))
return self._send(404, "not found", "text/plain")

View File

@ -37,8 +37,17 @@ let
- CI_REPO=recipe-maintainers/cc-ci
- DASH_LISTEN=0.0.0.0:8080
- DRONE_TOKEN_FILE=/run/secrets/drone_token
- CCCI_RUNS_DIR=/var/lib/cc-ci-runs
secrets:
- drone_token
# Phase 3 (U2.3): the per-run artifacts (results.json, summary.png, screenshot.png, badge.svg)
# the runner writes under /var/lib/cc-ci-runs are bind-mounted READ-ONLY so the dashboard can
# serve them at /runs/<id>/<file>. Read-only: the dashboard never writes run artifacts.
volumes:
- type: bind
source: /var/lib/cc-ci-runs
target: /var/lib/cc-ci-runs
read_only: true
networks:
- proxy
deploy: