#!/usr/bin/env python3 """Aggregate per-VM results into a single self-contained HTML report. Inputs (from scripts/test/output//): /install.log — captured cloud-init / install output /install-status — "OK" or "FAIL" /pytest-report.json — pytest-json-report output (may be missing if install failed) /journalctl.log — backend logs (best-effort) Output: report.html in the run directory. """ import json import sys import html from pathlib import Path from datetime import datetime def esc(s): return html.escape(str(s) if s is not None else "") def load_json(path): try: return json.loads(path.read_text(encoding="utf-8-sig")) except Exception: return None def read_text(path, limit=200_000): try: data = path.read_text(encoding="utf-8", errors="replace") if len(data) > limit: data = "...[truncated]...\n" + data[-limit:] return data except Exception: return "(file missing)" def vm_summary(vm_dir: Path): name = vm_dir.name status_file = vm_dir / "install-status" install_ok = status_file.exists() and status_file.read_text().strip() == "OK" pytest_json = load_json(vm_dir / "pytest-report.json") tests = [] pass_count = fail_count = skip_count = 0 if pytest_json and "tests" in pytest_json: for t in pytest_json["tests"]: outcome = t.get("outcome", "unknown") tests.append((t.get("nodeid", "?"), outcome, t.get("call", {}).get("longrepr", ""))) if outcome == "passed": pass_count += 1 elif outcome == "failed": fail_count += 1 elif outcome == "skipped": skip_count += 1 overall = "PASS" if (install_ok and fail_count == 0 and (pass_count > 0 or pytest_json is None)) else "FAIL" if not install_ok: overall = "FAIL" return { "name": name, "install_ok": install_ok, "overall": overall, "tests": tests, "pass": pass_count, "fail": fail_count, "skip": skip_count, "install_log": read_text(vm_dir / "install.log"), "journal": read_text(vm_dir / "journalctl.log", limit=50_000), "runtime": load_json(vm_dir / "runtime.json"), } def runtime_table(measurement): if not measurement: return '

Runtime baseline: unavailable (no artifact).

' status = measurement.get('status', 'unknown') if status != 'measured': return f"

Runtime baseline: {esc(status)}. {esc(measurement.get('reason', ''))}

" def number(value, divisor=1): return 'unavailable' if value is None else f'{value / divisor:,.2f}' rows = [] for item in measurement.get('summary', []): memory = item.get('memory_bytes', {}) cpu = item.get('cpu_percent_one_core', {}) rows.append(f"{esc(item['name'])}" f"{number(memory.get('p50'), 1024 ** 2)}" f"{number(memory.get('sampled_max'), 1024 ** 2)}" f"{number(cpu.get('p50'))}{number(cpu.get('p95'))}") return ('

Post-install observation: cgroup memory includes cache; CPU 100% = one logical CPU. ' 'Sampled peaks can miss short spikes. This is not a capacity benchmark.

' '' '' + ''.join(rows) + '
ServiceMedian MiBSampled peak MiBMedian CPU %p95 CPU %
' f"

Source: {esc(measurement.get('running_revision'))}

") HTML_TEMPLATE = """ ServerKit E2E Report — {ts} {refresh_meta}

ServerKit E2E Test Report

{live_banner}

Run: {run_id} · {ts}

Overall: {overall}   VMs: {n_vms}   Passed: {n_pass}   Failed: {n_fail}

{vm_sections} """ VM_TEMPLATE = """

{name} {overall}

Stage: {stage} Install: {install} Passed: {p} Failed: {f} Skipped: {s}
{tests_table} {runtime_table}
Install log
{install_log}
journalctl -u serverkit
{journal}
""" def _read_state(run_dir: Path): """state.json (written by the orchestrator) tells us which phase each VM is in while the run is still going.""" f = run_dir / "state.json" if not f.exists(): return {} try: return json.loads(f.read_text(encoding="utf-8")) except Exception: return {} def render(run_dir: Path) -> Path: state = _read_state(run_dir) state_file_present = (run_dir / "state.json").exists() # If state.json is missing we're likely rendering between the orchestrator # creating OutDir and the first Save-State call — treat as RUNNING so the # page doesn't flash "FAIL" on first open. running = state.get("running", not state_file_present) vm_states = state.get("vms", {}) vm_dirs = sorted([p for p in run_dir.iterdir() if p.is_dir()]) summaries = [vm_summary(d) for d in vm_dirs] # Add stage from state.json (if available) and merge in VMs that haven't # produced output yet (so the UI shows them as "running"). seen = {s["name"] for s in summaries} for name, st in vm_states.items(): if name not in seen: summaries.append({ "name": name, "install_ok": False, "overall": "RUNNING", "tests": [], "pass": 0, "fail": 0, "skip": 0, "install_log": "", "journal": "", }) for s in summaries: s["stage"] = vm_states.get(s["name"], {}).get("stage", "done") # While the run is in progress, a VM that hasn't produced a status # file yet should display as RUNNING (not FAIL). if running and s["stage"] not in ("done", "install failed") and not s["install_ok"]: s["overall"] = "RUNNING" n_pass = sum(1 for s in summaries if s["overall"] == "PASS") n_fail = sum(1 for s in summaries if s["overall"] == "FAIL") overall = "PASS" if (n_fail == 0 and n_pass > 0 and not running) else ("RUNNING" if running else ("PASS" if n_fail == 0 and n_pass > 0 else "FAIL")) refresh_meta = '' if running else '' live_banner = '
Run in progress — page auto-refreshes every 8s
' if running else '' vm_sections = [] for s in summaries: cls = {"PASS": "pass", "FAIL": "fail", "RUNNING": "running"}.get(s["overall"], "fail") if s["tests"]: rows = [] for nid, oc, repr_ in s["tests"]: details_html = ( f"
{esc(repr_)}
" if (repr_ and str(repr_).strip()) else "—" ) rows.append( f"{esc(nid)}" f"{esc(oc)}" f"{details_html}" ) tests_table = ("" + "".join(rows) + "
TestResultDetails
") elif s["overall"] == "RUNNING": tests_table = f"

Currently: {esc(s['stage'])}…

" else: tests_table = "

No pytest results (install likely failed before harness ran).

" vm_sections.append(VM_TEMPLATE.format( cls=cls, name=esc(s["name"]), overall=esc(s["overall"]), stage=esc(s.get("stage", "?")), install="OK" if s["install_ok"] else ("-" if s["overall"] == "RUNNING" else "FAIL"), p=s["pass"], f=s["fail"], s=s["skip"], tests_table=tests_table, runtime_table=runtime_table(s.get('runtime')), install_log=esc(s["install_log"]), journal=esc(s["journal"]), )) out = run_dir / "report.html" out.write_text(HTML_TEMPLATE.format( ts=datetime.now().strftime("%Y-%m-%d %H:%M:%S"), run_id=esc(run_dir.name), overall=overall, overall_cls={"PASS":"pass","FAIL":"fail","RUNNING":"running"}.get(overall,"fail"), n_vms=len(summaries), n_pass=n_pass, refresh_meta=refresh_meta, live_banner=live_banner, n_fail=n_fail, vm_sections="\n".join(vm_sections), ), encoding="utf-8") return out if __name__ == "__main__": if len(sys.argv) != 2: print("usage: report.py ", file=sys.stderr) sys.exit(2) run_dir = Path(sys.argv[1]).resolve() if not run_dir.is_dir(): print(f"not a directory: {run_dir}", file=sys.stderr) sys.exit(2) out = render(run_dir) print(str(out))