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,5 @@
|
||||
"""Pulumi-side Proxmox helpers (httpx)."""
|
||||
|
||||
from .api import Pve
|
||||
|
||||
__all__ = ["Pve"]
|
||||
@@ -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
|
||||
@@ -0,0 +1,353 @@
|
||||
"""Sync contract surface probe against a live simulator (majors 6–9).
|
||||
|
||||
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
|
||||
Reference in New Issue
Block a user