"""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, }