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:
2026-07-18 04:26:48 +03:00
commit 6033967e6a
509 changed files with 464404 additions and 0 deletions
+161
View File
@@ -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())
+161
View File
@@ -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())
+77
View File
@@ -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
+85
View File
@@ -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())
+102
View File
@@ -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())
+2
View File
@@ -0,0 +1,2 @@
proxmoxer>=2.3,<3
requests>=2.31