Initial commit: stateful OpenStack API laboratory simulator.
Ship Keystone auth, multi-service handlers (Yoga→Dalmatian), Compose/Helm packaging, API contract packs, and pytest/Pulumi coverage labs.
This commit is contained in:
@@ -0,0 +1 @@
|
||||
# Pulumi OpenStack coverage helpers.
|
||||
@@ -0,0 +1,208 @@
|
||||
"""HTTP pack coverage with completeness and non-empty body checks."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections import Counter
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from _lib.validate import payload_nonempty
|
||||
|
||||
# Methods that normally return a JSON body on success (DELETE / 204 may be empty).
|
||||
_BODY_METHODS = frozenset({"GET", "POST", "PUT", "PATCH"})
|
||||
|
||||
|
||||
def _expected_ops(packs: dict[str, Any], *, collections_only: bool) -> int:
|
||||
if not collections_only:
|
||||
return sum(len(p.operations) for p in packs.values())
|
||||
total = 0
|
||||
for pack in packs.values():
|
||||
for op in pack.operations:
|
||||
if op.method == "GET" and "{" not in op.path:
|
||||
total += 1
|
||||
return total
|
||||
|
||||
|
||||
def _methods_breakdown(results: list[Any]) -> dict[str, int]:
|
||||
counts: Counter[str] = Counter()
|
||||
for r in results:
|
||||
method = getattr(r, "method", None) or (r.get("method") if isinstance(r, dict) else None)
|
||||
if method:
|
||||
counts[str(method).upper()] += 1
|
||||
return {m: counts.get(m, 0) for m in ("GET", "POST", "PUT", "PATCH", "DELETE")}
|
||||
|
||||
|
||||
def _nonempty_from_lifecycle(report: Any) -> list[dict[str, Any]]:
|
||||
"""Check succeeded lifecycle bodies (skip DELETE / 204 / 202 / no-body)."""
|
||||
failures: list[dict[str, Any]] = []
|
||||
for r in report.results:
|
||||
if not r.succeeded:
|
||||
continue
|
||||
if r.method == "DELETE" or r.status in {202, 204}:
|
||||
continue
|
||||
if r.method not in _BODY_METHODS:
|
||||
continue
|
||||
# OpenStack often returns 200/201 with an empty body (Swift PUT, tag put).
|
||||
if r.payload is None:
|
||||
continue
|
||||
if not payload_nonempty(r.payload, collection_key=r.collection_key, method=r.method):
|
||||
failures.append(
|
||||
{
|
||||
"service": r.service,
|
||||
"operation_id": r.operation_id,
|
||||
"method": r.method,
|
||||
"path": r.path,
|
||||
"status": r.status,
|
||||
"detail": "empty response body",
|
||||
"ok": False,
|
||||
"nonempty": False,
|
||||
}
|
||||
)
|
||||
return failures
|
||||
|
||||
|
||||
def _nonempty_smoke_collections(
|
||||
series: str,
|
||||
*,
|
||||
host: str,
|
||||
packs: dict[str, Any],
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Re-check collection GET bodies for smoke mode (stable seed data)."""
|
||||
from app.openstack.surface_probe import (
|
||||
SUCCESS,
|
||||
fill_path,
|
||||
http_request,
|
||||
issue_token,
|
||||
_seed_context,
|
||||
)
|
||||
|
||||
token, auth_body = issue_token(host)
|
||||
project_id = str(((auth_body.get("token") or {}).get("project") or {}).get("id") or "")
|
||||
ctx = _seed_context(host, token, project_id)
|
||||
failures: list[dict[str, Any]] = []
|
||||
for name in sorted(packs):
|
||||
pack = packs[name]
|
||||
for op in pack.operations:
|
||||
if "{" in op.path or op.method != "GET":
|
||||
continue
|
||||
path = fill_path(
|
||||
op.path,
|
||||
{
|
||||
**ctx,
|
||||
"project_id": project_id,
|
||||
"project": project_id,
|
||||
"tenant_id": project_id,
|
||||
"account": project_id,
|
||||
},
|
||||
)
|
||||
url = f"{host.rstrip('/')}{path}"
|
||||
status, payload = http_request(op.method, url, token=token, service=pack.name)
|
||||
if status not in SUCCESS or status == 204:
|
||||
continue
|
||||
if not payload_nonempty(payload, collection_key=op.collection_key, method=op.method):
|
||||
failures.append(
|
||||
{
|
||||
"service": pack.name,
|
||||
"operation_id": op.operation_id,
|
||||
"method": op.method,
|
||||
"path": op.path,
|
||||
"status": status,
|
||||
"detail": "empty response body",
|
||||
"ok": False,
|
||||
"nonempty": False,
|
||||
}
|
||||
)
|
||||
return failures
|
||||
|
||||
|
||||
def probe_pack_operations(
|
||||
series: str,
|
||||
*,
|
||||
host: str,
|
||||
collections_only: bool = False,
|
||||
require_nonempty: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
from app.openstack.contract_loader import load_series_pack
|
||||
from app.openstack.surface_probe import probe_series
|
||||
|
||||
report = probe_series(
|
||||
series,
|
||||
host=host,
|
||||
collections_only=collections_only,
|
||||
lifecycle=not collections_only,
|
||||
)
|
||||
|
||||
packs = load_series_pack(series)
|
||||
expected_ops = _expected_ops(packs, collections_only=collections_only)
|
||||
methods = _methods_breakdown(report.results)
|
||||
coverage_incomplete = len(report.results) != expected_ops
|
||||
|
||||
nonempty_failures: list[dict[str, Any]] = []
|
||||
if require_nonempty:
|
||||
if collections_only:
|
||||
nonempty_failures = _nonempty_smoke_collections(series, host=host, packs=packs)
|
||||
else:
|
||||
nonempty_failures = _nonempty_from_lifecycle(report)
|
||||
|
||||
probe_failures = [
|
||||
{
|
||||
"service": r.service,
|
||||
"operation_id": r.operation_id,
|
||||
"method": r.method,
|
||||
"path": r.path,
|
||||
"status": r.status,
|
||||
"detail": r.detail,
|
||||
"ok": False,
|
||||
"nonempty": True,
|
||||
}
|
||||
for r in report.failures
|
||||
]
|
||||
|
||||
coverage_failures: list[dict[str, Any]] = []
|
||||
if coverage_incomplete:
|
||||
coverage_failures.append(
|
||||
{
|
||||
"service": "_coverage",
|
||||
"operation_id": "coverage_incomplete",
|
||||
"method": "*",
|
||||
"path": "*",
|
||||
"status": 0,
|
||||
"detail": f"coverage_incomplete: total={len(report.results)} expected_ops={expected_ops}",
|
||||
"ok": False,
|
||||
"nonempty": True,
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"series": series,
|
||||
"host": host,
|
||||
"mode": report.mode,
|
||||
"total": len(report.results),
|
||||
"expected_ops": expected_ops,
|
||||
"coverage_incomplete": coverage_incomplete,
|
||||
"methods": methods,
|
||||
"ok_count": len(report.results) - len(report.failures),
|
||||
"fail_count": len(report.failures) + (1 if coverage_incomplete else 0),
|
||||
"nonempty_fail_count": len(nonempty_failures),
|
||||
"results": [
|
||||
{
|
||||
"service": r.service,
|
||||
"method": r.method,
|
||||
"path": r.path,
|
||||
"operation_id": r.operation_id,
|
||||
"status": r.status,
|
||||
"detail": r.detail,
|
||||
"mode": r.mode,
|
||||
"ok": r.ok,
|
||||
"succeeded": r.succeeded,
|
||||
}
|
||||
for r in report.results
|
||||
],
|
||||
"failures": coverage_failures + probe_failures + nonempty_failures,
|
||||
}
|
||||
|
||||
|
||||
def write_probe_json(payload: dict[str, Any], path: Path) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
|
||||
@@ -0,0 +1,132 @@
|
||||
"""Render HTML report from Pulumi + HTTP coverage results."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
import json
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
SERIES_ORDER = ("yoga", "antelope", "caracal", "dalmatian")
|
||||
|
||||
|
||||
def _methods_line(methods: dict[str, Any]) -> str:
|
||||
return " ".join(f"{m}={methods.get(m, 0)}" for m in ("GET", "POST", "PUT", "PATCH", "DELETE"))
|
||||
|
||||
|
||||
def render_html(summary: dict[str, Any], series_reports: list[dict[str, Any]]) -> str:
|
||||
generated = datetime.now(UTC).strftime("%Y-%m-%d %H:%M:%S UTC")
|
||||
cards = []
|
||||
for rep in series_reports:
|
||||
series = html.escape(str(rep.get("series", "?")))
|
||||
pu = rep.get("pulumi", {})
|
||||
http = rep.get("http", {})
|
||||
cards.append(
|
||||
f"""
|
||||
<div class="card">
|
||||
<h3>{series}</h3>
|
||||
<p class="muted">pulumi_openstack + HTTP pack probe</p>
|
||||
<div class="stats">
|
||||
<span class="ok">pulumi exports ok={len(pu.get("outputs", {})) - len(pu.get("empty_exports", []))}</span>
|
||||
<span class="fail">empty exports={len(pu.get("empty_exports", []))}</span>
|
||||
</div>
|
||||
<div class="stats">
|
||||
<span class="ok">http ok={http.get("ok_count", 0)}</span>
|
||||
<span class="fail">http fail={http.get("fail_count", 0)} nonempty_fail={http.get("nonempty_fail_count", 0)}</span>
|
||||
<span>http total={http.get("total", 0)}/{http.get("expected_ops", "?")}</span>
|
||||
</div>
|
||||
<div class="stats muted">
|
||||
methods: {html.escape(_methods_line(http.get("methods") or {}))}
|
||||
{" · <span class='fail'>coverage incomplete</span>" if http.get("coverage_incomplete") else ""}
|
||||
</div>
|
||||
</div>"""
|
||||
)
|
||||
|
||||
detail_rows = []
|
||||
for rep in series_reports:
|
||||
series = rep.get("series", "?")
|
||||
for item in rep.get("http", {}).get("failures", [])[:500]:
|
||||
detail_rows.append(
|
||||
'<tr class="fail">'
|
||||
f"<td>{html.escape(str(series))}</td>"
|
||||
f"<td>{html.escape(str(item.get('service', '')))}</td>"
|
||||
f"<td>{html.escape(str(item.get('operation_id', '')))}</td>"
|
||||
f"<td>{html.escape(str(item.get('method', '')))}</td>"
|
||||
f"<td><code>{html.escape(str(item.get('path', '')))}</code></td>"
|
||||
f"<td>{html.escape(str(item.get('status', '')))}</td>"
|
||||
f"<td>{html.escape(str(item.get('detail', ''))[:240])}</td>"
|
||||
"</tr>"
|
||||
)
|
||||
for empty in rep.get("pulumi", {}).get("empty_exports", []):
|
||||
detail_rows.append(
|
||||
'<tr class="fail">'
|
||||
f"<td>{html.escape(str(series))}</td>"
|
||||
f"<td>pulumi_openstack</td>"
|
||||
f"<td>export_nonempty</td>"
|
||||
f"<td>—</td><td><code>export</code></td><td>—</td>"
|
||||
f"<td>{html.escape(str(empty))}</td>"
|
||||
"</tr>"
|
||||
)
|
||||
|
||||
return f"""<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<title>Pulumi OpenStack coverage</title>
|
||||
<style>
|
||||
:root {{ --bg:#0f1419; --panel:#1a222c; --text:#e7ecf3; --muted:#9aa7b8; --ok:#3dd68c; --fail:#ff6b6b; --border:#2a3544; }}
|
||||
body {{ margin:0; font-family:"IBM Plex Sans",sans-serif; background:radial-gradient(1000px 500px at 0% 0%,#1b2a3d,var(--bg)); color:var(--text); }}
|
||||
header, main {{ max-width:1200px; margin:0 auto; padding:1.5rem; }}
|
||||
.summary, .cards {{ display:grid; gap:.75rem; grid-template-columns:repeat(auto-fit,minmax(160px,1fr)); margin:1rem 0; }}
|
||||
.summary div, .card {{ background:var(--panel); border:1px solid var(--border); border-radius:10px; padding:1rem; }}
|
||||
.ok {{ color:var(--ok); font-weight:600; }} .fail {{ color:var(--fail); font-weight:600; }}
|
||||
.muted {{ color:var(--muted); }}
|
||||
table {{ width:100%; border-collapse:collapse; background:var(--panel); border:1px solid var(--border); border-radius:10px; overflow:hidden; font-size:.9rem; }}
|
||||
th, td {{ padding:.45rem .6rem; border-bottom:1px solid var(--border); text-align:left; vertical-align:top; }}
|
||||
th {{ color:var(--muted); background:#121820; }}
|
||||
tr.fail {{ background:rgba(255,107,107,.08); }}
|
||||
code {{ font-family:ui-monospace,monospace; font-size:.82rem; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>Pulumi OpenStack API coverage</h1>
|
||||
<p class="muted">Generated {html.escape(generated)} · pulumi_openstack primary + HTTP pack probe with non-empty checks</p>
|
||||
</header>
|
||||
<main>
|
||||
<div class="summary">
|
||||
<div><strong>{summary.get("series_count", 0)}</strong><span class="muted"> series</span></div>
|
||||
<div><strong class="ok">{summary.get("pulumi_ok", 0)}</strong><span class="muted"> pulumi stacks ok</span></div>
|
||||
<div><strong class="fail">{summary.get("pulumi_fail", 0)}</strong><span class="muted"> pulumi failures</span></div>
|
||||
<div><strong class="ok">{summary.get("http_ok", 0)}</strong><span class="muted"> http ops ok</span></div>
|
||||
<div><strong class="fail">{summary.get("http_fail", 0)}</strong><span class="muted"> http / nonempty fails</span></div>
|
||||
</div>
|
||||
<h2>Series</h2>
|
||||
<div class="cards">{"".join(cards)}</div>
|
||||
<h2>Failures</h2>
|
||||
<table>
|
||||
<thead><tr><th>Series</th><th>Service</th><th>Operation</th><th>Method</th><th>Path</th><th>HTTP</th><th>Detail</th></tr></thead>
|
||||
<tbody>{"".join(detail_rows) if detail_rows else '<tr><td colspan="7">No failures</td></tr>'}</tbody>
|
||||
</table>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
def write_html(
|
||||
report_dir: Path, summary: dict[str, Any], series_reports: list[dict[str, Any]]
|
||||
) -> Path:
|
||||
path = report_dir / "pulumi-report.html"
|
||||
path.write_text(render_html(summary, series_reports), encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
def load_series_files(report_dir: Path) -> list[dict[str, Any]]:
|
||||
out = []
|
||||
for series in SERIES_ORDER:
|
||||
path = report_dir / f"series-{series}.json"
|
||||
if path.exists():
|
||||
out.append(json.loads(path.read_text(encoding="utf-8")))
|
||||
return out
|
||||
@@ -0,0 +1,84 @@
|
||||
"""Helpers for series activation and non-empty validation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from typing import Any
|
||||
|
||||
|
||||
def activate_series(host: str, series: str) -> None:
|
||||
body = json.dumps({"series": series}).encode()
|
||||
req = urllib.request.Request(
|
||||
f"{host.rstrip('/')}/ui/api/openstack/contracts/activate",
|
||||
data=body,
|
||||
headers={"Content-Type": "application/json", "Accept": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as res:
|
||||
if res.status >= 400:
|
||||
raise RuntimeError(f"activate {series}: HTTP {res.status}")
|
||||
except urllib.error.HTTPError as exc:
|
||||
raw = exc.read().decode()
|
||||
raise RuntimeError(f"activate {series}: HTTP {exc.code} {raw[:300]}") from exc
|
||||
|
||||
|
||||
def is_nonempty(value: Any) -> bool:
|
||||
if value is None:
|
||||
return False
|
||||
if isinstance(value, str) and not value.strip():
|
||||
return False
|
||||
if isinstance(value, (list, tuple, set, dict)) and len(value) == 0:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def assert_outputs_nonempty(outputs: dict[str, Any], *, min_count: int = 15) -> list[str]:
|
||||
"""Return list of failing export names (empty if all good)."""
|
||||
failures: list[str] = []
|
||||
if len(outputs) < min_count:
|
||||
failures.append(f"__export_count__={len(outputs)}<{min_count}")
|
||||
for key, value in sorted(outputs.items()):
|
||||
if not is_nonempty(value):
|
||||
failures.append(f"{key}={value!r}")
|
||||
return failures
|
||||
|
||||
|
||||
def payload_nonempty(
|
||||
payload: Any, *, collection_key: str | None = None, method: str = "GET"
|
||||
) -> bool:
|
||||
"""True when a successful response body has meaningful content.
|
||||
|
||||
For GET list/show and POST create we require real data — empty ``[]`` /
|
||||
``{}`` / blank strings fail. DELETE/204-style empties are not checked here.
|
||||
"""
|
||||
if payload is None:
|
||||
return False
|
||||
if isinstance(payload, str):
|
||||
return bool(payload.strip())
|
||||
if isinstance(payload, list):
|
||||
return len(payload) > 0
|
||||
if not isinstance(payload, dict):
|
||||
return True
|
||||
if collection_key and collection_key in payload:
|
||||
value = payload[collection_key]
|
||||
if isinstance(value, list):
|
||||
return len(value) > 0
|
||||
return is_nonempty(value)
|
||||
# Common OpenStack envelopes
|
||||
for key, value in payload.items():
|
||||
if key in {"versions", "version", "id", "token", "links", "status", "name"}:
|
||||
if is_nonempty(value):
|
||||
return True
|
||||
if isinstance(value, list) and value:
|
||||
return True
|
||||
if isinstance(value, dict) and (value.get("id") or value.get("name") or value.get("uuid")):
|
||||
return True
|
||||
if isinstance(value, str) and value.strip():
|
||||
return True
|
||||
if isinstance(value, (int, float, bool)):
|
||||
return True
|
||||
# Non-empty dict with any nested content
|
||||
return any(is_nonempty(v) for v in payload.values())
|
||||
Reference in New Issue
Block a user