baa1f58ad0
Stabilize node status, cluster-wide Ceph OSD ids, and QEMU cpu utilization so status/list no longer 500 on model strings; align pulumi defaults to pve1; add bilingual testing docs with the 2026-07-18 ci-all + pulumi-tests pass.
133 lines
4.1 KiB
Python
133 lines
4.1 KiB
Python
"""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", "pve1")
|
|
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
|