feat: ship production local assurance viewer in core
This commit is contained in:
@@ -0,0 +1,295 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Loopback-only HTTP server for the CASAN Core Local Assurance Viewer."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
import json
|
||||
import mimetypes
|
||||
import os
|
||||
from pathlib import Path
|
||||
import signal
|
||||
import threading
|
||||
from urllib.parse import parse_qs, unquote, urlparse
|
||||
|
||||
from local_report import (
|
||||
SAFE_ID,
|
||||
h6_report,
|
||||
project_profile,
|
||||
report_html,
|
||||
run_report,
|
||||
runs,
|
||||
trace_graph,
|
||||
)
|
||||
|
||||
|
||||
ASSET_ROOT = Path(__file__).resolve().parents[2] / "assets" / "local-viewer"
|
||||
MAX_RESPONSE_BYTES = 24 * 1024 * 1024
|
||||
|
||||
|
||||
def atomic_json(path: Path, payload: dict) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp")
|
||||
temporary.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
|
||||
try:
|
||||
os.chmod(temporary, 0o600)
|
||||
except OSError:
|
||||
if os.name != "nt":
|
||||
raise
|
||||
os.replace(temporary, path)
|
||||
|
||||
|
||||
class ViewerServer(ThreadingHTTPServer):
|
||||
daemon_threads = True
|
||||
allow_reuse_address = False
|
||||
|
||||
def __init__(self, address, root: Path, token: str):
|
||||
self.root = root.resolve()
|
||||
self.token = token
|
||||
super().__init__(address, ViewerHandler)
|
||||
|
||||
|
||||
class ViewerHandler(BaseHTTPRequestHandler):
|
||||
server_version = "CASAN-Core-Viewer/1"
|
||||
sys_version = ""
|
||||
|
||||
@property
|
||||
def viewer(self) -> ViewerServer:
|
||||
return self.server # type: ignore[return-value]
|
||||
|
||||
def log_message(self, fmt: str, *args) -> None:
|
||||
# Never persist the session token from the initial/query download URL.
|
||||
print(
|
||||
"%s - - [%s] %s %s" % (
|
||||
self.client_address[0],
|
||||
self.log_date_time_string(),
|
||||
self.command,
|
||||
urlparse(self.path).path,
|
||||
),
|
||||
flush=True,
|
||||
)
|
||||
|
||||
def _host_allowed(self) -> bool:
|
||||
host = self.headers.get("Host", "").split(":", 1)[0].strip("[]").lower()
|
||||
return host in {"127.0.0.1", "localhost", "::1"}
|
||||
|
||||
def _authorized(self, query: dict[str, list[str]]) -> bool:
|
||||
supplied = self.headers.get("X-CASAN-Viewer-Token", "")
|
||||
if not supplied:
|
||||
supplied = query.get("token", [""])[0]
|
||||
return bool(supplied) and supplied == self.viewer.token
|
||||
|
||||
def _headers(self, status: int, content_type: str, length: int) -> None:
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", content_type)
|
||||
self.send_header("Content-Length", str(length))
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.send_header("Pragma", "no-cache")
|
||||
self.send_header("X-Content-Type-Options", "nosniff")
|
||||
self.send_header("X-Frame-Options", "DENY")
|
||||
self.send_header("Referrer-Policy", "no-referrer")
|
||||
self.send_header("Cross-Origin-Resource-Policy", "same-origin")
|
||||
self.send_header(
|
||||
"Content-Security-Policy",
|
||||
"default-src 'self'; script-src 'self'; style-src 'self'; "
|
||||
"img-src 'self' data:; connect-src 'self'; object-src 'none'; "
|
||||
"base-uri 'none'; frame-ancestors 'none'; form-action 'self'",
|
||||
)
|
||||
self.end_headers()
|
||||
|
||||
def _send(self, status: int, body: bytes, content_type: str) -> None:
|
||||
if len(body) > MAX_RESPONSE_BYTES:
|
||||
self._json(413, {"error": "CASAN_VIEW_RESPONSE_TOO_LARGE"})
|
||||
return
|
||||
self._headers(status, content_type, len(body))
|
||||
if self.command != "HEAD":
|
||||
self.wfile.write(body)
|
||||
|
||||
def _json(self, status: int, payload) -> None:
|
||||
self._send(
|
||||
status,
|
||||
(json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + "\n").encode("utf-8"),
|
||||
"application/json; charset=utf-8",
|
||||
)
|
||||
|
||||
def _asset(self, name: str) -> None:
|
||||
safe_name = "index.html" if name in {"", "/"} else name.lstrip("/")
|
||||
if safe_name not in {"index.html", "app.css", "app.js", "favicon.svg"}:
|
||||
self._json(404, {"error": "CASAN_VIEW_NOT_FOUND"})
|
||||
return
|
||||
path = ASSET_ROOT / safe_name
|
||||
try:
|
||||
body = path.read_bytes()
|
||||
except OSError:
|
||||
self._json(503, {"error": "CASAN_VIEW_ASSET_MISSING"})
|
||||
return
|
||||
content_type = mimetypes.guess_type(str(path))[0] or "application/octet-stream"
|
||||
if content_type.startswith("text/") or content_type == "application/javascript":
|
||||
content_type += "; charset=utf-8"
|
||||
self._send(200, body, content_type)
|
||||
|
||||
def _download(self, report: dict, fmt: str, filename: str) -> None:
|
||||
if fmt == "json":
|
||||
body = (json.dumps(report, ensure_ascii=False, indent=2) + "\n").encode("utf-8")
|
||||
content_type = "application/json; charset=utf-8"
|
||||
elif fmt == "html":
|
||||
body = report_html(report, "h6" if report.get("harness") == "H6" else "run").encode("utf-8")
|
||||
content_type = "text/html; charset=utf-8"
|
||||
else:
|
||||
self._json(400, {"error": "CASAN_REPORT_FORMAT_INVALID"})
|
||||
return
|
||||
if len(body) > MAX_RESPONSE_BYTES:
|
||||
self._json(413, {"error": "CASAN_REPORT_TOO_LARGE"})
|
||||
return
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", content_type)
|
||||
self.send_header("Content-Disposition", f'attachment; filename="{filename}.{fmt}"')
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.send_header("X-Content-Type-Options", "nosniff")
|
||||
self.send_header("Referrer-Policy", "no-referrer")
|
||||
self.end_headers()
|
||||
if self.command != "HEAD":
|
||||
self.wfile.write(body)
|
||||
|
||||
def do_HEAD(self) -> None:
|
||||
self.do_GET()
|
||||
|
||||
def do_GET(self) -> None:
|
||||
if self.client_address[0] not in {"127.0.0.1", "::1"} or not self._host_allowed():
|
||||
self._json(403, {"error": "CASAN_VIEW_LOOPBACK_ONLY"})
|
||||
return
|
||||
parsed = urlparse(self.path)
|
||||
path = unquote(parsed.path)
|
||||
query = parse_qs(parsed.query, keep_blank_values=False)
|
||||
if path in {"/", "/index.html", "/app.css", "/app.js"}:
|
||||
self._asset(path)
|
||||
return
|
||||
if not self._authorized(query):
|
||||
self._json(401, {"error": "CASAN_VIEW_TOKEN_REQUIRED"})
|
||||
return
|
||||
try:
|
||||
if path == "/healthz":
|
||||
self._json(200, {
|
||||
"status": "ok",
|
||||
"mode": "local_core",
|
||||
"project_id": project_profile(self.viewer.root)["project_id"],
|
||||
"pid": os.getpid(),
|
||||
})
|
||||
elif path == "/api/v1/project":
|
||||
self._json(200, project_profile(self.viewer.root))
|
||||
elif path == "/api/v1/runs":
|
||||
self._json(200, runs(self.viewer.root, int(query.get("limit", ["100"])[0])))
|
||||
elif path == "/api/v1/reports/h6":
|
||||
report = self._h6(query)
|
||||
self._json(200, report)
|
||||
elif path == "/api/v1/reports/h6/export":
|
||||
report = self._h6(query)
|
||||
self._download(report, query.get("format", ["html"])[0], report["report_id"])
|
||||
elif path == "/__shutdown":
|
||||
self._json(200, {"status": "stopping"})
|
||||
threading.Thread(target=self.viewer.shutdown, daemon=True).start()
|
||||
elif path.startswith("/api/v1/runs/") and path.endswith("/graph"):
|
||||
trace_id = path[len("/api/v1/runs/"):-len("/graph")].strip("/")
|
||||
self._trace_required(trace_id)
|
||||
self._json(200, trace_graph(self.viewer.root, trace_id))
|
||||
elif path.startswith("/api/v1/reports/run/"):
|
||||
suffix = path[len("/api/v1/reports/run/"):].strip("/")
|
||||
exporting = suffix.endswith("/export")
|
||||
trace_id = suffix[:-len("/export")].strip("/") if exporting else suffix
|
||||
self._trace_required(trace_id)
|
||||
report = run_report(self.viewer.root, trace_id)
|
||||
if report["verdict"] == "not_found":
|
||||
self._json(404, {"error": "CASAN_RUN_NOT_FOUND"})
|
||||
elif exporting:
|
||||
self._download(
|
||||
report,
|
||||
query.get("format", ["html"])[0],
|
||||
report["report_id"],
|
||||
)
|
||||
else:
|
||||
self._json(200, report)
|
||||
else:
|
||||
self._json(404, {"error": "CASAN_VIEW_NOT_FOUND"})
|
||||
except (TypeError, ValueError):
|
||||
self._json(400, {"error": "CASAN_VIEW_INVALID_QUERY"})
|
||||
except BrokenPipeError:
|
||||
return
|
||||
except Exception as error: # fail closed without leaking project paths
|
||||
self.log_error("request failed: %s", type(error).__name__)
|
||||
self._json(500, {"error": "CASAN_VIEW_INTERNAL_ERROR"})
|
||||
|
||||
def _trace_required(self, trace_id: str) -> None:
|
||||
if not SAFE_ID.fullmatch(trace_id):
|
||||
raise ValueError("unsafe trace id")
|
||||
|
||||
def _h6(self, query: dict[str, list[str]]) -> dict:
|
||||
return h6_report(
|
||||
self.viewer.root,
|
||||
project=query.get("project", [None])[0],
|
||||
run=query.get("run", [None])[0],
|
||||
from_date=query.get("from", [None])[0],
|
||||
to_date=query.get("to", [None])[0],
|
||||
limit=int(query.get("limit", ["50"])[0]),
|
||||
)
|
||||
|
||||
def do_POST(self) -> None:
|
||||
self._json(405, {"error": "CASAN_VIEW_READ_ONLY"})
|
||||
|
||||
do_PUT = do_POST
|
||||
do_PATCH = do_POST
|
||||
do_DELETE = do_POST
|
||||
|
||||
|
||||
def serve(root: Path, port: int, token: str, state_file: Path) -> int:
|
||||
if not token or len(token) < 32:
|
||||
raise ValueError("viewer token is too short")
|
||||
server = ViewerServer(("127.0.0.1", port), root, token)
|
||||
actual_port = server.server_address[1]
|
||||
atomic_json(state_file, {
|
||||
"schema_version": 1,
|
||||
"pid": os.getpid(),
|
||||
"port": actual_port,
|
||||
"token": token,
|
||||
"project_root": str(root.resolve()),
|
||||
"started_at": __import__("datetime").datetime.now(
|
||||
__import__("datetime").timezone.utc
|
||||
).isoformat().replace("+00:00", "Z"),
|
||||
})
|
||||
|
||||
def stop(_signum, _frame):
|
||||
threading.Thread(target=server.shutdown, daemon=True).start()
|
||||
|
||||
signal.signal(signal.SIGTERM, stop)
|
||||
signal.signal(signal.SIGINT, stop)
|
||||
try:
|
||||
server.serve_forever(poll_interval=0.25)
|
||||
finally:
|
||||
server.server_close()
|
||||
try:
|
||||
current = json.loads(state_file.read_text(encoding="utf-8"))
|
||||
if current.get("pid") == os.getpid():
|
||||
state_file.unlink()
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
return 0
|
||||
|
||||
|
||||
def main(argv=None) -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--root", required=True)
|
||||
parser.add_argument("--port", type=int, default=0)
|
||||
parser.add_argument("--token", required=True)
|
||||
parser.add_argument("--state-file", required=True)
|
||||
args = parser.parse_args(argv)
|
||||
return serve(
|
||||
Path(args.root).expanduser().resolve(),
|
||||
max(0, min(args.port, 65535)),
|
||||
args.token,
|
||||
Path(args.state_file).expanduser().resolve(),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user