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:
Sergey Antropoff
2026-07-18 04:18:05 +03:00
parent 777926487b
commit 48df10b17e
172 changed files with 7528 additions and 1208 deletions
+14
View File
@@ -0,0 +1,14 @@
API_URL=http://simulator:8006
API_USER=root@pam
API_PASSWORD=secret
API_TOKEN=root@pam!automation=automation-secret
API_AUDITOR_USER=auditor@pve
API_AUDITOR_PASSWORD=auditor-secret
API_AUDITOR_TOKEN=auditor@pve!readonly=readonly-secret
PVE_NODE=pve01
PVE_STORAGE=local-lvm
PVE_BRIDGE=vmbr0
API_TIMEOUT=120
API_POLL_INTERVAL=0.2
TEST_RESOURCE_PREFIX=hx
SEED_PROFILE=small
+11
View File
@@ -0,0 +1,11 @@
.env
**/reports/*.xml
**/reports/*.html
**/reports/*.json
pulumi/.pulumi-state/
pulumi/.pulumi-work/
pulumi/programs/**/.venv/
__pycache__/
*.pyc
.pytest_cache/
*.retry
+43
View File
@@ -0,0 +1,43 @@
COMPOSE := docker compose -f docker/docker-compose.yml
ENV_FILE := .env
.PHONY: help env up down build seed test test-smoke clean-test-resources reports-dir
help: ## Show targets
@awk 'BEGIN {FS = ":.*## "}; /^[a-zA-Z0-9_-]+:.*## / {printf "%-24s %s\n", $$1, $$2}' $(MAKEFILE_LIST)
env: ## Copy .env.example → .env if missing
@test -f $(ENV_FILE) || cp .env.example $(ENV_FILE)
up: env ## Start postgres + simulator + internal TLS (for pulumi-proxmoxve) + seed
$(COMPOSE) --env-file $(ENV_FILE) up -d --build postgres migrate simulator tls-gateway
$(COMPOSE) --env-file $(ENV_FILE) run --rm seed
down: ## Stop test stack
$(COMPOSE) --env-file $(ENV_FILE) down -v
build: env ## Build simulator + pulumi runner
$(COMPOSE) --env-file $(ENV_FILE) --profile pulumi build
seed: env ## Reseed simulator (small profile by default)
$(COMPOSE) --env-file $(ENV_FILE) run --rm seed
reports-dir:
@mkdir -p pulumi/reports reports
test: env reports-dir ## Full suite: surface majors 69 + lifecycle (HTML report)
$(COMPOSE) --env-file $(ENV_FILE) --profile pulumi run --rm --entrypoint /usr/local/bin/python3 pulumi-runner \
pulumi/run_suite.py \
--report-html pulumi/reports/report.html \
--report-json pulumi/reports/results.json \
--report-junit pulumi/reports/junit.xml
test-smoke: env reports-dir ## Smoke: surface major 9 + short lifecycle
$(COMPOSE) --env-file $(ENV_FILE) --profile pulumi run --rm --entrypoint /usr/local/bin/python3 pulumi-runner \
pulumi/run_suite.py --smoke \
--report-html pulumi/reports/report-smoke.html \
--report-json pulumi/reports/results-smoke.json \
--report-junit pulumi/reports/junit-smoke.xml
clean-test-resources: env ## Delete leftover hx* guests on simulator
$(COMPOSE) --env-file $(ENV_FILE) --profile cleanup run --rm cleanup
+49
View File
@@ -0,0 +1,49 @@
**Language / Язык:** [English](README.md) | [Русский](README.ru.md)
# Pulumi integration suite
Docker-only suite against the Proxmox API simulator.
| Layer | What it covers |
|---|---|
| **Surface** | **100%** of declared contract methods for PVE majors **69** (every path+verb) via HTTP (`pvelib/surface.py`). Suite PASS requires `declared == probed` per major and zero critical failures (501, “not supported” messages, 5xx, exceptions, unknown verbs). |
| **Lifecycle** | **`pulumi-proxmoxve` only** (BPG Terraform bridge) — Provider, inventory data sources, `VmLegacy` + non-empty checks. Negative auth via httpx. This is **not** full API coverage; the provider exposes dozens of resources, not thousands of contract methods. Provider uses internal HTTPS gateway; surface stays HTTP. |
Reports (`pulumi/reports/report.html`, `results.json`, `junit.xml`) include a **Full contract coverage** summary with per-major and total `declared`/`probed` counts (e.g. `Coverage: 2324/2324 methods across majors 69`).
## Layout
```
pulumi-tests/
pulumi/
run_suite.py
report.py
pvelib/ # httpx client + surface probe
programs/lifecycle/ # pulumi-proxmoxve program
docker/docker-compose.yml
Makefile
```
## Run
From the repository root:
```bash
make pulumi-tests
```
Or:
```bash
cd pulumi-tests
make up
make test-smoke
make test
open pulumi/reports/report.html
make down
```
Provider env (set by Compose): `PROXMOX_VE_ENDPOINT=https://tls-gateway:8443/`
(internal suite TLS — `pulumi-proxmoxve` rejects `http://`), plus username/password
/`INSECURE`. Surface probe uses `API_URL=http://simulator:8006`. Host lab URL
remains plain `http://localhost:8006/` (Kubernetes HTTPS is Ingress-only).
+49
View File
@@ -0,0 +1,49 @@
**Language / Язык:** [English](README.md) | [Русский](README.ru.md)
# Интеграционный набор Pulumi
Suite только в Docker против симулятора Proxmox API.
| Слой | Покрытие |
|---|---|
| **Surface** | **100%** объявленных методов контракта majors **69** (каждый path+verb) по HTTP (`pvelib/surface.py`). PASS только при `declared == probed` на каждый major и нуле критичных сбоев (501, «not supported», 5xx, exceptions, неизвестные verb). |
| **Lifecycle** | Только **`pulumi-proxmoxve`** (мост BPG) — Provider, data sources инвентаря, `VmLegacy` + проверки непустых outputs. Это **не** полное покрытие API: в провайдере десятки ресурсов, не тысячи contract methods. HTTPS через lab TLS-шлюз; surface — HTTP. |
В отчётах (`pulumi/reports/report.html`, `results.json`, `junit.xml`) — блок **Full contract coverage** с итогами `declared`/`probed` по major и суммой (например `Coverage: 2324/2324 methods across majors 69`).
## Структура
```
pulumi-tests/
pulumi/
run_suite.py
report.py
pvelib/ # httpx + surface probe
programs/lifecycle/ # программа pulumi-proxmoxve
docker/docker-compose.yml
Makefile
```
## Запуск
Из корня репозитория:
```bash
make pulumi-tests
```
Или:
```bash
cd pulumi-tests
make up
make test-smoke
make test
open pulumi/reports/report.html
make down
```
Env провайдера (Compose): `PROXMOX_VE_ENDPOINT=https://tls-gateway:8443/`
(внутренний TLS suite — `pulumi-proxmoxve` не принимает `http://`), плюс
username/password/`INSECURE`. Surface probe: `API_URL=http://simulator:8006`.
Хостовый lab URL — plain `http://localhost:8006/` (HTTPS в K8s — только Ingress).
@@ -0,0 +1,22 @@
FROM python:3.13-bookworm
ARG PULUMI_VERSION=3.193.0
ARG PULUMI_PROXMOXVE_VERSION=8.2.1
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl ca-certificates \
&& curl -fsSL https://get.pulumi.com | sh -s -- --version "${PULUMI_VERSION}" \
&& ln -s /root/.pulumi/bin/pulumi /usr/local/bin/pulumi \
&& pip install --no-cache-dir \
"httpx==0.28.1" \
"pulumi==${PULUMI_VERSION}" \
"pulumi-proxmoxve==${PULUMI_PROXMOXVE_VERSION}" \
&& pulumi plugin install resource proxmoxve "${PULUMI_PROXMOXVE_VERSION}" \
--server github://api.github.com/muhlba91/pulumi-proxmoxve \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /workspace/pulumi-tests
ENV PYTHONPATH=/workspace/pulumi-tests/pulumi:/workspace/pulumi-tests
ENV PULUMI_CONFIG_PASSPHRASE=hx-test-passphrase
CMD ["python", "pulumi/run_suite.py"]
+188
View File
@@ -0,0 +1,188 @@
name: proxmox-hx-tests
x-test-env: &test-env
API_URL: http://simulator:8006
API_USER: ${API_USER:-root@pam}
API_PASSWORD: ${API_PASSWORD:-secret}
API_TOKEN: ${API_TOKEN:-root@pam!automation=automation-secret}
PVE_NODE: ${PVE_NODE:-pve01}
PVE_STORAGE: ${PVE_STORAGE:-local-lvm}
PVE_BRIDGE: ${PVE_BRIDGE:-vmbr0}
API_TIMEOUT: ${API_TIMEOUT:-120}
TEST_RESOURCE_PREFIX: ${TEST_RESOURCE_PREFIX:-hx}
PYTHONPATH: /workspace/pulumi-tests/pulumi:/workspace/pulumi-tests
CONTRACTS_ROOT: /workspace/contracts
SIMULATION_TIME_SCALE: "10"
# pulumi-proxmoxve requires https://… endpoints; keep TLS internal to this suite.
# Host-facing Compose lab stays plain HTTP on :8006 (K8s TLS = Ingress only).
PROXMOX_VE_ENDPOINT: https://tls-gateway:8443/
PROXMOX_VE_USERNAME: ${API_USER:-root@pam}
PROXMOX_VE_PASSWORD: ${API_PASSWORD:-secret}
PROXMOX_VE_INSECURE: "true"
x-simulator-env: &simulator-env
DATABASE_URL: postgresql://proxmox:proxmox@postgres:5432/proxmox_simulator
CONTRACT_SNAPSHOT: /app/contracts/pve-9.2.3.json
COMPATIBILITY_EVIDENCE: /app/evidence/pve-9.2.3.json
LOG_LEVEL: INFO
TICKET_SIGNING_KEY: development-only-signing-key-change-me
TASK_WORKER_CONCURRENCY: "4"
SIMULATION_TIME_SCALE: "10"
networks:
hx:
driver: bridge
volumes:
postgres-data:
pulumi-state:
services:
postgres:
image: postgres:17.5-bookworm
networks: [hx]
environment:
POSTGRES_DB: proxmox_simulator
POSTGRES_USER: proxmox
POSTGRES_PASSWORD: proxmox
healthcheck:
test: ["CMD-SHELL", "pg_isready -U proxmox -d proxmox_simulator"]
interval: 5s
timeout: 3s
retries: 10
start_period: 5s
volumes:
- postgres-data:/var/lib/postgresql/data
migrate:
build:
context: ../..
target: runtime
image: proxmox-api-simulator:0.1.0
networks: [hx]
environment:
<<: *simulator-env
depends_on:
postgres:
condition: service_healthy
entrypoint: ["python"]
command: ["-m", "app.db.migrate_cli"]
restart: "no"
simulator:
build:
context: ../..
target: runtime
image: proxmox-api-simulator:0.1.0
networks: [hx]
environment:
<<: *simulator-env
depends_on:
migrate:
condition: service_completed_successfully
command: ["--host", "0.0.0.0", "--port", "8006"]
healthcheck:
test:
[
"CMD",
"python",
"-c",
"import urllib.request; urllib.request.urlopen('http://127.0.0.1:8006/health/ready', timeout=2)",
]
interval: 5s
timeout: 3s
retries: 20
start_period: 15s
ports:
- "${HX_SIMULATOR_HOST_PORT:-127.0.0.1:18006}:8006"
# Internal-only HTTPS for pulumi-proxmoxve (provider rejects http:// endpoints).
# Not published on host :8006 — lab HTTPS for end users is K8s Ingress.
tls-gateway:
image: nginx:1.28.0-alpine
networks: [hx]
depends_on:
simulator:
condition: service_healthy
volumes:
- ../../docker/tls/gateway.conf:/etc/nginx/conf.d/default.conf:ro
- ../../docker/tls/server.crt:/etc/nginx/tls/server.crt:ro
- ../../docker/tls/server.key:/etc/nginx/tls/server.key:ro
healthcheck:
test:
[
"CMD-SHELL",
"wget -qO- --no-check-certificate https://127.0.0.1:8443/health/ready || exit 1",
]
interval: 5s
timeout: 3s
retries: 20
start_period: 5s
read_only: true
tmpfs:
- /var/cache/nginx
- /var/run
- /tmp
seed:
image: proxmox-api-simulator:0.1.0
networks: [hx]
environment:
<<: *simulator-env
SEED_PROFILE: ${SEED_PROFILE:-small}
depends_on:
simulator:
condition: service_healthy
entrypoint: ["python"]
command: ["-m", "app.simulation.seed_cli"]
restart: "no"
pulumi-runner:
build:
context: ..
dockerfile: docker/Dockerfile.pulumi-runner
image: hx-pulumi-runner:local
networks: [hx]
working_dir: /workspace/pulumi-tests
volumes:
- ../..:/workspace
- pulumi-state:/workspace/pulumi-tests/pulumi/.pulumi-state
environment:
<<: *test-env
PULUMI_CONFIG_PASSPHRASE: hx-test-passphrase
PULUMI_BACKEND_URL: file:///workspace/pulumi-tests/pulumi/.pulumi-state
depends_on:
seed:
condition: service_completed_successfully
tls-gateway:
condition: service_healthy
profiles: [pulumi]
entrypoint: ["python"]
command:
[
"pulumi/run_suite.py",
"--report-html",
"pulumi/reports/report.html",
"--report-json",
"pulumi/reports/results.json",
"--report-junit",
"pulumi/reports/junit.xml",
]
cleanup:
build:
context: ..
dockerfile: docker/Dockerfile.pulumi-runner
image: hx-pulumi-runner:local
networks: [hx]
working_dir: /workspace/pulumi-tests
volumes:
- ../..:/workspace
environment:
<<: *test-env
depends_on:
simulator:
condition: service_healthy
profiles: [cleanup]
entrypoint: ["python"]
command: ["tools/cleanup_test_resources.py"]
@@ -0,0 +1,3 @@
encryptionsalt: v1:XH46KobfYos=:v1:tQDcaIs9vVMnsfrW:b9Gi6Lg3VJtY1sIwqiebZuWZnUs2JQ==
config:
hx-lifecycle:smoke: "0"
@@ -0,0 +1,3 @@
encryptionsalt: v1:A8xu+B2cPSM=:v1:pyNe+LdXmI8S/syj:y55+cE0uCXlcqMIU9QH3lcy2VdM0/g==
config:
hx-lifecycle:smoke: "0"
@@ -0,0 +1,3 @@
encryptionsalt: v1:UzHEGVVb7Hg=:v1:wFaQ/0fPsClXx/FH:If0c+b7X1v2JlAsjgqw3VbiEixE4WQ==
config:
hx-lifecycle:smoke: "0"
@@ -0,0 +1,3 @@
encryptionsalt: v1:T7d8B72/cgw=:v1:JQTIJDuIttxYRG3P:dkgjqexvURaSUfheXRFBnnhk4gShAg==
config:
hx-lifecycle:smoke: "0"
@@ -0,0 +1,3 @@
encryptionsalt: v1:os3oQmc2a3w=:v1:ucPclEUo9WaE7yPN:HC3Vd0B6beGLxZVvOI9J9GO5BHdwCQ==
config:
hx-lifecycle:smoke: "0"
@@ -0,0 +1,3 @@
encryptionsalt: v1:cp9iAk4kDHE=:v1:F/B2BBJxO0k5ZUA5:DTlM5vdnm0ZCzhBdO0PeNwVdutV5yg==
config:
hx-lifecycle:smoke: "0"
@@ -0,0 +1,3 @@
encryptionsalt: v1:2+s2B58ijdg=:v1:1Kp9yO99F2P3ge1T:kGUur8A2sLlkUZ4oHzqi8FtnQaAGAg==
config:
hx-lifecycle:smoke: "0"
@@ -0,0 +1,3 @@
encryptionsalt: v1:iFaG0Vq94ro=:v1:tk3lV99BGpFbDimz:n1hcd1LrIDd5UOfPp3Zr3VBZjWd59Q==
config:
hx-lifecycle:smoke: "0"
@@ -0,0 +1,3 @@
encryptionsalt: v1:erx+K9e1Euc=:v1:AdiBWzU1hqyX6uoG:BybNBCL5DmFOBYT2bMZXpYcRPqgvow==
config:
hx-lifecycle:smoke: "0"
@@ -0,0 +1,3 @@
encryptionsalt: v1:72zO/F62ADI=:v1:1eWLQhjIh/pym+XQ:acoyK9LVJRwiQti6/ISvHEEY5b949Q==
config:
hx-lifecycle:smoke: "0"
@@ -0,0 +1,3 @@
encryptionsalt: v1:aJXNBbnMxrc=:v1:b3PW3ve+9nVeIQzL:8aERP/Q0sfJdxfzCpp036++baWcIPw==
config:
hx-lifecycle:smoke: "0"
@@ -0,0 +1,3 @@
encryptionsalt: v1:FncbMOoIrxc=:v1:SQhsQbyz402+sdJr:eLVKSgilS1OXn3h/akvFMUl9Hn/SQQ==
config:
hx-lifecycle:smoke: "0"
@@ -0,0 +1,3 @@
encryptionsalt: v1:i+BOW7Km3mY=:v1:+fV2zYTynoK3+SSe:bv6NNjPsbqdf+xpIPB8FjuglWAfEjQ==
config:
hx-lifecycle:smoke: "0"
@@ -0,0 +1,3 @@
name: hx-lifecycle
runtime: python
description: Compact Proxmox simulator lifecycle scenarios
@@ -0,0 +1,153 @@
"""Lifecycle suite driven primarily by pulumi-proxmoxve (BPG bridge).
Surface probing of every contract method stays in ``run_suite.py`` (HTTP).
This program covers provider-backed inventory + VM lifecycle with non-empty
output checks. Negative auth still uses httpx against the plain HTTP API.
"""
from __future__ import annotations
import hashlib
import os
from typing import Any
import pulumi
import pulumi_proxmoxve as proxmox
from pvelib.api import Pve
cfg = pulumi.Config()
smoke = (cfg.get("smoke") or os.environ.get("SMOKE_ONLY") or "0") == "1"
node = os.environ.get("PVE_NODE", "pve01")
endpoint = (
os.environ.get("PROXMOX_VE_ENDPOINT") or "https://tls-gateway:8443/"
).rstrip("/") + "/"
username = os.environ.get("PROXMOX_VE_USERNAME") or os.environ.get("API_USER", "root@pam")
password = os.environ.get("PROXMOX_VE_PASSWORD") or os.environ.get("API_PASSWORD", "secret")
insecure = (os.environ.get("PROXMOX_VE_INSECURE") or "true").lower() in {
"1",
"true",
"yes",
}
def _vmid(tag: str) -> int:
digest = hashlib.sha1(f"hx-pve-{tag}-{os.getpid()}".encode()).hexdigest()
return 710000 + (int(digest[:6], 16) % 90000)
def _require(value: Any, label: str) -> Any:
if value is None:
raise AssertionError(f"{label}: value is None")
if isinstance(value, (str, bytes)) and not str(value).strip():
raise AssertionError(f"{label}: empty string")
if isinstance(value, (list, tuple, set, dict)) and len(value) == 0:
raise AssertionError(f"{label}: empty {type(value).__name__}")
return value
def _datastore_ids(datastores: Any) -> list[str]:
items = getattr(datastores, "datastores", None) or datastores or []
ids: list[str] = []
for item in items:
if isinstance(item, dict):
value = item.get("id") or item.get("datastore_id") or item.get("storage")
else:
value = (
getattr(item, "id", None)
or getattr(item, "datastore_id", None)
or getattr(item, "storage", None)
)
if value not in (None, ""):
ids.append(str(value))
return ids
provider = proxmox.Provider(
"proxmoxve",
endpoint=endpoint,
username=username,
password=password,
insecure=insecure,
)
prov_opts = pulumi.ResourceOptions(provider=provider)
invoke_opts = pulumi.InvokeOptions(provider=provider)
# --- Provider data sources (inventory) ---
version = proxmox.get_version_legacy(opts=invoke_opts)
identity = version.version or version.release or version.repository_id
_require(identity, "get_version_legacy identity")
nodes = proxmox.get_nodes_legacy(opts=invoke_opts)
node_names = list(_require(nodes.names, "get_nodes_legacy.names"))
if node not in node_names:
raise AssertionError(f"expected node {node!r} in {node_names!r}")
stores = proxmox.get_datastores_legacy(node_name=node, opts=invoke_opts)
store_ids = _require(_datastore_ids(stores), "get_datastores_legacy ids")
# --- Negative auth via HTTP ---
_bad = Pve(authenticate=False)
try:
try:
_bad.login(password="definitely-wrong")
raise AssertionError("expected login failure for bad password")
except AssertionError:
raise
except Exception:
pass
finally:
_bad.close()
# --- VM via pulumi-proxmoxve (mirrors examples/terraform cookbook shape) ---
vm_id = _vmid("qemu")
vm_name = f"hxpu{vm_id}"
vm = proxmox.VmLegacy(
"hx-vm",
name=vm_name,
node_name=node,
vm_id=vm_id,
started=False,
on_boot=False,
agent={"enabled": False},
cpu={"cores": 1 if smoke else 2},
memory={"dedicated": 512 if smoke else 1024},
opts=prov_opts,
)
def _check_vm(args: list[Any]) -> dict[str, Any]:
name, vmid, node_name = args
_require(name, "VmLegacy.name")
_require(vmid, "VmLegacy.vm_id")
_require(node_name, "VmLegacy.node_name")
if str(name) != vm_name:
raise AssertionError(f"name mismatch {name!r} != {vm_name!r}")
if int(vmid) != vm_id:
raise AssertionError(f"vmid mismatch {vmid!r} != {vm_id!r}")
if str(node_name) != node:
raise AssertionError(f"node mismatch {node_name!r} != {node!r}")
return {"name": str(name), "vm_id": int(vmid), "node": str(node_name)}
vm_checked = pulumi.Output.all(vm.name, vm.vm_id, vm.node_name).apply(_check_vm)
scenario_ids = [
"provider_version",
"provider_nodes",
"provider_datastores",
"auth_bad_password",
"vm_lifecycle_provider",
]
pulumi.export(
"inventory",
{
"version": str(identity),
"nodes": node_names,
"datastores": store_ids,
},
)
pulumi.export("vm", vm_checked)
pulumi.export("scenario_ids", scenario_ids)
+5
View File
@@ -0,0 +1,5 @@
"""Pulumi-side Proxmox helpers (httpx)."""
from .api import Pve
__all__ = ["Pve"]
+132
View File
@@ -0,0 +1,132 @@
"""Minimal Proxmox API client for Pulumi programs and surface probes."""
from __future__ import annotations
import os
import time
import urllib.parse
from typing import Any
import httpx
class Pve:
def __init__(self, *, authenticate: bool = True) -> None:
root = os.environ.get("API_URL", "http://simulator:8006").rstrip("/")
self.root = root
self.base = root + "/api2/json"
self.node = os.environ.get("PVE_NODE", "pve01")
self.storage = os.environ.get("PVE_STORAGE", "local-lvm")
self.bridge = os.environ.get("PVE_BRIDGE", "vmbr0")
self._c = httpx.Client(base_url=self.base, timeout=120.0)
self._h: dict[str, str] = {}
if authenticate:
self.login()
def close(self) -> None:
self._c.close()
def __enter__(self) -> Pve:
return self
def __exit__(self, *args: object) -> None:
self.close()
def login(
self,
username: str | None = None,
password: str | None = None,
) -> dict[str, Any]:
r = self._c.post(
"/access/ticket",
data={
"username": username or os.environ.get("API_USER", "root@pam"),
"password": password or os.environ.get("API_PASSWORD", "secret"),
},
)
r.raise_for_status()
data = r.json()["data"]
self._h = {
"Cookie": f"PVEAuthCookie={data['ticket']}",
"CSRFPreventionToken": data["CSRFPreventionToken"],
}
return data
def clear_auth(self) -> None:
self._h = {}
def req(self, method: str, path: str, **kw: Any) -> Any:
r = self._c.request(method, path, headers=self._h, **kw)
if r.status_code >= 400:
raise RuntimeError(f"{method} {path} -> {r.status_code} {r.text}")
return r.json().get("data")
def wait(self, upid: str) -> None:
enc = urllib.parse.quote(upid, safe="")
for _ in range(600):
st = self.req("GET", f"/nodes/{self.node}/tasks/{enc}/status")
if st.get("status") == "stopped":
if st.get("exitstatus") != "OK":
raise RuntimeError(st)
return
time.sleep(0.2)
raise TimeoutError(upid)
def create_vm(self, vmid: int, name: str) -> None:
upid = self.req(
"POST",
f"/nodes/{self.node}/qemu",
data={
"vmid": vmid,
"name": name,
"cores": 1,
"memory": 512,
"scsi0": f"{self.storage}:vm-{vmid}-disk-0,size=8G",
"net0": f"virtio,bridge={self.bridge}",
},
)
self.wait(upid)
def delete_vm(self, vmid: int) -> None:
try:
upid = self.req("POST", f"/nodes/{self.node}/qemu/{vmid}/status/stop")
if isinstance(upid, str):
self.wait(upid)
except Exception:
pass
try:
upid = self.req("DELETE", f"/nodes/{self.node}/qemu/{vmid}")
if isinstance(upid, str):
self.wait(upid)
except Exception:
pass
def create_lxc(self, vmid: int, name: str) -> None:
upid = self.req(
"POST",
f"/nodes/{self.node}/lxc",
data={
"vmid": vmid,
"hostname": name,
"ostemplate": "local:vztmpl/example.tar.zst",
"rootfs": f"{self.storage}:8",
"memory": 512,
"cores": 1,
"net0": f"name=eth0,bridge={self.bridge},ip=dhcp",
},
)
self.wait(upid)
def delete_lxc(self, vmid: int) -> None:
try:
upid = self.req("POST", f"/nodes/{self.node}/lxc/{vmid}/status/stop")
if isinstance(upid, str):
self.wait(upid)
except Exception:
pass
try:
upid = self.req("DELETE", f"/nodes/{self.node}/lxc/{vmid}")
if isinstance(upid, str):
self.wait(upid)
except Exception:
pass
+353
View File
@@ -0,0 +1,353 @@
"""Sync contract surface probe against a live simulator (majors 69).
Mirrors ``app/surface_probe.py`` classification and path/body synthesis, but
talks HTTP to ``API_URL`` instead of an in-process ASGI app.
"""
from __future__ import annotations
import json
import os
import re
import time
from collections import Counter, defaultdict
from pathlib import Path
from typing import Any
from urllib.parse import quote
import httpx
# Bundled revisions — keep in sync with app/web/contract_catalog.py
MAJOR_REVISIONS: dict[int, tuple[str, str]] = {
6: ("6.4-15", "96cd7121e75cdb3efd58f79ca988f6b235a2f28e6f7eae276ae243f65d8a6724"),
7: ("7.4-16", "2cf632fa6ea4939ca9cb7998ade688150db25b0684600f53ac0ca95730f1d99f"),
8: ("8.4.5", "fce6db0a784b3a9b447895895fc6ff4b4437c2dce82e5f3db99227af217726fa"),
9: ("9.2.3", "e61a893e996d05d376579226e7dfbedbcfce8b71787adacffbc557e6e35901c1"),
}
_PATH_RE = re.compile(r"\{([^{}]+)\}")
_FORBIDDEN = re.compile(
r"not supported in the emulator|not implemented in the simulator|"
r"handler pending for this contract method|is not supported in the (emulator|simulator)",
re.I,
)
_PATH_PARAM_EXAMPLES: dict[str, object] = {
"node": "pve01",
"vmid": 100,
"storage": "local",
"pool": "testpool",
"userid": "root@pam",
"tokenid": "automation",
"realm": "pam",
"group": "admins",
"role": "Administrator",
"upid": "UPID:pve01:00000001:00000001:65000001:qmstart:100:root@pam:",
"snapname": "snap1",
"volume": "local:100/vm-100-disk-0.qcow2",
"disk": "scsi0",
"iface": "net0",
"key": "cpu",
"digest": "00000000",
"name": "example",
}
_EXTRA_PATH: dict[str, object] = {
"groupid": "admins",
"roleid": "Administrator",
"zone": "localnet",
"vnet": "vnet0",
"subnet": "10.0.0.0-24",
"controller": "evpn1",
"dns": "dns1",
"ipam": "pve",
"flag": "noout",
"osdid": "0",
"monid": "0",
"id": "example",
"cputype": "custom1",
"pci-id-or-mapping": "0000:00:1f.0",
"rule": "rule1",
"sid": "vm:100",
"pos": "0",
"cidr": "10.0.0.0/24",
"tokenid": "automation",
"fabric_id": "fab1",
"node_id": "pve01",
"url_seq": "1",
"route-map-id": "rm1",
"order": "10",
"userid": "root@pam",
"realm": "pam",
"name": "example",
"plugin": "example",
"target": "example",
}
CRITICAL_BUCKETS = frozenset(
{"unimplemented_501", "unsupported_message", "server_5xx", "exception"}
)
def contracts_root() -> Path:
env = os.environ.get("CONTRACTS_ROOT")
if env:
return Path(env)
# Runner mounts repo at /workspace; local runs use repo-relative path.
here = Path(__file__).resolve()
candidates = [
Path("/workspace/contracts"),
here.parents[3] / "contracts", # pulumi-tests/pulumi/pvelib → repo
Path.cwd() / "contracts",
Path.cwd().parent / "contracts",
]
for path in candidates:
if path.is_dir():
return path
raise FileNotFoundError("contracts/ directory not found; set CONTRACTS_ROOT")
def load_snapshot(major: int) -> dict[str, Any]:
version, revision = MAJOR_REVISIONS[major]
path = contracts_root() / revision / "snapshot.json"
data = json.loads(path.read_text(encoding="utf-8"))
if data.get("source_version") != version:
# Still usable; keep declared version from manifest pairing.
pass
return data
def _path_value(name: str) -> str:
value = _PATH_PARAM_EXAMPLES.get(name)
if value is None:
value = _EXTRA_PATH.get(name, "example")
return str(value)
def render_path(template: str) -> str:
def replace(match: re.Match[str]) -> str:
return quote(str(_path_value(match.group(1))), safe="@._-")
return _PATH_RE.sub(replace, template)
def _schema_example(schema: dict[str, Any] | None, *, name: str | None = None) -> Any:
if not schema:
return "example"
if schema.get("default") is not None:
return schema["default"]
enum = schema.get("enum") or []
if enum:
return enum[0]
if name is not None:
hinted = _PATH_PARAM_EXAMPLES.get(name)
if hinted is not None:
return hinted
typ = schema.get("type")
if typ == "array":
items = schema.get("items")
return [_schema_example(items)] if items else []
if typ == "object":
props = schema.get("properties") or {}
return {
key: _schema_example(defn, name=key)
for key, defn in props.items()
if not (isinstance(defn, dict) and defn.get("optional"))
}
if typ == "boolean":
return False
if typ == "integer":
minimum = schema.get("minimum")
return int(minimum) if minimum is not None else 1
if typ == "number":
minimum = schema.get("minimum")
return float(minimum) if minimum is not None else 1.0
return "example"
def body_for(method: dict[str, Any], path_template: str) -> dict[str, Any]:
path_names = set(_PATH_RE.findall(path_template))
payload: dict[str, Any] = {}
for parameter in method.get("parameters") or []:
name = parameter.get("name")
if not name or name in path_names:
continue
definition = parameter.get("definition") or {}
if definition.get("optional"):
continue
payload[name] = _schema_example(definition, name=name)
return payload
def classify(status: int, text: str) -> str:
if _FORBIDDEN.search(text or ""):
return "unsupported_message"
if status == 501:
return "unimplemented_501"
if 200 <= status < 300:
return "success_2xx"
if status in {401, 403}:
return "auth_401_403"
if status in {400, 404, 405, 409, 412, 422, 423}:
return "client_4xx"
if status >= 500:
return "server_5xx"
return f"other_{status}"
def probe_major(
client: httpx.Client,
csrf: str,
major: int,
snapshot: dict[str, Any],
) -> dict[str, Any]:
started = time.monotonic()
apply = client.post("/ui/api/contract/apply", params={"major": major})
apply.raise_for_status()
applied = apply.json()
by_verb: dict[str, Counter[str]] = defaultdict(Counter)
failures: list[dict[str, Any]] = []
method_results: list[dict[str, Any]] = []
methods: list[tuple[str, dict[str, Any]]] = [
(path["path"], method)
for path in snapshot.get("paths") or []
for method in path.get("methods") or []
]
order = {"GET": 0, "PUT": 1, "POST": 2, "DELETE": 3}
methods.sort(key=lambda item: (order.get(str(item[1].get("verb", "")).upper(), 9), item[0]))
for path_template, method in methods:
verb = str(method.get("verb", "")).upper()
url = f"/api2/json{render_path(path_template)}"
headers = {"CSRFPreventionToken": csrf} if verb != "GET" else {}
params = body_for(method, path_template)
try:
if verb == "GET":
response = client.get(url, headers=headers, params=params or None)
elif verb == "PUT":
response = client.put(url, data=params or {}, headers=headers)
elif verb == "POST":
response = client.post(url, data=params or {}, headers=headers)
elif verb == "DELETE":
# Proxmox accepts delete identifiers as form or query params.
response = client.request(
"DELETE", url, data=params or {}, headers=headers
)
else:
by_verb[verb or "UNKNOWN"]["exception"] += 1
item = {
"verb": verb or "UNKNOWN",
"path": path_template,
"error": f"unsupported HTTP verb {verb!r}",
"bucket": "exception",
"ok": False,
}
failures.append(item)
method_results.append(item)
continue
except Exception as exc: # noqa: BLE001
by_verb[verb]["exception"] += 1
item = {
"verb": verb,
"path": path_template,
"error": str(exc)[:200],
"bucket": "exception",
"ok": False,
}
failures.append(item)
method_results.append(item)
continue
text = response.text
bucket = classify(response.status_code, text)
by_verb[verb][bucket] += 1
ok = bucket not in CRITICAL_BUCKETS
item = {
"verb": verb,
"path": path_template,
"status": response.status_code,
"bucket": bucket,
"ok": ok,
}
if not ok:
item["body"] = text[:240]
failures.append(item)
method_results.append(item)
version, _ = MAJOR_REVISIONS[major]
declared = int(snapshot.get("method_count") or len(method_results))
critical = len(failures)
success_2xx = sum(c.get("success_2xx", 0) for c in by_verb.values())
probed = len(method_results)
empty_reasons: list[str] = []
if not isinstance(applied, dict) or not applied:
empty_reasons.append("apply response empty")
elif applied.get("ok") is False:
empty_reasons.append(f"apply not ok: {applied!r}")
if declared <= 0:
empty_reasons.append("declared method_count is 0")
if probed <= 0:
empty_reasons.append("no methods probed")
if probed != declared:
empty_reasons.append(
f"incomplete coverage: probed {probed} of {declared} declared methods"
)
if success_2xx <= 0:
empty_reasons.append("success_2xx count is 0")
if empty_reasons:
for reason in empty_reasons:
failures.append(
{
"verb": "META",
"path": f"major/{major}",
"bucket": "exception",
"ok": False,
"error": reason,
}
)
critical = len(failures)
return {
"major": major,
"version": snapshot.get("source_version") or version,
"apply": applied,
"declared": declared,
"probed": probed,
"by_verb": {verb: dict(counter) for verb, counter in by_verb.items()},
"success_2xx": success_2xx,
"client_4xx": sum(
c.get("client_4xx", 0) + c.get("auth_401_403", 0) for c in by_verb.values()
),
"failure_count": critical,
"failures": failures,
"ok": critical == 0,
"time": time.monotonic() - started,
"methods": method_results,
}
def run_surface(
*,
majors: tuple[int, ...] = (6, 7, 8, 9),
api_url: str | None = None,
) -> list[dict[str, Any]]:
base = (api_url or os.environ.get("API_URL", "http://simulator:8006")).rstrip("/")
user = os.environ.get("API_USER", "root@pam")
password = os.environ.get("API_PASSWORD", "secret")
results: list[dict[str, Any]] = []
with httpx.Client(base_url=base, timeout=60.0) as client:
# Prefer env credentials over hard-coded login body.
ticket = client.post(
"/api2/json/access/ticket",
data={"username": user, "password": password},
)
ticket.raise_for_status()
data = ticket.json()["data"]
client.cookies.set("PVEAuthCookie", data["ticket"])
csrf = str(data["CSRFPreventionToken"])
for major in majors:
snapshot = load_snapshot(major)
results.append(probe_major(client, csrf, major, snapshot))
return results
+265
View File
@@ -0,0 +1,265 @@
"""Render HTML and optional JUnit reports for the Pulumi HX suite."""
from __future__ import annotations
import html
import json
from pathlib import Path
from typing import Any
from xml.etree.ElementTree import Element, ElementTree, SubElement
def write_json(payload: dict[str, Any], path: Path) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
# Drop per-method lists from majors for a lighter default JSON? Keep full for debugging.
path.write_text(json.dumps(payload, indent=2, ensure_ascii=False), encoding="utf-8")
def write_junit(payload: dict[str, Any], path: Path) -> None:
surface = payload.get("surface") or []
scenarios = payload.get("scenarios") or []
cases: list[dict[str, Any]] = []
for major in surface:
cases.append(
{
"classname": "surface",
"name": f"PVE {major.get('version')} major={major.get('major')}",
"time": major.get("time") or 0,
"ok": bool(major.get("ok")),
"error": _surface_error(major),
}
)
for item in scenarios:
cases.append(
{
"classname": "lifecycle",
"name": item.get("id") or item.get("name") or "lifecycle",
"time": item.get("time") or 0,
"ok": bool(item.get("ok")),
"error": item.get("error") or "",
}
)
suite = Element(
"testsuite",
name="pulumi-hx",
tests=str(len(cases)),
failures=str(sum(1 for c in cases if not c["ok"])),
time=f"{sum(float(c['time']) for c in cases):.3f}",
)
for item in cases:
case = SubElement(
suite,
"testcase",
classname=str(item["classname"]),
name=str(item["name"]),
time=f"{float(item['time']):.3f}",
)
if not item["ok"]:
failure = SubElement(case, "failure", message=str(item["error"])[:500])
failure.text = str(item["error"])
path.parent.mkdir(parents=True, exist_ok=True)
ElementTree(suite).write(path, encoding="utf-8", xml_declaration=True)
def _surface_error(major: dict[str, Any]) -> str:
fails = major.get("failures") or []
if not fails:
return ""
parts = [
f"{f.get('verb')} {f.get('path')} -> {f.get('bucket')} {f.get('status', '')}"
for f in fails[:20]
]
return f"{len(fails)} critical: " + "; ".join(parts)
def write_html(payload: dict[str, Any], path: Path) -> None:
surface = payload.get("surface") or []
scenarios = payload.get("scenarios") or []
coverage = payload.get("coverage") or {}
ok = bool(payload.get("ok"))
elapsed = float(payload.get("elapsed") or 0)
coverage_by_major = coverage.get("by_major") or []
coverage_rows = []
for item in coverage_by_major:
complete = bool(item.get("complete"))
coverage_rows.append(
"<tr>"
f"<td>{html.escape(str(item.get('major')))}</td>"
f"<td>{html.escape(str(item.get('version')))}</td>"
f"<td>{html.escape(str(item.get('declared')))}</td>"
f"<td>{html.escape(str(item.get('probed')))}</td>"
f"<td>{html.escape(str(item.get('critical')))}</td>"
f"<td class='{'ok' if complete else 'fail'}'>"
f"{'yes' if complete else 'no'}</td>"
"</tr>"
)
declared_total = int(coverage.get("declared_total") or 0)
probed_total = int(coverage.get("probed_total") or 0)
critical_total = int(coverage.get("critical_total") or 0)
coverage_ok = bool(coverage.get("ok")) if coverage_by_major else True
majs = coverage.get("majors") or []
if len(majs) >= 2:
majors_label = f"{majs[0]}{majs[-1]}"
elif majs:
majors_label = str(majs[0])
else:
majors_label = ""
surface_rows = []
failure_rows = []
for major in surface:
surface_rows.append(
"<tr>"
f"<td>{html.escape(str(major.get('major')))}</td>"
f"<td>{html.escape(str(major.get('version')))}</td>"
f"<td>{html.escape(str(major.get('declared')))}</td>"
f"<td>{html.escape(str(major.get('probed')))}</td>"
f"<td>{html.escape(str(major.get('success_2xx')))}</td>"
f"<td>{html.escape(str(major.get('client_4xx')))}</td>"
f"<td class='{'ok' if major.get('ok') else 'fail'}'>"
f"{html.escape(str(major.get('failure_count')))}</td>"
f"<td>{float(major.get('time') or 0):.1f}s</td>"
"</tr>"
)
for fail in major.get("failures") or []:
failure_rows.append(
"<tr>"
f"<td>{html.escape(str(major.get('version')))}</td>"
f"<td>{html.escape(str(fail.get('verb')))}</td>"
f"<td><code>{html.escape(str(fail.get('path')))}</code></td>"
f"<td>{html.escape(str(fail.get('bucket')))}</td>"
f"<td>{html.escape(str(fail.get('status', fail.get('error', ''))))}</td>"
f"<td><code>{html.escape(str(fail.get('body', ''))[:200])}</code></td>"
"</tr>"
)
scenario_rows = []
for item in scenarios:
scenario_rows.append(
"<tr>"
f"<td>{html.escape(str(item.get('id') or item.get('name')))}</td>"
f"<td class='{'ok' if item.get('ok') else 'fail'}'>"
f"{'PASS' if item.get('ok') else 'FAIL'}</td>"
f"<td>{float(item.get('time') or 0):.2f}s</td>"
f"<td><code>{html.escape(str(item.get('error') or ''))}</code></td>"
"</tr>"
)
status = "PASS" if ok else "FAIL"
status_class = "ok" if ok else "fail"
coverage_status = "complete" if coverage_ok else "incomplete"
coverage_class = "ok" if coverage_ok else "fail"
doc = f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<title>Pulumi HX suite report</title>
<style>
:root {{
--bg: #0f1419; --panel: #1a222c; --text: #e7ecf1; --muted: #9aa7b5;
--ok: #3dd68c; --fail: #ff6b6b; --line: #2a3542;
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
--sans: "Segoe UI", system-ui, sans-serif;
}}
body {{ margin: 0; font-family: var(--sans); background: var(--bg); color: var(--text); }}
main {{ max-width: 1100px; margin: 0 auto; padding: 2rem 1.25rem 4rem; }}
h1 {{ font-size: 1.6rem; margin: 0 0 .4rem; }}
h2 {{ font-size: 1.15rem; margin: 2rem 0 .75rem; }}
p.lead {{ color: var(--muted); margin: 0 0 1.5rem; }}
.badge {{ display: inline-block; padding: .2rem .6rem; border-radius: .35rem;
font-weight: 700; letter-spacing: .02em; }}
.badge.ok {{ background: color-mix(in srgb, var(--ok) 25%, transparent); color: var(--ok); }}
.badge.fail {{ background: color-mix(in srgb, var(--fail) 25%, transparent); color: var(--fail); }}
.cards {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: .75rem; margin: 1rem 0 1.5rem; }}
.card {{ background: var(--panel); border: 1px solid var(--line); border-radius: .5rem;
padding: .9rem 1rem; }}
.card .label {{ color: var(--muted); font-size: .8rem; }}
.card .value {{ font-size: 1.35rem; font-weight: 700; margin-top: .25rem; }}
table {{ width: 100%; border-collapse: collapse; background: var(--panel);
border: 1px solid var(--line); border-radius: .5rem; overflow: hidden; }}
th, td {{ text-align: left; padding: .55rem .7rem; border-bottom: 1px solid var(--line);
vertical-align: top; font-size: .92rem; }}
th {{ color: var(--muted); font-weight: 600; }}
tr:last-child td {{ border-bottom: 0; }}
td.ok {{ color: var(--ok); font-weight: 600; }}
td.fail {{ color: var(--fail); font-weight: 600; }}
code {{ font-family: var(--mono); font-size: .85em; }}
.empty {{ color: var(--muted); font-style: italic; }}
</style>
</head>
<body>
<main>
<h1>Pulumi HX suite report</h1>
<p class="lead">Contract surface probe (PVE majors 69) + lifecycle scenarios</p>
<span class="badge {status_class}">{status}</span>
<div class="cards">
<div class="card"><div class="label">Elapsed</div>
<div class="value">{elapsed:.1f}s</div></div>
<div class="card"><div class="label">Majors</div>
<div class="value">{len(surface)}</div></div>
<div class="card"><div class="label">Critical surface fails</div>
<div class="value">{sum(int(m.get('failure_count') or 0) for m in surface)}</div></div>
<div class="card"><div class="label">Scenarios</div>
<div class="value">{sum(1 for s in scenarios if s.get('ok'))}/{len(scenarios)}</div></div>
</div>
<h2>Full contract coverage</h2>
<p class="lead">
{probed_total}/{declared_total} methods across majors {html.escape(majors_label)}
(critical={critical_total}) —
<span class="{coverage_class}">{coverage_status}</span>
</p>
<table>
<thead><tr>
<th>Major</th><th>Version</th><th>Declared</th><th>Probed</th>
<th>Critical</th><th>declared==probed</th>
</tr></thead>
<tbody>
{''.join(coverage_rows) or '<tr><td colspan="6" class="empty">No coverage data</td></tr>'}
<tr>
<td colspan="2"><strong>Total</strong></td>
<td><strong>{declared_total}</strong></td>
<td><strong>{probed_total}</strong></td>
<td><strong>{critical_total}</strong></td>
<td class="{coverage_class}"><strong>{'yes' if coverage_ok else 'no'}</strong></td>
</tr>
</tbody>
</table>
<h2>Surface by major</h2>
<table>
<thead><tr>
<th>Major</th><th>Version</th><th>Declared</th><th>Probed</th>
<th>2xx</th><th>4xx/auth</th><th>Critical</th><th>Time</th>
</tr></thead>
<tbody>
{''.join(surface_rows) or '<tr><td colspan="8" class="empty">No surface results</td></tr>'}
</tbody>
</table>
<h2>Critical surface failures</h2>
<table>
<thead><tr>
<th>Version</th><th>Verb</th><th>Path</th><th>Bucket</th><th>Status</th><th>Body</th>
</tr></thead>
<tbody>
{''.join(failure_rows) or '<tr><td colspan="6" class="empty">None</td></tr>'}
</tbody>
</table>
<h2>Lifecycle scenarios</h2>
<table>
<thead><tr><th>ID</th><th>Result</th><th>Time</th><th>Error</th></tr></thead>
<tbody>
{''.join(scenario_rows) or '<tr><td colspan="4" class="empty">No scenarios</td></tr>'}
</tbody>
</table>
</main>
</body>
</html>
"""
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(doc, encoding="utf-8")
+273
View File
@@ -0,0 +1,273 @@
#!/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())
@@ -0,0 +1,43 @@
#!/usr/bin/env python3
"""Delete leftover test guests whose names start with the configured prefix."""
from __future__ import annotations
import os
import sys
from pathlib import Path
HERE = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(HERE / "pulumi"))
from pvelib.api import Pve # noqa: E402
def main() -> int:
prefix = os.environ.get("TEST_RESOURCE_PREFIX", "hx")
deleted = 0
with Pve() as pve:
for kind, delete in (
("qemu", pve.delete_vm),
("lxc", pve.delete_lxc),
):
items = pve.req("GET", f"/nodes/{pve.node}/{kind}")
if not isinstance(items, list):
continue
for item in items:
name = str(item.get("name") or item.get("hostname") or "")
vmid = int(item["vmid"])
if not name.startswith(prefix):
continue
try:
delete(vmid)
deleted += 1
print(f"deleted {kind} vmid={vmid} name={name}")
except Exception as exc: # noqa: BLE001
print(f"skip {kind} vmid={vmid}: {exc}", file=sys.stderr)
print(f"cleanup complete: deleted={deleted}")
return 0
if __name__ == "__main__":
raise SystemExit(main())