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:
@ -15,6 +15,7 @@ POLL_INTERVAL (default 60), CACHE_TTL (default 30).
|
|||||||
import html
|
import html
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
import urllib.error
|
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")
|
CI_REPO = os.environ.get("CI_REPO", "recipe-maintainers/cc-ci")
|
||||||
CACHE_TTL = int(os.environ.get("CACHE_TTL", "30"))
|
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):
|
def _read(path):
|
||||||
with open(path) as fh:
|
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>"""
|
<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):
|
class Handler(BaseHTTPRequestHandler):
|
||||||
def _send(self, code, body, ctype="text/html; charset=utf-8"):
|
def _send(self, code, body, ctype="text/html; charset=utf-8"):
|
||||||
data = body.encode() if isinstance(body, str) else body
|
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)
|
row = next((r for r in recipes_cached() if r["recipe"] == recipe), None)
|
||||||
status = row["status"] if row else "unknown"
|
status = row["status"] if row else "unknown"
|
||||||
return self._send(200, render_badge(recipe, status), "image/svg+xml")
|
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 == "/":
|
if path == "/":
|
||||||
return self._send(200, render_overview(recipes_cached()))
|
return self._send(200, render_overview(recipes_cached()))
|
||||||
return self._send(404, "not found", "text/plain")
|
return self._send(404, "not found", "text/plain")
|
||||||
|
|||||||
@ -37,8 +37,17 @@ let
|
|||||||
- CI_REPO=recipe-maintainers/cc-ci
|
- CI_REPO=recipe-maintainers/cc-ci
|
||||||
- DASH_LISTEN=0.0.0.0:8080
|
- DASH_LISTEN=0.0.0.0:8080
|
||||||
- DRONE_TOKEN_FILE=/run/secrets/drone_token
|
- DRONE_TOKEN_FILE=/run/secrets/drone_token
|
||||||
|
- CCCI_RUNS_DIR=/var/lib/cc-ci-runs
|
||||||
secrets:
|
secrets:
|
||||||
- drone_token
|
- 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:
|
networks:
|
||||||
- proxy
|
- proxy
|
||||||
deploy:
|
deploy:
|
||||||
|
|||||||
Reference in New Issue
Block a user