Prepare 0.1.0 for lab release: durable handlers, HTTP Compose, CI, and pulumi-tests.
- Harden DB-backed handlers and seed profiles; align client wire shapes for cluster resources, QEMU config, and node SSL fields - Serve plain HTTP on Compose :8006; keep TLS optional (--profile tls) and terminate HTTPS at Kubernetes Ingress - Add pulumi-tests (full contract surface majors 6–9 + BPG lifecycle) and make pulumi-tests - Ship bilingual docs, CHANGELOG, SECURITY, CONTRIBUTING, and GitHub Actions (make ci + Compose/Helm validation)
This commit is contained in:
@@ -0,0 +1,273 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run Pulumi HX suite: full contract surface (majors 6–9) + lifecycle scenarios."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
ROOT = HERE.parent
|
||||
PROGRAMS = HERE / "programs"
|
||||
REPORTS = HERE / "reports"
|
||||
|
||||
# Ensure pvelib imports work when invoked as a script.
|
||||
sys.path.insert(0, str(HERE))
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
|
||||
def run_lifecycle(*, smoke: bool) -> dict[str, Any]:
|
||||
from pulumi import automation as auto
|
||||
|
||||
program_dir = PROGRAMS / "lifecycle"
|
||||
stack_name = f"hxlife{os.getpid()}{int(time.time())}"
|
||||
os.environ.setdefault("PULUMI_CONFIG_PASSPHRASE", "hx-test-passphrase")
|
||||
state = HERE / ".pulumi-state"
|
||||
state.mkdir(parents=True, exist_ok=True)
|
||||
os.environ.setdefault("PULUMI_BACKEND_URL", f"file://{state}")
|
||||
|
||||
env = {
|
||||
**os.environ,
|
||||
"PYTHONPATH": str(HERE)
|
||||
+ os.pathsep
|
||||
+ str(ROOT)
|
||||
+ os.pathsep
|
||||
+ os.environ.get("PYTHONPATH", ""),
|
||||
}
|
||||
if smoke:
|
||||
env["SMOKE_ONLY"] = "1"
|
||||
|
||||
started = time.monotonic()
|
||||
stack = None
|
||||
try:
|
||||
# Pass env (incl. PULUMI_BACKEND_URL) at workspace creation — assigning
|
||||
# workspace.env_vars after create_or_select_stack can lose stack selection.
|
||||
stack = auto.create_or_select_stack(
|
||||
stack_name=stack_name,
|
||||
work_dir=str(program_dir),
|
||||
opts=auto.LocalWorkspaceOptions(env_vars=env),
|
||||
)
|
||||
stack.set_config("smoke", auto.ConfigValue(value="1" if smoke else "0"))
|
||||
up_result = stack.up(on_output=lambda _: None)
|
||||
outputs = stack.outputs()
|
||||
if not outputs and getattr(up_result, "outputs", None):
|
||||
outputs = up_result.outputs
|
||||
ids: list[str] = []
|
||||
sid_out = outputs.get("scenario_ids")
|
||||
if sid_out is not None and getattr(sid_out, "value", None) is not None:
|
||||
value = sid_out.value
|
||||
if isinstance(value, list):
|
||||
ids = [str(x) for x in value]
|
||||
# Also require inventory / vm exports to be non-empty when present.
|
||||
for key in ("inventory", "vm"):
|
||||
item = outputs.get(key)
|
||||
if item is None or getattr(item, "value", None) in (None, "", {}, []):
|
||||
raise RuntimeError(f"lifecycle export {key!r} is empty")
|
||||
stack.destroy(on_output=lambda _: None)
|
||||
try:
|
||||
stack.workspace.remove_stack(stack_name)
|
||||
except Exception:
|
||||
pass
|
||||
elapsed = time.monotonic() - started
|
||||
if ids:
|
||||
return {
|
||||
"ok": True,
|
||||
"scenarios": [{"id": sid, "ok": True, "error": "", "time": elapsed / max(len(ids), 1)} for sid in ids],
|
||||
"time": elapsed,
|
||||
}
|
||||
return {
|
||||
"ok": True,
|
||||
"scenarios": [{"id": "lifecycle", "ok": True, "error": "", "time": elapsed}],
|
||||
"time": elapsed,
|
||||
}
|
||||
except Exception as exc: # noqa: BLE001
|
||||
detail = str(exc)
|
||||
stderr = getattr(exc, "stderr", None)
|
||||
if stderr:
|
||||
detail = f"{detail}\n{stderr}"
|
||||
if stack is not None:
|
||||
try:
|
||||
stack.destroy(on_output=lambda _: None)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
stack.workspace.remove_stack(stack_name)
|
||||
except Exception:
|
||||
pass
|
||||
return {
|
||||
"ok": False,
|
||||
"scenarios": [
|
||||
{
|
||||
"id": "lifecycle",
|
||||
"ok": False,
|
||||
"error": detail,
|
||||
"time": time.monotonic() - started,
|
||||
}
|
||||
],
|
||||
"time": time.monotonic() - started,
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--smoke", action="store_true", help="Major 9 surface + short lifecycle")
|
||||
parser.add_argument(
|
||||
"--skip-lifecycle",
|
||||
action="store_true",
|
||||
help="Only run the contract surface probe",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--skip-surface",
|
||||
action="store_true",
|
||||
help="Only run lifecycle scenarios",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--majors",
|
||||
default="",
|
||||
help="Comma-separated majors (default: 6,7,8,9 or 9 with --smoke)",
|
||||
)
|
||||
parser.add_argument("--report-html", default=str(REPORTS / "report.html"))
|
||||
parser.add_argument("--report-json", default=str(REPORTS / "results.json"))
|
||||
parser.add_argument("--report-junit", default=str(REPORTS / "junit.xml"))
|
||||
args = parser.parse_args()
|
||||
|
||||
from pvelib.surface import run_surface
|
||||
from report import write_html, write_json, write_junit
|
||||
|
||||
started = time.monotonic()
|
||||
if args.majors.strip():
|
||||
majors = tuple(int(part.strip()) for part in args.majors.split(",") if part.strip())
|
||||
elif args.smoke:
|
||||
majors = (9,)
|
||||
else:
|
||||
majors = (6, 7, 8, 9)
|
||||
|
||||
surface: list[dict[str, Any]] = []
|
||||
scenarios: list[dict[str, Any]] = []
|
||||
|
||||
# Lifecycle first: surface probing mutates shared auth/config state.
|
||||
if not args.skip_lifecycle:
|
||||
print("Lifecycle scenarios …")
|
||||
life = run_lifecycle(smoke=args.smoke)
|
||||
scenarios = life["scenarios"]
|
||||
for item in scenarios:
|
||||
print(f" {'ok' if item['ok'] else 'FAIL'} {item['id']}")
|
||||
if not item["ok"]:
|
||||
print(item["error"][-2000:], file=sys.stderr)
|
||||
|
||||
if not args.skip_surface:
|
||||
print(f"Surface probe majors={list(majors)} …")
|
||||
surface = run_surface(majors=majors)
|
||||
for item in surface:
|
||||
status = "ok" if item["ok"] else "FAIL"
|
||||
print(
|
||||
f" {status} PVE {item['version']}: declared={item['declared']} "
|
||||
f"probed={item['probed']} critical={item['failure_count']} "
|
||||
f"2xx={item['success_2xx']} 4xx={item['client_4xx']}"
|
||||
)
|
||||
if not item["ok"]:
|
||||
for fail in (item.get("failures") or [])[:10]:
|
||||
print(
|
||||
f" {fail.get('verb')} {fail.get('path')} "
|
||||
f"-> {fail.get('bucket')} {fail.get('status', fail.get('error', ''))}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
# Slim JSON: drop full method lists (keep failures)
|
||||
surface_slim = []
|
||||
for item in surface:
|
||||
slim = dict(item)
|
||||
slim.pop("methods", None)
|
||||
surface_slim.append(slim)
|
||||
|
||||
coverage = build_coverage(surface_slim, majors=list(majors))
|
||||
if surface_slim:
|
||||
majs = coverage["majors"]
|
||||
if len(majs) >= 2:
|
||||
major_label = f"{majs[0]}–{majs[-1]}"
|
||||
elif majs:
|
||||
major_label = str(majs[0])
|
||||
else:
|
||||
major_label = "none"
|
||||
print(
|
||||
f"Coverage: {coverage['probed_total']}/{coverage['declared_total']} "
|
||||
f"methods across majors {major_label} "
|
||||
f"(critical={coverage['critical_total']})"
|
||||
)
|
||||
|
||||
# Explicit gate: every major must have declared==probed and zero critical failures.
|
||||
surface_ok = all(
|
||||
int(m.get("declared") or 0) == int(m.get("probed") or 0)
|
||||
and int(m.get("failure_count") or 0) == 0
|
||||
and bool(m.get("ok"))
|
||||
for m in surface_slim
|
||||
)
|
||||
lifecycle_ok = all(bool(s.get("ok")) for s in scenarios)
|
||||
coverage_ok = bool(coverage.get("ok")) if surface_slim else True
|
||||
suite_ok = surface_ok and lifecycle_ok and coverage_ok
|
||||
|
||||
payload = {
|
||||
"ok": suite_ok,
|
||||
"elapsed": time.monotonic() - started,
|
||||
"smoke": args.smoke,
|
||||
"majors": list(majors),
|
||||
"coverage": coverage,
|
||||
"surface": surface_slim,
|
||||
"scenarios": scenarios,
|
||||
}
|
||||
|
||||
write_json(payload, Path(args.report_json))
|
||||
write_html(payload, Path(args.report_html))
|
||||
write_junit(payload, Path(args.report_junit))
|
||||
|
||||
print(
|
||||
f"Suite {'PASS' if payload['ok'] else 'FAIL'} in {payload['elapsed']:.1f}s; "
|
||||
f"html={args.report_html} json={args.report_json}"
|
||||
)
|
||||
return 0 if payload["ok"] else 1
|
||||
|
||||
|
||||
def build_coverage(
|
||||
surface: list[dict[str, Any]],
|
||||
*,
|
||||
majors: list[int] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Aggregate declared/probed/critical counts across surface majors."""
|
||||
by_major: list[dict[str, Any]] = []
|
||||
for item in surface:
|
||||
declared = int(item.get("declared") or 0)
|
||||
probed = int(item.get("probed") or 0)
|
||||
critical = int(item.get("failure_count") or 0)
|
||||
by_major.append(
|
||||
{
|
||||
"major": item.get("major"),
|
||||
"version": item.get("version"),
|
||||
"declared": declared,
|
||||
"probed": probed,
|
||||
"critical": critical,
|
||||
"complete": declared == probed and critical == 0,
|
||||
}
|
||||
)
|
||||
declared_total = sum(m["declared"] for m in by_major)
|
||||
probed_total = sum(m["probed"] for m in by_major)
|
||||
critical_total = sum(m["critical"] for m in by_major)
|
||||
major_ids = [m["major"] for m in by_major if m.get("major") is not None]
|
||||
if not major_ids and majors:
|
||||
major_ids = list(majors)
|
||||
complete = bool(by_major) and all(m["complete"] for m in by_major)
|
||||
return {
|
||||
"majors": major_ids,
|
||||
"declared_total": declared_total,
|
||||
"probed_total": probed_total,
|
||||
"critical_total": critical_total,
|
||||
"by_major": by_major,
|
||||
"ok": complete,
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user