f8d3cbdd59
Add the FastAPI app, PostgreSQL migrations, Docker/Helm packaging, API contracts, docs, client examples, and the unit/integration/compatibility test suite for local client and tooling labs without a real vCenter.
362 lines
13 KiB
Python
362 lines
13 KiB
Python
"""Deep REST CRUD battery: create → read → update → delete with nonempty checks.
|
|
|
|
Covers session, folder, tagging, content library, and VM where durable handlers exist.
|
|
Fails if a deep handler returns a stub marker or empty body after a successful write.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import secrets
|
|
from typing import Any
|
|
|
|
from rest_matrix import login, request
|
|
|
|
|
|
def _session_headers(session: str) -> dict[str, str]:
|
|
return {
|
|
"vmware-api-session-id": session,
|
|
"Content-Type": "application/json",
|
|
"Accept": "application/json",
|
|
}
|
|
|
|
|
|
def _parse(body: str) -> Any:
|
|
if not body or not body.strip():
|
|
return None
|
|
try:
|
|
return json.loads(body)
|
|
except json.JSONDecodeError:
|
|
return body
|
|
|
|
|
|
def _is_stub(body: str) -> bool:
|
|
return '"stub": true' in body or '"stub":true' in body
|
|
|
|
|
|
def _fail(flow: str, step: str, detail: str) -> dict[str, Any]:
|
|
return {"flow": flow, "step": step, "status": "failed", "error": detail}
|
|
|
|
|
|
def _ok(flow: str, detail: str = "") -> dict[str, Any]:
|
|
return {"flow": flow, "step": "done", "status": "passed", "error": detail}
|
|
|
|
|
|
def _flow_session(headers: dict[str, str]) -> dict[str, Any]:
|
|
# Use a dedicated session so we can delete it without killing the suite session.
|
|
sid = login()
|
|
h = _session_headers(sid)
|
|
code, body = request("GET", "/api/session", headers=h)
|
|
if code != 200:
|
|
return _fail("session", "get", f"GET /api/session → {code} {body[:120]}")
|
|
if _is_stub(body):
|
|
return _fail("session", "get", "stub marker on session GET")
|
|
code, body = request("DELETE", "/api/session", headers=h)
|
|
if code not in {200, 204}:
|
|
return _fail("session", "delete", f"DELETE /api/session → {code} {body[:120]}")
|
|
code, body = request("GET", "/api/session", headers=h)
|
|
if code not in {401, 403, 404}:
|
|
return _fail("session", "gone", f"expected auth failure after delete, got {code}")
|
|
del headers # suite session untouched
|
|
return _ok("session")
|
|
|
|
|
|
def _flow_folder(headers: dict[str, str]) -> dict[str, Any]:
|
|
suffix = secrets.token_hex(3)
|
|
name = f"crud-folder-{suffix}"
|
|
renamed = f"crud-folder-renamed-{suffix}"
|
|
code, body = request(
|
|
"POST",
|
|
"/api/vcenter/folder",
|
|
headers=headers,
|
|
data=json.dumps({"name": name, "parent": "group-v23"}).encode(),
|
|
)
|
|
if code not in {200, 201}:
|
|
return _fail("folder", "create", f"{code} {body[:160]}")
|
|
if _is_stub(body):
|
|
return _fail("folder", "create", "stub marker on create")
|
|
folder_id = _parse(body)
|
|
if not isinstance(folder_id, str) or not folder_id.strip():
|
|
return _fail("folder", "create", f"empty folder id: {body[:160]}")
|
|
|
|
code, body = request("GET", "/api/vcenter/folder", headers=headers)
|
|
if code != 200 or _is_stub(body):
|
|
return _fail("folder", "list", f"{code} stub={_is_stub(body)} {body[:120]}")
|
|
folders = _parse(body) or []
|
|
names = {f.get("name") for f in folders if isinstance(f, dict)}
|
|
ids = {f.get("folder") for f in folders if isinstance(f, dict)}
|
|
if name not in names and folder_id not in ids:
|
|
return _fail("folder", "list", f"created folder not in list ({folder_id})")
|
|
|
|
code, body = request(
|
|
"POST",
|
|
f"/api/vcenter/folder/{folder_id}?action=rename",
|
|
headers=headers,
|
|
data=json.dumps({"name": renamed}).encode(),
|
|
)
|
|
if code not in {200, 204}:
|
|
return _fail("folder", "rename", f"{code} {body[:160]}")
|
|
|
|
code, body = request("GET", "/api/vcenter/folder", headers=headers)
|
|
folders = _parse(body) or []
|
|
names = {f.get("name") for f in folders if isinstance(f, dict)}
|
|
if renamed not in names:
|
|
return _fail("folder", "rename-verify", f"renamed name missing: {names}")
|
|
|
|
code, body = request("DELETE", f"/api/vcenter/folder/{folder_id}", headers=headers)
|
|
if code not in {200, 204}:
|
|
return _fail("folder", "delete", f"{code} {body[:160]}")
|
|
|
|
code, body = request("GET", f"/api/vcenter/folder/{folder_id}/children", headers=headers)
|
|
if code not in {404, 400}:
|
|
# children on missing folder should fail; also check list no longer has it
|
|
code2, body2 = request("GET", "/api/vcenter/folder", headers=headers)
|
|
folders = _parse(body2) or []
|
|
ids = {f.get("folder") for f in folders if isinstance(f, dict)}
|
|
if folder_id in ids:
|
|
return _fail("folder", "gone", f"folder still listed after delete; children={code}")
|
|
return _ok("folder", folder_id)
|
|
|
|
|
|
def _flow_tagging(headers: dict[str, str]) -> dict[str, Any]:
|
|
suffix = secrets.token_hex(3)
|
|
code, body = request(
|
|
"POST",
|
|
"/api/cis/tagging/category",
|
|
headers=headers,
|
|
data=json.dumps(
|
|
{
|
|
"create_spec": {
|
|
"name": f"crud-cat-{suffix}",
|
|
"description": "crud",
|
|
"cardinality": "MULTIPLE",
|
|
"associable_types": ["VirtualMachine"],
|
|
}
|
|
}
|
|
).encode(),
|
|
)
|
|
if code not in {200, 201}:
|
|
return _fail("tagging", "create-category", f"{code} {body[:160]}")
|
|
if _is_stub(body):
|
|
return _fail("tagging", "create-category", "stub marker")
|
|
cat_id = _parse(body)
|
|
if not isinstance(cat_id, str) or not cat_id:
|
|
return _fail("tagging", "create-category", f"empty id: {body[:120]}")
|
|
|
|
code, body = request("GET", f"/api/cis/tagging/category/{cat_id}", headers=headers)
|
|
if code != 200 or _is_stub(body):
|
|
return _fail("tagging", "get-category", f"{code} {body[:160]}")
|
|
cat = _parse(body)
|
|
if not isinstance(cat, dict) or not cat.get("name"):
|
|
return _fail("tagging", "get-category", f"empty category body: {body[:160]}")
|
|
|
|
code, body = request(
|
|
"POST",
|
|
"/api/cis/tagging/tag",
|
|
headers=headers,
|
|
data=json.dumps(
|
|
{
|
|
"create_spec": {
|
|
"name": f"crud-tag-{suffix}",
|
|
"category_id": cat_id,
|
|
"description": "before",
|
|
}
|
|
}
|
|
).encode(),
|
|
)
|
|
if code not in {200, 201}:
|
|
return _fail("tagging", "create-tag", f"{code} {body[:160]}")
|
|
tag_id = _parse(body)
|
|
if not isinstance(tag_id, str) or not tag_id:
|
|
return _fail("tagging", "create-tag", f"empty tag id: {body[:120]}")
|
|
|
|
code, body = request("GET", f"/api/cis/tagging/tag/{tag_id}", headers=headers)
|
|
if code != 200 or _is_stub(body):
|
|
return _fail("tagging", "get-tag", f"{code} {body[:160]}")
|
|
tag = _parse(body)
|
|
if not isinstance(tag, dict) or tag.get("name") != f"crud-tag-{suffix}":
|
|
return _fail("tagging", "get-tag", f"unexpected tag: {body[:160]}")
|
|
|
|
# No PATCH on tagging in CORE — create/read/delete is the durable contract.
|
|
code, body = request("DELETE", f"/api/cis/tagging/tag/{tag_id}", headers=headers)
|
|
if code not in {200, 204}:
|
|
return _fail("tagging", "delete-tag", f"{code} {body[:160]}")
|
|
code, body = request("GET", f"/api/cis/tagging/tag/{tag_id}", headers=headers)
|
|
if code not in {404, 400}:
|
|
return _fail("tagging", "tag-gone", f"expected 404, got {code}")
|
|
|
|
code, body = request("DELETE", f"/api/cis/tagging/category/{cat_id}", headers=headers)
|
|
if code not in {200, 204}:
|
|
return _fail("tagging", "delete-category", f"{code} {body[:160]}")
|
|
code, body = request("GET", f"/api/cis/tagging/category/{cat_id}", headers=headers)
|
|
if code not in {404, 400}:
|
|
return _fail("tagging", "category-gone", f"expected 404, got {code}")
|
|
return _ok("tagging", f"{cat_id}/{tag_id}")
|
|
|
|
|
|
def _flow_content_library(headers: dict[str, str]) -> dict[str, Any]:
|
|
suffix = secrets.token_hex(3)
|
|
code, body = request(
|
|
"POST",
|
|
"/api/content/local-library",
|
|
headers=headers,
|
|
data=json.dumps(
|
|
{"create_spec": {"name": f"crud-lib-{suffix}", "description": "crud"}}
|
|
).encode(),
|
|
)
|
|
if code not in {200, 201}:
|
|
return _fail("content-library", "create-library", f"{code} {body[:160]}")
|
|
if _is_stub(body):
|
|
return _fail("content-library", "create-library", "stub marker")
|
|
lib_id = _parse(body)
|
|
if not isinstance(lib_id, str) or not lib_id:
|
|
return _fail("content-library", "create-library", f"empty id: {body[:120]}")
|
|
|
|
code, body = request("GET", "/api/content/library", headers=headers)
|
|
if code != 200 or _is_stub(body):
|
|
return _fail("content-library", "list-libraries", f"{code} {body[:160]}")
|
|
libs = _parse(body) or []
|
|
if lib_id not in libs:
|
|
return _fail("content-library", "list-libraries", f"{lib_id} not in {libs!r}")
|
|
|
|
code, body = request(
|
|
"POST",
|
|
"/api/content/library/item",
|
|
headers=headers,
|
|
data=json.dumps(
|
|
{
|
|
"create_spec": {
|
|
"library_id": lib_id,
|
|
"name": f"crud-item-{suffix}",
|
|
"type": "ovf",
|
|
"description": "crud-item",
|
|
}
|
|
}
|
|
).encode(),
|
|
)
|
|
if code not in {200, 201}:
|
|
return _fail("content-library", "create-item", f"{code} {body[:160]}")
|
|
item_id = _parse(body)
|
|
if not isinstance(item_id, str) or not item_id:
|
|
return _fail("content-library", "create-item", f"empty item id: {body[:120]}")
|
|
|
|
code, body = request(
|
|
"GET",
|
|
f"/api/content/library/item?library_id={lib_id}",
|
|
headers=headers,
|
|
)
|
|
if code != 200 or _is_stub(body):
|
|
return _fail("content-library", "list-items", f"{code} {body[:160]}")
|
|
items = _parse(body) or []
|
|
if item_id not in items:
|
|
return _fail("content-library", "list-items", f"{item_id} not in {items!r}")
|
|
return _ok("content-library", f"{lib_id}/{item_id}")
|
|
|
|
|
|
def _flow_vm(headers: dict[str, str]) -> dict[str, Any]:
|
|
suffix = secrets.token_hex(3)
|
|
name = f"crud-vm-{suffix}"
|
|
code, body = request(
|
|
"POST",
|
|
"/api/vcenter/vm",
|
|
headers=headers,
|
|
data=json.dumps(
|
|
{
|
|
"name": name,
|
|
"placement": {
|
|
"folder": "group-v23",
|
|
"host": "host-11",
|
|
"datastore": "datastore-31",
|
|
"resource_pool": "resgroup-22",
|
|
},
|
|
"cpu_count": 1,
|
|
"memory_size_MiB": 512,
|
|
}
|
|
).encode(),
|
|
)
|
|
if code not in {200, 201}:
|
|
return _fail("vm", "create", f"{code} {body[:160]}")
|
|
if _is_stub(body):
|
|
return _fail("vm", "create", "stub marker")
|
|
vm_id = _parse(body)
|
|
if not isinstance(vm_id, str) or not vm_id.startswith("vm-"):
|
|
return _fail("vm", "create", f"bad vm id: {body[:120]}")
|
|
|
|
code, body = request("GET", f"/api/vcenter/vm/{vm_id}", headers=headers)
|
|
if code != 200 or _is_stub(body):
|
|
return _fail("vm", "get", f"{code} {body[:160]}")
|
|
info = _parse(body)
|
|
if not isinstance(info, dict) or not info:
|
|
return _fail("vm", "get", f"empty vm info: {body[:160]}")
|
|
|
|
code, body = request(
|
|
"PATCH",
|
|
f"/api/vcenter/vm/{vm_id}/hardware/cpu",
|
|
headers=headers,
|
|
data=json.dumps({"count": 2}).encode(),
|
|
)
|
|
if code not in {200, 204}:
|
|
return _fail("vm", "patch-cpu", f"{code} {body[:160]}")
|
|
|
|
code, body = request("GET", f"/api/vcenter/vm/{vm_id}/hardware/cpu", headers=headers)
|
|
if code != 200 or _is_stub(body):
|
|
return _fail("vm", "get-cpu", f"{code} {body[:160]}")
|
|
cpu = _parse(body)
|
|
count = None
|
|
if isinstance(cpu, dict):
|
|
count = cpu.get("count") or cpu.get("num_cpus") or cpu.get("numCPUs")
|
|
if count is not None and int(count) != 2:
|
|
return _fail("vm", "patch-verify", f"cpu count={count!r} after patch")
|
|
|
|
# Ensure powered off before delete.
|
|
code, body = request("GET", f"/api/vcenter/vm/{vm_id}/power", headers=headers)
|
|
power = _parse(body) if code == 200 else {}
|
|
state = ""
|
|
if isinstance(power, dict):
|
|
state = str(power.get("state") or power.get("power_state") or "")
|
|
if state.upper() in {"POWERED_ON", "ON"}:
|
|
request(
|
|
"POST",
|
|
f"/api/vcenter/vm/{vm_id}/power?action=stop",
|
|
headers=headers,
|
|
data=b"{}",
|
|
)
|
|
|
|
code, body = request("DELETE", f"/api/vcenter/vm/{vm_id}", headers=headers)
|
|
if code not in {200, 204}:
|
|
return _fail("vm", "delete", f"{code} {body[:160]}")
|
|
code, body = request("GET", f"/api/vcenter/vm/{vm_id}", headers=headers)
|
|
if code not in {404, 400}:
|
|
return _fail("vm", "gone", f"expected 404 after delete, got {code}")
|
|
return _ok("vm", vm_id)
|
|
|
|
|
|
def run_rest_crud() -> dict[str, Any]:
|
|
"""Run curated deep CRUD flows. Returns suite-ready summary."""
|
|
|
|
session = login()
|
|
headers = _session_headers(session)
|
|
flows = [
|
|
_flow_session,
|
|
_flow_folder,
|
|
_flow_tagging,
|
|
_flow_content_library,
|
|
_flow_vm,
|
|
]
|
|
results: list[dict[str, Any]] = []
|
|
for flow in flows:
|
|
# Refresh session in case a prior flow touched auth edges.
|
|
if flow is not _flow_session:
|
|
session = login()
|
|
headers = _session_headers(session)
|
|
results.append(flow(headers))
|
|
|
|
failed = [r for r in results if r.get("status") == "failed"]
|
|
return {
|
|
"flows": results,
|
|
"total": len(results),
|
|
"failed": len(failed),
|
|
"failures": failed,
|
|
"ok": not failed,
|
|
}
|