Files
proxmox-api-simulator/pulumi-tests/pulumi/run_suite.py
T
Sergey Antropoff 48df10b17e 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)
2026-07-18 04:18:05 +03:00

274 lines
9.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Run Pulumi HX suite: full contract surface (majors 69) + 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())