f8d3cbdd59
Add the FastAPI app, PostgreSQL migrations, Docker/Helm packaging, API contracts, docs, client examples, and the unit/integration/compatibility test suite for local client and tooling labs without a real vCenter.
214 lines
8.5 KiB
Python
214 lines
8.5 KiB
Python
"""HTML report for pulumi hybrid lab suite (pulumi-vsphere + REST + SOAP)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import html
|
|
from datetime import UTC, datetime
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
|
|
def _esc(value: Any) -> str:
|
|
return html.escape(str(value), quote=True)
|
|
|
|
|
|
def _section_rest(summary: dict[str, Any]) -> str:
|
|
rest = summary.get("rest") or {}
|
|
majors = rest.get("majors") or []
|
|
by_verb = rest.get("by_verb") or {}
|
|
failures = rest.get("failures") or []
|
|
|
|
major_rows = []
|
|
for m in majors:
|
|
buckets = m.get("buckets") or {}
|
|
deep = m.get("deep_vs_stub") or {}
|
|
major_rows.append(
|
|
"<tr>"
|
|
f"<td>{_esc(m.get('major'))}</td>"
|
|
f"<td>{_esc(m.get('version'))}</td>"
|
|
f"<td>{_esc(m.get('probed'))}</td>"
|
|
f"<td>{_esc(m.get('failed'))}</td>"
|
|
f"<td>{_esc(buckets.get('success_2xx', 0))}</td>"
|
|
f"<td>{_esc(buckets.get('client_4xx', 0))}</td>"
|
|
f"<td>{_esc(buckets.get('server_5xx', 0))}</td>"
|
|
f"<td>{_esc(deep.get('deep', 0))}/{_esc(deep.get('stub', 0))}</td>"
|
|
"</tr>"
|
|
)
|
|
|
|
verb_bits = " · ".join(f"{_esc(k)}={_esc(v)}" for k, v in sorted(by_verb.items()))
|
|
fail_rows = []
|
|
for f in failures[:40]:
|
|
fail_rows.append(
|
|
"<tr>"
|
|
f"<td>{_esc(f.get('major'))}</td>"
|
|
f"<td>{_esc(f.get('verb'))}</td>"
|
|
f"<td><code>{_esc(f.get('path'))}</code></td>"
|
|
f"<td>{_esc(f.get('kind'))}</td>"
|
|
f"<td>{_esc(f.get('status'))}</td>"
|
|
f"<td class='err'>{_esc((f.get('body') or f.get('expected') or '')[:180])}</td>"
|
|
"</tr>"
|
|
)
|
|
|
|
return f"""
|
|
<h2>REST matrix</h2>
|
|
<p class="meta">total={_esc(rest.get("total"))} · failed={_esc(rest.get("failed"))}
|
|
· verbs: {verb_bits or "—"}</p>
|
|
<table>
|
|
<thead><tr><th>Major</th><th>Version</th><th>Probed</th><th>Failed</th>
|
|
<th>2xx</th><th>4xx</th><th>5xx</th><th>deep/stub</th></tr></thead>
|
|
<tbody>{"".join(major_rows) or '<tr><td colspan="8">—</td></tr>'}</tbody>
|
|
</table>
|
|
<h3>REST failures (sample)</h3>
|
|
<table>
|
|
<thead><tr><th>Major</th><th>Verb</th><th>Path</th><th>Kind</th><th>Status</th><th>Detail</th></tr></thead>
|
|
<tbody>{"".join(fail_rows) or '<tr><td colspan="6">none</td></tr>'}</tbody>
|
|
</table>
|
|
"""
|
|
|
|
|
|
def _section_crud(summary: dict[str, Any]) -> str:
|
|
crud = summary.get("crud") or {}
|
|
flows = crud.get("flows") or []
|
|
rows = []
|
|
for f in flows:
|
|
rows.append(
|
|
f"<tr class='{_esc(f.get('status'))}'>"
|
|
f"<td>{_esc(f.get('flow'))}</td>"
|
|
f"<td>{_esc(f.get('step'))}</td>"
|
|
f"<td>{_esc(f.get('status'))}</td>"
|
|
f"<td class='err'>{_esc(f.get('error') or '—')}</td>"
|
|
f"</tr>"
|
|
)
|
|
return f"""
|
|
<h2>Deep REST CRUD</h2>
|
|
<p class="meta">total={_esc(crud.get("total"))} · failed={_esc(crud.get("failed"))}</p>
|
|
<table>
|
|
<thead><tr><th>Flow</th><th>Step</th><th>Status</th><th>Detail</th></tr></thead>
|
|
<tbody>{"".join(rows) or '<tr><td colspan="4">— (smoke skips CRUD)</td></tr>'}</tbody>
|
|
</table>
|
|
"""
|
|
|
|
|
|
def _section_soap(summary: dict[str, Any]) -> str:
|
|
soap = summary.get("soap") or {}
|
|
ops = soap.get("ops") or []
|
|
rows = []
|
|
for op in ops:
|
|
status = "passed" if op.get("ok") else "failed"
|
|
rows.append(
|
|
f"<tr class='{_esc(status)}'>"
|
|
f"<td><code>{_esc(op.get('op'))}</code></td>"
|
|
f"<td>{_esc(op.get('status'))}</td>"
|
|
f"<td>{_esc(status)}</td>"
|
|
f"<td class='err'>{_esc(op.get('error') or '—')}</td>"
|
|
f"</tr>"
|
|
)
|
|
return f"""
|
|
<h2>SOAP WSDL ops</h2>
|
|
<p class="meta">total={_esc(soap.get("total"))} · failed={_esc(soap.get("failed"))}</p>
|
|
<table>
|
|
<thead><tr><th>Operation</th><th>HTTP</th><th>Status</th><th>Detail</th></tr></thead>
|
|
<tbody>{"".join(rows) or '<tr><td colspan="4">— (smoke skips SOAP)</td></tr>'}</tbody>
|
|
</table>
|
|
"""
|
|
|
|
|
|
def render_html(summary: dict[str, Any]) -> str:
|
|
generated = summary.get("generated_at") or datetime.now(UTC).isoformat()
|
|
cases = summary.get("cases") or []
|
|
total = len(cases)
|
|
passed = sum(1 for c in cases if c.get("status") == "passed")
|
|
failed = sum(1 for c in cases if c.get("status") == "failed")
|
|
skipped = sum(1 for c in cases if c.get("status") == "skipped")
|
|
rest = summary.get("rest") or {}
|
|
crud = summary.get("crud") or {}
|
|
soap = summary.get("soap") or {}
|
|
|
|
rows = []
|
|
for case in cases:
|
|
status = case.get("status") or ""
|
|
outs = case.get("outputs") or {}
|
|
out_html = "<br/>".join(f"<code>{_esc(k)}</code>={_esc(v)}" for k, v in outs.items())
|
|
err = _esc(case.get("error") or "")
|
|
rows.append(
|
|
f"<tr class='{_esc(status)}'>"
|
|
f"<td>{_esc(case.get('id'))}</td>"
|
|
f"<td>{_esc(case.get('title'))}</td>"
|
|
f"<td>{_esc(status)}</td>"
|
|
f"<td class='outs'>{out_html or '—'}</td>"
|
|
f"<td class='err'>{err or '—'}</td>"
|
|
f"</tr>"
|
|
)
|
|
|
|
return f"""<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8"/>
|
|
<title>pulumi hybrid lab report</title>
|
|
<style>
|
|
:root {{
|
|
--bg:#0f1419; --panel:#1a222c; --text:#e7eef7; --muted:#9aa8b8;
|
|
--ok:#3dd68c; --fail:#ff6b6b; --skip:#f0c14a; --line:#2a3542; --accent:#5b9fd4;
|
|
}}
|
|
body {{ margin:0; font-family:"IBM Plex Sans","Segoe UI",sans-serif; background:var(--bg); color:var(--text); }}
|
|
header, main {{ max-width:1200px; margin:0 auto; padding:1.5rem; }}
|
|
h1 {{ margin:0 0 .4rem; }}
|
|
h2 {{ margin:1.75rem 0 .6rem; color:var(--accent); }}
|
|
h3 {{ margin:1rem 0 .4rem; font-size:1rem; color:var(--muted); }}
|
|
.meta {{ color:var(--muted); }}
|
|
.summary {{ display:grid; grid-template-columns:repeat(4,1fr); gap:.75rem; margin:1rem 0 1.5rem; }}
|
|
.stat {{ background:var(--panel); border:1px solid var(--line); border-radius:12px; padding:1rem; }}
|
|
.stat strong {{ display:block; font-size:1.5rem; margin-top:.25rem; }}
|
|
.ok strong {{ color:var(--ok); }} .fail strong {{ color:var(--fail); }} .skip strong {{ color:var(--skip); }}
|
|
table {{ width:100%; border-collapse:collapse; background:var(--panel); border:1px solid var(--line);
|
|
border-radius:12px; overflow:hidden; margin-bottom:1rem; }}
|
|
th, td {{ padding:.55rem .65rem; border-bottom:1px solid var(--line); text-align:left; vertical-align:top; font-size:.9rem; }}
|
|
th {{ color:var(--accent); }}
|
|
tr.failed td:nth-child(3) {{ color:var(--fail); font-weight:600; }}
|
|
tr.passed td:nth-child(3) {{ color:var(--ok); }}
|
|
tr.skipped td:nth-child(3) {{ color:var(--skip); }}
|
|
code {{ font-family:ui-monospace,Menlo,monospace; font-size:.8rem; }}
|
|
.outs, .err {{ word-break:break-word; }}
|
|
.metrics {{ display:grid; grid-template-columns:repeat(3,1fr); gap:.75rem; margin-bottom:1rem; }}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<header>
|
|
<h1>pulumi hybrid lab suite</h1>
|
|
<div class="meta">Generated {_esc(generated)} · server {_esc(summary.get("vsphere_server"))}
|
|
· base {_esc(summary.get("vsphere_base"))}
|
|
· smoke={_esc(summary.get("smoke"))}</div>
|
|
</header>
|
|
<main>
|
|
<section class="summary">
|
|
<div class="stat"><span>Cases</span><strong>{total}</strong></div>
|
|
<div class="stat ok"><span>Passed</span><strong>{passed}</strong></div>
|
|
<div class="stat fail"><span>Failed</span><strong>{failed}</strong></div>
|
|
<div class="stat skip"><span>Skipped</span><strong>{skipped}</strong></div>
|
|
</section>
|
|
<section class="metrics">
|
|
<div class="stat"><span>REST failed</span><strong>{_esc(rest.get("failed", 0))}</strong>
|
|
<span class="meta"> / {_esc(rest.get("total", 0))} probes</span></div>
|
|
<div class="stat"><span>CRUD failed</span><strong>{_esc(crud.get("failed", 0))}</strong>
|
|
<span class="meta"> / {_esc(crud.get("total", 0))} flows</span></div>
|
|
<div class="stat"><span>SOAP failed</span><strong>{_esc(soap.get("failed", 0))}</strong>
|
|
<span class="meta"> / {_esc(soap.get("total", 0))} ops</span></div>
|
|
</section>
|
|
<h2>Suite cases</h2>
|
|
<table>
|
|
<thead><tr><th>ID</th><th>Title</th><th>Status</th><th>Outputs</th><th>Error</th></tr></thead>
|
|
<tbody>{"".join(rows)}</tbody>
|
|
</table>
|
|
{_section_rest(summary)}
|
|
{_section_crud(summary)}
|
|
{_section_soap(summary)}
|
|
</main>
|
|
</body>
|
|
</html>
|
|
"""
|
|
|
|
|
|
def write_report(summary: dict[str, Any], path: Path) -> None:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text(render_html(summary), encoding="utf-8")
|