Initial commit: stateful OpenStack API laboratory simulator.
Ship Keystone auth, multi-service handlers (Yoga→Dalmatian), Compose/Helm packaging, API contract packs, and pytest/Pulumi coverage labs.
This commit is contained in:
@@ -0,0 +1,161 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Write-path conformance sample: create → show → delete across core services."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from uuid import uuid4
|
||||
|
||||
HOST = os.environ.get("OS_HOST", "127.0.0.1")
|
||||
KEYSTONE = sys.argv[1] if len(sys.argv) > 1 else f"http://{HOST}:5000"
|
||||
|
||||
|
||||
def _u(port: int, path: str) -> str:
|
||||
return f"http://{HOST}:{port}{path}"
|
||||
|
||||
|
||||
def request(method: str, url: str, *, data: dict | None = None, token: str | None = None):
|
||||
body = None if data is None else json.dumps(data).encode()
|
||||
headers = {"Accept": "application/json"}
|
||||
if data is not None:
|
||||
headers["Content-Type"] = "application/json"
|
||||
if token:
|
||||
headers["X-Auth-Token"] = token
|
||||
req = urllib.request.Request(url, data=body, headers=headers, method=method)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=20) as res:
|
||||
raw = res.read().decode()
|
||||
return res.status, dict(res.headers), json.loads(raw) if raw else None
|
||||
except urllib.error.HTTPError as exc:
|
||||
raw = exc.read().decode()
|
||||
try:
|
||||
parsed = json.loads(raw) if raw else None
|
||||
except json.JSONDecodeError:
|
||||
parsed = raw
|
||||
return exc.code, dict(exc.headers), parsed
|
||||
except urllib.error.URLError as exc:
|
||||
return 0, {}, {"error": str(exc.reason)}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
# Allow full URL host override via argv keystone URL.
|
||||
global HOST
|
||||
if KEYSTONE.startswith("http"):
|
||||
# http://api-gateway:5000 → api-gateway
|
||||
from urllib.parse import urlparse
|
||||
|
||||
parsed = urlparse(KEYSTONE)
|
||||
if parsed.hostname:
|
||||
HOST = parsed.hostname
|
||||
|
||||
auth = {
|
||||
"auth": {
|
||||
"identity": {
|
||||
"methods": ["password"],
|
||||
"password": {
|
||||
"user": {"name": "admin", "domain": {"name": "Default"}, "password": "secret"}
|
||||
},
|
||||
},
|
||||
"scope": {"project": {"name": "demo", "domain": {"name": "Default"}}},
|
||||
}
|
||||
}
|
||||
status, headers, body = request("POST", f"{KEYSTONE.rstrip('/')}/v3/auth/tokens", data=auth)
|
||||
token = headers.get("X-Subject-Token") or headers.get("x-subject-token")
|
||||
if not token and isinstance(body, dict):
|
||||
token = (body.get("token") or {}).get("id")
|
||||
if status != 201 or not token:
|
||||
print("auth failed", status, body)
|
||||
return 1
|
||||
project_id = (body or {}).get("token", {}).get("project", {}).get("id")
|
||||
failed = 0
|
||||
|
||||
name = f"conf-{uuid4().hex[:8]}"
|
||||
st, _, created = request(
|
||||
"POST",
|
||||
_u(9311, "/v1/secrets"),
|
||||
token=token,
|
||||
data={"secret": {"name": name, "payload_content_type": "text/plain"}},
|
||||
)
|
||||
print("barbican.create", st)
|
||||
sid = ((created or {}).get("secret") or {}).get("id")
|
||||
if st >= 400 or not sid:
|
||||
failed += 1
|
||||
else:
|
||||
st, _, _ = request("GET", _u(9311, f"/v1/secrets/{sid}"), token=token)
|
||||
print("barbican.show", st)
|
||||
if st >= 400:
|
||||
failed += 1
|
||||
st, _, _ = request("DELETE", _u(9311, f"/v1/secrets/{sid}"), token=token)
|
||||
print("barbican.delete", st)
|
||||
if st >= 400 and st != 204:
|
||||
failed += 1
|
||||
|
||||
st, _, sgs = request("GET", _u(9696, "/v2.0/security-groups"), token=token)
|
||||
sg_id = ((sgs or {}).get("security_groups") or [{}])[0].get("id")
|
||||
if sg_id:
|
||||
st, _, rule = request(
|
||||
"POST",
|
||||
_u(9696, "/v2.0/security-group-rules"),
|
||||
token=token,
|
||||
data={
|
||||
"security_group_rule": {
|
||||
"security_group_id": sg_id,
|
||||
"direction": "ingress",
|
||||
"protocol": "tcp",
|
||||
"port_range_min": 8080,
|
||||
"port_range_max": 8080,
|
||||
"ethertype": "IPv4",
|
||||
"remote_ip_prefix": "0.0.0.0/0",
|
||||
}
|
||||
},
|
||||
)
|
||||
print(
|
||||
"neutron.sg_rule.create", st, ((rule or {}).get("security_group_rule") or {}).get("id")
|
||||
)
|
||||
if st >= 400:
|
||||
failed += 1
|
||||
|
||||
st, _, servers = request("GET", _u(8774, "/v2.1/servers"), token=token)
|
||||
server_id = ((servers or {}).get("servers") or [{}])[0].get("id")
|
||||
if server_id:
|
||||
req = urllib.request.Request(
|
||||
_u(8774, f"/v2.1/servers/{server_id}/action"),
|
||||
data=json.dumps({"os-getConsoleOutput": {"length": 20}}).encode(),
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"X-Auth-Token": token,
|
||||
"OpenStack-API-Version": "compute 2.79",
|
||||
},
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=15) as res:
|
||||
print("nova.console", res.status)
|
||||
except urllib.error.HTTPError as exc:
|
||||
print("nova.console", exc.code)
|
||||
failed += 1
|
||||
|
||||
if project_id:
|
||||
st, _, stacks = request("GET", _u(8004, f"/v1/{project_id}/stacks"), token=token)
|
||||
print("heat.stacks", st, len((stacks or {}).get("stacks") or []))
|
||||
if st >= 400:
|
||||
failed += 1
|
||||
|
||||
st, _, contracts = request("GET", _u(5000, "/ui/api/openstack/contracts"))
|
||||
print("ui.contracts", st, (contracts or {}).get("active", {}).get("operation_count"))
|
||||
if st != 200 or not (contracts or {}).get("active", {}).get("operation_count"):
|
||||
failed += 1
|
||||
|
||||
if failed:
|
||||
print(f"FAILED checks={failed}")
|
||||
return 1
|
||||
print("OK conformance write-paths")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,161 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Full-surface smoke: Keystone token → every default-port OpenStack service."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
HOST = os.environ.get("OS_HOST", "127.0.0.1")
|
||||
KEYSTONE = sys.argv[1] if len(sys.argv) > 1 else f"http://{HOST}:5000"
|
||||
|
||||
|
||||
def _u(port: int, path: str) -> str:
|
||||
return f"http://{HOST}:{port}{path}"
|
||||
|
||||
|
||||
# (label, url, expected_json_key or None for version-only)
|
||||
CHECKS: list[tuple[str, str, str | None]] = [
|
||||
("nova.servers", _u(8774, "/v2.1/servers/detail"), "servers"),
|
||||
("nova.flavors", _u(8774, "/v2.1/flavors"), "flavors"),
|
||||
("nova.keypairs", _u(8774, "/v2.1/os-keypairs"), "keypairs"),
|
||||
("nova.az", _u(8774, "/v2.1/os-availability-zone"), "availabilityZoneInfo"),
|
||||
("nova.hypervisors", _u(8774, "/v2.1/os-hypervisors"), "hypervisors"),
|
||||
("neutron.networks", _u(9696, "/v2.0/networks"), "networks"),
|
||||
("neutron.routers", _u(9696, "/v2.0/routers"), "routers"),
|
||||
("neutron.sg", _u(9696, "/v2.0/security-groups"), "security_groups"),
|
||||
("neutron.fips", _u(9696, "/v2.0/floatingips"), "floatingips"),
|
||||
("glance.images", _u(9292, "/v2/images"), "images"),
|
||||
("cinder.volumes", _u(8776, "/v3/volumes/detail"), "volumes"),
|
||||
("placement.rp", _u(8003, "/resource_providers"), "resource_providers"),
|
||||
("heat.stacks", _u(8004, "/v1/demo/stacks"), "stacks"),
|
||||
("swift.info", _u(8080, "/info"), None),
|
||||
("ironic.nodes", _u(6385, "/v1/nodes"), "nodes"),
|
||||
("octavia.lbs", _u(9876, "/v2/lbaas/loadbalancers"), "loadbalancers"),
|
||||
("barbican.secrets", _u(9311, "/v1/secrets"), "secrets"),
|
||||
("manila.shares", _u(8786, "/v2/shares"), "shares"),
|
||||
("designate.zones", _u(9001, "/v2/zones"), "zones"),
|
||||
("magnum.clusters", _u(9511, "/v1/clusters"), "clusters"),
|
||||
("zun.containers", _u(9517, "/v1/containers"), "containers"),
|
||||
("trove.instances", _u(8779, "/v1.0/instances"), "instances"),
|
||||
("mistral.workflows", _u(8989, "/v2/workflows"), "workflows"),
|
||||
("aodh.alarms", _u(8042, "/v2/alarms"), "alarms"),
|
||||
("freezer.jobs", _u(9090, "/v2/jobs"), "jobs"),
|
||||
("blazar.leases", _u(1234, "/leases"), "leases"),
|
||||
("vitrage.alarms", _u(8999, "/v1/alarm"), "alarms"),
|
||||
("masakari.segments", _u(15868, "/v1/segments"), "segments"),
|
||||
("tacker.vnfs", _u(9890, "/v1.0/vnfs"), "vnfs"),
|
||||
("adjutant.tasks", _u(5050, "/v1/tasks"), "tasks"),
|
||||
("cloudkitty.services", _u(8889, "/v1/rating/module_config/hashmap/services"), "services"),
|
||||
("heat-cfn.stacks", _u(8000, "/stacks"), "Stacks"),
|
||||
]
|
||||
|
||||
|
||||
def request(
|
||||
method: str,
|
||||
url: str,
|
||||
*,
|
||||
data: dict | None = None,
|
||||
token: str | None = None,
|
||||
extra_headers: dict[str, str] | None = None,
|
||||
):
|
||||
body = None if data is None else json.dumps(data).encode()
|
||||
headers = {"Accept": "application/json"}
|
||||
if data is not None:
|
||||
headers["Content-Type"] = "application/json"
|
||||
if token:
|
||||
headers["X-Auth-Token"] = token
|
||||
if extra_headers:
|
||||
headers.update(extra_headers)
|
||||
req = urllib.request.Request(url, data=body, headers=headers, method=method)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=15) as res:
|
||||
raw = res.read().decode()
|
||||
return res.status, dict(res.headers), json.loads(raw) if raw else None
|
||||
except urllib.error.HTTPError as exc:
|
||||
raw = exc.read().decode()
|
||||
try:
|
||||
parsed = json.loads(raw) if raw else None
|
||||
except json.JSONDecodeError:
|
||||
parsed = raw
|
||||
return exc.code, dict(exc.headers), parsed
|
||||
except urllib.error.URLError as exc:
|
||||
return 0, {}, {"error": str(exc.reason)}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
auth = {
|
||||
"auth": {
|
||||
"identity": {
|
||||
"methods": ["password"],
|
||||
"password": {
|
||||
"user": {
|
||||
"name": "admin",
|
||||
"domain": {"name": "Default"},
|
||||
"password": "secret",
|
||||
}
|
||||
},
|
||||
},
|
||||
"scope": {"project": {"name": "demo", "domain": {"name": "Default"}}},
|
||||
}
|
||||
}
|
||||
status, headers, body = request("POST", f"{KEYSTONE}/v3/auth/tokens", data=auth)
|
||||
token = headers.get("X-Subject-Token") or headers.get("x-subject-token")
|
||||
print("auth", status, "token", bool(token))
|
||||
if status != 201 or not token:
|
||||
print(body)
|
||||
return 1
|
||||
catalog = (body or {}).get("token", {}).get("catalog", [])
|
||||
print("catalog_services", len(catalog), sorted(s.get("name") for s in catalog))
|
||||
|
||||
# Microversion header round-trip on Nova
|
||||
st, hdrs, _ = request(
|
||||
"GET",
|
||||
_u(8774, "/v2.1/servers"),
|
||||
token=token,
|
||||
extra_headers={"OpenStack-API-Version": "compute 2.79"},
|
||||
)
|
||||
mv = hdrs.get("OpenStack-API-Version") or hdrs.get("openstack-api-version")
|
||||
print("nova.microversion", st, mv)
|
||||
if st >= 400:
|
||||
return 1
|
||||
|
||||
failed = 0
|
||||
for label, url, key in CHECKS:
|
||||
# Heat needs project id in path — fetch from token
|
||||
if label == "heat.stacks":
|
||||
project_id = (body or {}).get("token", {}).get("project", {}).get("id")
|
||||
if project_id:
|
||||
url = _u(8004, f"/v1/{project_id}/stacks")
|
||||
st, _, payload = request("GET", url, token=token)
|
||||
if key is None:
|
||||
print(label, st)
|
||||
else:
|
||||
items = (payload or {}).get(key)
|
||||
count = (
|
||||
len(items)
|
||||
if isinstance(items, list)
|
||||
else ("ok" if items is not None else "missing")
|
||||
)
|
||||
print(label, st, "count", count)
|
||||
if st == 0 or st >= 400:
|
||||
print(" FAIL", payload)
|
||||
failed += 1
|
||||
|
||||
# Root discovery per port
|
||||
for port, name in [(5000, "keystone"), (8774, "nova"), (6385, "ironic"), (8080, "swift")]:
|
||||
st, _, payload = request("GET", _u(port, "/"))
|
||||
print(f"root.{name}", st, list((payload or {}).keys())[:3])
|
||||
|
||||
if failed:
|
||||
print(f"FAILED {failed}/{len(CHECKS)}")
|
||||
return 1
|
||||
print("OK", len(CHECKS), "service checks")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,61 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Probe every pack operation for Yoga → Dalmatian against the live gateway."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
|
||||
# Allow `python examples/python/openstack_surface_probe.py` from repo / container.
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")))
|
||||
|
||||
from app.openstack.surface_probe import format_report, probe_series # noqa: E402
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--host", default=os.environ.get("OS_HOST", "http://127.0.0.1:5000"))
|
||||
parser.add_argument(
|
||||
"--series",
|
||||
action="append",
|
||||
help="Limit to series (repeatable). Default: all four.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--collections-only",
|
||||
action="store_true",
|
||||
help="Only GET endpoints without path parameters (faster smoke).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-lifecycle",
|
||||
action="store_true",
|
||||
help="Random-UUID shallow probe (accepts 404) instead of create→CRUD lifecycle.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--methods",
|
||||
default="",
|
||||
help="Comma-separated methods filter (e.g. GET,POST)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
series_list = args.series or ["yoga", "antelope", "caracal", "dalmatian"]
|
||||
methods = frozenset(m.strip().upper() for m in args.methods.split(",") if m.strip()) or None
|
||||
failed = 0
|
||||
for series in series_list:
|
||||
report = probe_series(
|
||||
series,
|
||||
host=args.host,
|
||||
methods=methods,
|
||||
collections_only=args.collections_only,
|
||||
lifecycle=not args.no_lifecycle and not args.collections_only,
|
||||
)
|
||||
print(format_report(report))
|
||||
failed += len(report.failures)
|
||||
if failed:
|
||||
print(f"FAILED total={failed}")
|
||||
return 1
|
||||
print("OK all probed operations returned acceptable statuses")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,77 @@
|
||||
#!/usr/bin/env python3
|
||||
"""OpenStack SDK cookbook against openstack-api-simulator.
|
||||
|
||||
Creates network + server + volume, updates metadata, cleans up.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
import openstack
|
||||
|
||||
|
||||
def main() -> int:
|
||||
conn = openstack.connect(
|
||||
auth_url="http://127.0.0.1:5000/v3",
|
||||
project_name="demo",
|
||||
username="admin",
|
||||
password="secret",
|
||||
user_domain_name="Default",
|
||||
project_domain_name="Default",
|
||||
region_name="RegionOne",
|
||||
)
|
||||
|
||||
print("identity ok:", conn.identity.get_project(conn.current_project_id).name)
|
||||
|
||||
image = conn.image.find_image("cirros", ignore_missing=False)
|
||||
network = conn.network.find_network("demo-net", ignore_missing=False)
|
||||
print("boot image:", image.id, image.name)
|
||||
print("network:", network.id, network.name)
|
||||
|
||||
app_net = conn.network.create_network(name="sdk-app-net", admin_state_up=True)
|
||||
app_subnet = conn.network.create_subnet(
|
||||
name="sdk-app-subnet",
|
||||
network_id=app_net.id,
|
||||
ip_version=4,
|
||||
cidr="10.88.0.0/24",
|
||||
)
|
||||
print("created net/subnet:", app_net.id, app_subnet.id)
|
||||
|
||||
server = conn.compute.create_server(
|
||||
name="sdk-cookbook-vm",
|
||||
flavor_id="1",
|
||||
image_id=image.id,
|
||||
networks=[{"uuid": network.id}],
|
||||
metadata={"managed_by": "openstacksdk"},
|
||||
)
|
||||
server = conn.compute.wait_for_server(server, status="ACTIVE", failures=["ERROR"], wait=60)
|
||||
print("server ACTIVE:", server.id, server.name, server.status)
|
||||
|
||||
conn.compute.set_server_metadata(server, playbook="sdk", env="lab")
|
||||
server = conn.compute.get_server(server.id)
|
||||
print("metadata:", dict(server.metadata or {}))
|
||||
|
||||
volume = conn.block_storage.create_volume(name="sdk-cookbook-vol", size=5)
|
||||
volume = conn.block_storage.wait_for_status(volume, status="available", wait=60)
|
||||
print("volume:", volume.id, volume.status)
|
||||
|
||||
conn.compute.delete_server(server, ignore_missing=True)
|
||||
print("server deleted")
|
||||
|
||||
conn.block_storage.delete_volume(volume, ignore_missing=True)
|
||||
print("volume deleted")
|
||||
|
||||
conn.network.delete_subnet(app_subnet, ignore_missing=True)
|
||||
conn.network.delete_network(app_net, ignore_missing=True)
|
||||
print("network cleaned")
|
||||
print("OPENSTACKSDK_COOKBOOK_OK")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
raise SystemExit(main())
|
||||
except Exception as exc: # noqa: BLE001
|
||||
print("OPENSTACKSDK_COOKBOOK_FAIL:", exc, file=sys.stderr)
|
||||
raise
|
||||
@@ -0,0 +1,85 @@
|
||||
#!/usr/bin/env python3
|
||||
"""proxmoxer cookbook against the local HTTPS gateway."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
from proxmoxer import ProxmoxAPI
|
||||
|
||||
|
||||
def wait_task(proxmox: ProxmoxAPI, node: str, upid: str, timeout: float = 120.0) -> None:
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
status = proxmox.nodes(node).tasks(upid).status.get()
|
||||
if status.get("status") == "stopped":
|
||||
exitstatus = status.get("exitstatus", "")
|
||||
if exitstatus not in ("OK", "ok", None, ""):
|
||||
# Proxmox uses exitstatus "OK" on success; accept empty for lab.
|
||||
if str(exitstatus).upper() != "OK":
|
||||
raise RuntimeError(f"task failed: {status}")
|
||||
return
|
||||
time.sleep(0.5)
|
||||
raise TimeoutError(upid)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
host = os.environ.get("PVE_HOST", "localhost")
|
||||
port = int(os.environ.get("PVE_PORT", "8007"))
|
||||
user = os.environ.get("PVE_USER", "root@pam")
|
||||
node = os.environ.get("PVE_NODE", "pve01")
|
||||
vmid = int(os.environ.get("PVE_VMID", "110"))
|
||||
|
||||
token_name = os.environ.get("PVE_TOKEN_NAME")
|
||||
token_value = os.environ.get("PVE_TOKEN_VALUE")
|
||||
if token_name and token_value:
|
||||
proxmox = ProxmoxAPI(
|
||||
host,
|
||||
user=user,
|
||||
token_name=token_name,
|
||||
token_value=token_value,
|
||||
port=port,
|
||||
verify_ssl=False,
|
||||
)
|
||||
else:
|
||||
proxmox = ProxmoxAPI(
|
||||
host,
|
||||
user=user,
|
||||
password=os.environ.get("PVE_PASSWORD", "secret"),
|
||||
port=port,
|
||||
verify_ssl=False,
|
||||
)
|
||||
|
||||
print("version:", proxmox.version.get())
|
||||
print("nodes:", proxmox.nodes.get())
|
||||
print("qemu:", proxmox.nodes(node).qemu.get())
|
||||
|
||||
upid = proxmox.nodes(node).qemu.post(
|
||||
vmid=vmid,
|
||||
name=f"cookbook-{vmid}",
|
||||
cores=1,
|
||||
memory=512,
|
||||
)
|
||||
print("create:", upid)
|
||||
wait_task(proxmox, node, upid)
|
||||
|
||||
upid = proxmox.nodes(node).qemu(vmid).status.start.post()
|
||||
print("start:", upid)
|
||||
wait_task(proxmox, node, upid)
|
||||
print("status:", proxmox.nodes(node).qemu(vmid).status.current.get())
|
||||
|
||||
upid = proxmox.nodes(node).qemu(vmid).status.stop.post()
|
||||
print("stop:", upid)
|
||||
wait_task(proxmox, node, upid)
|
||||
|
||||
upid = proxmox.nodes(node).qemu(vmid).delete()
|
||||
print("delete:", upid)
|
||||
wait_task(proxmox, node, upid)
|
||||
print("ok")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,102 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Raw requests cookbook against HTTP :8006."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import requests
|
||||
|
||||
BASE = os.environ.get("PVE_BASE", "http://localhost:8006/api2/json")
|
||||
NODE = os.environ.get("PVE_NODE", "pve01")
|
||||
VMID = int(os.environ.get("PVE_VMID", "111"))
|
||||
TOKEN = os.environ.get(
|
||||
"PVE_API_TOKEN",
|
||||
"root@pam!automation=automation-secret",
|
||||
)
|
||||
|
||||
|
||||
def api(
|
||||
method: str,
|
||||
path: str,
|
||||
*,
|
||||
headers: dict[str, str] | None = None,
|
||||
data: dict[str, Any] | None = None,
|
||||
) -> Any:
|
||||
response = requests.request(
|
||||
method,
|
||||
f"{BASE}{path}",
|
||||
headers=headers,
|
||||
data=data,
|
||||
timeout=60,
|
||||
)
|
||||
response.raise_for_status()
|
||||
body = response.json()
|
||||
return body.get("data", body)
|
||||
|
||||
|
||||
def wait_task(headers: dict[str, str], upid: str, timeout: float = 120.0) -> None:
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
status = api("GET", f"/nodes/{NODE}/tasks/{upid}/status", headers=headers)
|
||||
if status.get("status") == "stopped":
|
||||
return
|
||||
time.sleep(0.5)
|
||||
raise TimeoutError(upid)
|
||||
|
||||
|
||||
def with_token() -> dict[str, str]:
|
||||
return {"Authorization": f"PVEAPIToken={TOKEN}"}
|
||||
|
||||
|
||||
def with_ticket() -> dict[str, str]:
|
||||
data = api(
|
||||
"POST",
|
||||
"/access/ticket",
|
||||
data={
|
||||
"username": os.environ.get("PVE_USER", "root@pam"),
|
||||
"password": os.environ.get("PVE_PASSWORD", "secret"),
|
||||
},
|
||||
)
|
||||
return {
|
||||
"Cookie": f"PVEAuthCookie={data['ticket']}",
|
||||
"CSRFPreventionToken": data["CSRFPreventionToken"],
|
||||
}
|
||||
|
||||
|
||||
def cookbook(headers: dict[str, str], label: str) -> None:
|
||||
print(label, "version:", api("GET", "/version", headers=headers))
|
||||
print(label, "qemu:", api("GET", f"/nodes/{NODE}/qemu", headers=headers))
|
||||
upid = api(
|
||||
"POST",
|
||||
f"/nodes/{NODE}/qemu",
|
||||
headers=headers,
|
||||
data={"vmid": VMID, "name": f"req-{VMID}", "cores": 1, "memory": 512},
|
||||
)
|
||||
wait_task(headers, upid)
|
||||
upid = api("POST", f"/nodes/{NODE}/qemu/{VMID}/status/start", headers=headers)
|
||||
wait_task(headers, upid)
|
||||
print(
|
||||
label, "status:", api("GET", f"/nodes/{NODE}/qemu/{VMID}/status/current", headers=headers)
|
||||
)
|
||||
upid = api("POST", f"/nodes/{NODE}/qemu/{VMID}/status/stop", headers=headers)
|
||||
wait_task(headers, upid)
|
||||
upid = api("DELETE", f"/nodes/{NODE}/qemu/{VMID}", headers=headers)
|
||||
wait_task(headers, upid)
|
||||
print(label, "ok")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
cookbook(with_token(), "token")
|
||||
# second VMID for ticket path
|
||||
global VMID
|
||||
VMID = int(os.environ.get("PVE_VMID_TICKET", "112"))
|
||||
cookbook(with_ticket(), "ticket")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,2 @@
|
||||
proxmoxer>=2.3,<3
|
||||
requests>=2.31
|
||||
Reference in New Issue
Block a user