Initial commit: VMware vSphere API simulator scaffold.

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.
This commit is contained in:
2026-07-18 04:42:11 +03:00
commit f8d3cbdd59
422 changed files with 361335 additions and 0 deletions
View File
+32
View File
@@ -0,0 +1,32 @@
"""Non-empty output validation for pulumi-vsphere stack results."""
from __future__ import annotations
from typing import Any
def _is_empty(value: Any) -> bool:
if value is None:
return True
if isinstance(value, str) and not value.strip():
return True
if isinstance(value, (list, dict, tuple, set)) and len(value) == 0:
return True
return False
def assert_nonempty(outputs: dict[str, Any], *, required: list[str] | None = None) -> list[str]:
"""Return list of error messages for empty/missing outputs."""
errors: list[str] = []
keys = required if required is not None else list(outputs)
if not keys:
errors.append("no outputs exported")
return errors
for key in keys:
if key not in outputs:
errors.append(f"missing output: {key}")
continue
if _is_empty(outputs[key]):
errors.append(f"empty output: {key}={outputs[key]!r}")
return errors
+361
View File
@@ -0,0 +1,361 @@
"""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,
}
+497
View File
@@ -0,0 +1,497 @@
"""Full REST verb×path×majors matrix for pulumi-tests (hybrid suite).
Reuses path substitution / session patterns from scripts/vsphere_full_matrix_probe.py.
Pass rules match that probe: no 5xx/501; inventory GETs nonempty + non-stub on major 9.
"""
from __future__ import annotations
import json
import os
import re
import secrets
import ssl
import urllib.error
import urllib.request
from base64 import b64encode
from collections import Counter
from typing import Any
from urllib.parse import urlencode
from app.vsphere.contracts.matrix import VERSIONS, catalog_entries_for_major, methods_for_major
from app.vsphere.rest.coverage import CORE_IMPLEMENTED, IMPLEMENTED
_PATH_SUBS = {
"{vm}": "vm-101",
"{host}": "host-11",
"{datastore}": "datastore-31",
"{task}": "task-1",
"{snapshot}": "snapshot-missing",
"{category_id}": "cat-lab-1",
"{tag_id}": "tag-lab-1",
"{item_id}": "item-ubuntu",
"{library_id}": "lib-local-1",
"{folder}": "group-v23",
"{datacenter}": "datacenter-21",
"{cluster}": "domain-c21",
"{resource_pool}": "resgroup-22",
"{permission_id}": "999999",
"{policy}": "policy-default",
"{disk}": "2000",
"{nic}": "4000",
"{cdrom}": "3000",
"{floppy}": "8000",
"{port}": "9000",
"{adapter}": "1000",
"{provider}": "vsphere.local",
"{supervisor}": "supervisor-1",
"{namespace}": "ns-lab-1",
"{role}": "ReadOnly",
"{zone}": "zone-1",
"{project}": "project-1",
"{domain}": "lab.local",
"{service}": "vsphere-ui",
"{depot}": "depot-1",
"{component}": "component-1",
"{image}": "image-1",
"{draft}": "draft-1",
"{connection}": "connection-1",
"{vpc}": "vpc-1",
"{subnet}": "subnet-1",
"{session_id}": "session-lab-1",
"{download_session_id}": "session-lab-1",
"{update_session_id}": "session-lab-1",
"{subscription_id}": "sub-1",
"{usage_id}": "usage-1",
"{version}": "1",
"{chain}": "chain-1",
"{node}": "node-1",
"{profile}": "profile-1",
"{interface}": "nic0",
"{core}": "core-1",
"{network}": "network-41",
"{commit}": "commit-lab-1",
}
_ACCEPT_CLIENT = {400, 401, 403, 404, 405, 409, 412, 422}
_INVENTORY_CRITICAL = {
"/api/vcenter/vm",
"/api/vcenter/host",
"/api/vcenter/datastore",
"/api/vcenter/network",
"/api/vcenter/cluster",
"/api/cis/tagging/category",
"/api/content/library",
"/api/esx/settings/clusters/{cluster}/software",
"/api/vcenter/namespace-management/supervisors/{supervisor}/summary",
"/api/appliance/access/ssh",
"/api/appliance/services",
}
def _base() -> str:
explicit = os.environ.get("VSPHERE_BASE")
if explicit:
return explicit.rstrip("/")
server = os.environ.get("VSPHERE_SERVER", "localhost")
if server.startswith("http://") or server.startswith("https://"):
return server.rstrip("/")
return f"https://{server}"
def _creds() -> tuple[str, str]:
return (
os.environ.get("VSPHERE_USER", "administrator@vsphere.local"),
os.environ.get("VSPHERE_PASSWORD", "VMware1!"),
)
def _ctx() -> ssl.SSLContext | None:
if not _base().startswith("https://"):
return None
return ssl._create_unverified_context() # noqa: S323
def concrete_path(path: str) -> str:
out = path
for key, value in _PATH_SUBS.items():
out = out.replace(key, value)
return re.sub(r"\{([A-Za-z0-9_]+)\}", r"probe-\1", out)
def request(
method: str,
path: str,
*,
headers: dict[str, str],
data: bytes | None = None,
) -> tuple[int, str]:
# Paths may already include query strings from _payload_for.
if "?" in path:
base_path, query = path.split("?", 1)
url = f"{_base()}{concrete_path(base_path)}?{query}"
else:
url = f"{_base()}{concrete_path(path)}"
req = urllib.request.Request(url, data=data, method=method, headers=headers)
try:
with urllib.request.urlopen(req, context=_ctx(), timeout=120) as resp: # noqa: S310
body = resp.read().decode("utf-8", errors="replace")
return int(resp.status), body
except urllib.error.HTTPError as error:
body = error.read().decode("utf-8", errors="replace")
return int(error.code), body
def login() -> str:
user, password = _creds()
basic = b64encode(f"{user}:{password}".encode()).decode()
code, body = request("POST", "/api/session", headers={"Authorization": f"Basic {basic}"})
if code not in {200, 201}:
raise RuntimeError(f"session failed: {code} {body[:200]}")
return json.loads(body)
def _payload_for(verb: str, path: str) -> tuple[str, bytes | None]:
if verb not in {"POST", "PUT", "PATCH"}:
return path, None
if path.endswith("/power") and verb == "POST":
if "/guest/power" in path:
return f"{path}?action=reboot", b"{}"
return f"{path}?action=start", b"{}"
if path.endswith("/maintenance") and verb == "POST":
return f"{path}?action=enter", b"{}"
if path.endswith("/folder/{folder}") and verb == "POST":
return f"{path}?action=rename", json.dumps({"name": "folder-renamed-probe"}).encode()
suffix = secrets.token_hex(4)
bodies: dict[str, dict[str, Any]] = {
"/api/vcenter/vm": {
"name": f"matrix-probe-vm-{suffix}",
"placement": {"folder": "group-v23", "host": "host-11", "datastore": "datastore-31"},
"cpu_count": 1,
"memory_size_MiB": 512,
},
"/api/vcenter/datacenter": {"name": f"probe-dc-{suffix}"},
"/api/vcenter/cluster": {"name": f"probe-cluster-{suffix}"},
"/api/vcenter/folder": {"name": f"probe-folder-{suffix}", "parent": "group-v23"},
"/api/vcenter/resource-pool": {"name": f"probe-rp-{suffix}", "parent": "resgroup-22"},
"/api/vcenter/network/dvs": {"name": f"probe-dvs-{suffix}"},
"/api/vcenter/network/dvpg": {
"name": f"probe-dvpg-{suffix}",
"dvs": "dvs-51",
"vlan_id": 10,
},
"/api/cis/tagging/category": {
"create_spec": {
"name": f"probe-cat-{suffix}",
"description": "probe",
"cardinality": "MULTIPLE",
"associable_types": [],
}
},
"/api/cis/tagging/tag": {
"create_spec": {
"name": f"probe-tag-{suffix}",
"category_id": "missing-category",
"description": "x",
}
},
"/api/cis/tagging/tag-association": {
"action": "list-attached-tags",
"tag_id": "x",
"object_id": {"type": "VirtualMachine", "id": "vm-101"},
},
"/api/content/local-library": {"create_spec": {"name": f"probe-lib-{suffix}"}},
"/api/content/library/item": {
"create_spec": {
"library_id": "lib-missing",
"name": f"probe-item-{suffix}",
"type": "ovf",
}
},
"/api/vcenter/ovf/library-item/{item_id}": {
"deployment_spec": {"name": f"ovf-probe-{suffix}"},
"target": {"folder": "group-v23", "host": "host-11", "datastore": "datastore-31"},
},
"/api/vcenter/authorization/permissions": {
"principal": "readonly@vsphere.local",
"role": "ReadOnly",
"entity": "datacenter-21",
},
"/api/vcenter/datastore/{datastore}/files": {
"path": f"/probe-{suffix}.txt",
"size": 1,
"type": "FILE",
},
"/api/vcenter/vm/{vm}/hardware/cpu": {"count": 2},
"/api/vcenter/vm/{vm}/hardware/memory": {"size_MiB": 1024},
"/api/vcenter/vm/{vm}/hardware/disk": {"type": "SCSI", "new_vmdk": {"capacity": 1024}},
"/api/vcenter/vm/{vm}/hardware/ethernet": {
"type": "VMXNET3",
"backing": {"type": "STANDARD_PORTGROUP", "network": "network-41"},
},
"/api/vcenter/vm/{vm}/snapshots": {"name": f"probe-snap-{suffix}"},
"/api/vcenter/vm/{vm}/snapshots/{snapshot}": {"action": "revert"},
"/api/vcenter/vm/{vm}/clone": {
"name": f"probe-clone-{suffix}",
"placement": {"folder": "group-v23", "host": "host-11"},
},
"/api/vcenter/vm/{vm}/relocate": {"placement": {"host": "host-12"}},
"/api/vcenter/vm/{vm}/tools": {"action": "upgrade"},
"/api/vcenter/vm/{vm}/console/tickets": {"type": "WEBMKS"},
"/api/vcenter/vm/{vm}/guest/customization": {"name": {"name": f"guest-probe-{suffix}"}},
"/api/vcenter/vm/{vm}": {"action": "unregister"},
}
body = bodies.get(path, {})
return path, json.dumps(body).encode()
def apply_major(major: int, headers: dict[str, str]) -> dict[str, Any]:
params = urlencode({"major": major})
code, body = request(
"POST",
f"/ui/api/contract/apply?{params}",
headers=headers,
)
if code >= 400:
raise RuntimeError(f"contract apply major={major} failed: {code} {body[:200]}")
return json.loads(body)
def _classify(verb: str, path: str) -> str:
status = CORE_IMPLEMENTED.get((verb, path)) or IMPLEMENTED.get((verb, path))
if (verb, path) in CORE_IMPLEMENTED:
return "deep"
if status == "stub":
return "stub"
return "deep" if status == "implemented" else "unknown"
def _record_result(
*,
major: int,
verb: str,
path: str,
code: int,
body: str,
buckets: Counter[str],
failures: list[dict[str, Any]],
deep_stub: Counter[str],
) -> None:
kind = _classify(verb, path)
deep_stub[kind] += 1
if 200 <= code < 300:
buckets["success_2xx"] += 1
if kind == "stub":
buckets["stub_ok"] += 1
if major == 9 and verb == "GET" and body:
if '"stub": true' in body or '"stub":true' in body:
if path in _INVENTORY_CRITICAL or kind == "deep":
buckets["stub_marker"] += 1
failures.append(
{
"major": major,
"verb": verb,
"path": path,
"kind": kind,
"status": code,
"body": body[:200],
"expected": "non-stub JSON",
}
)
elif path in _INVENTORY_CRITICAL:
try:
parsed = json.loads(body)
except json.JSONDecodeError:
parsed = None
empty = parsed in ([], {}, None, "")
if empty:
buckets["empty_inventory"] += 1
failures.append(
{
"major": major,
"verb": verb,
"path": path,
"kind": kind,
"status": code,
"body": body[:200],
"expected": "non-empty seeded data",
}
)
elif code in _ACCEPT_CLIENT:
buckets["client_4xx"] += 1
elif code == 501:
buckets["unexpected_501"] += 1
failures.append(
{
"major": major,
"verb": verb,
"path": path,
"kind": kind,
"status": code,
"body": body[:200],
}
)
elif code >= 500:
buckets["server_5xx"] += 1
failures.append(
{
"major": major,
"verb": verb,
"path": path,
"kind": kind,
"status": code,
"body": body[:200],
}
)
else:
buckets[f"other_{code}"] += 1
failures.append(
{
"major": major,
"verb": verb,
"path": path,
"kind": kind,
"status": code,
"body": body[:200],
}
)
def probe_major(major: int, session: str) -> dict[str, Any]:
headers = {
"vmware-api-session-id": session,
"Content-Type": "application/json",
"Accept": "application/json",
}
applied = apply_major(major, headers)
active = methods_for_major(major)
verb_order = {"GET": 0, "PUT": 1, "PATCH": 2, "POST": 3, "DELETE": 4}
entries = sorted(
catalog_entries_for_major(major),
key=lambda e: (verb_order.get(e["verb"], 9), e["path"]),
)
buckets: Counter[str] = Counter()
deep_stub: Counter[str] = Counter()
failures: list[dict[str, Any]] = []
probed = 0
by_verb: Counter[str] = Counter()
for entry in entries:
verb = entry["verb"]
path = entry["path"]
by_verb[verb] += 1
if verb == "DELETE" and path in {"/api/session", "/rest/com/vmware/cis/session"}:
continue
if verb == "DELETE" and path in {
"/api/vcenter/datacenter/{datacenter}",
"/api/vcenter/cluster/{cluster}",
"/api/vcenter/folder/{folder}",
"/api/vcenter/resource-pool/{resource_pool}",
"/api/vcenter/vm/{vm}",
}:
if path.endswith("{vm}"):
url_path = path.replace("{vm}", "vm-missing-matrix")
elif path.endswith("{datacenter}"):
url_path = path.replace("{datacenter}", "dc-missing")
elif path.endswith("{cluster}"):
url_path = path.replace("{cluster}", "cluster-missing")
elif path.endswith("{folder}"):
url_path = path.replace("{folder}", "folder-missing")
else:
url_path = path.replace("{resource_pool}", "rp-missing")
code, body = request(verb, url_path, headers=headers)
else:
url_path, data = _payload_for(verb, path)
if verb == "GET" and path == "/api/content/library/item":
url_path = f"{url_path}?library_id=lib-local-1"
code, body = request(verb, url_path, headers=headers, data=data)
probed += 1
_record_result(
major=major,
verb=verb,
path=path,
code=code,
body=body,
buckets=buckets,
failures=failures,
deep_stub=deep_stub,
)
above_floor = 0
for (verb, path), _status in sorted(IMPLEMENTED.items()):
if (verb, path) in active:
continue
if verb == "DELETE" and path in {"/api/session", "/rest/com/vmware/cis/session"}:
continue
url_path, data = _payload_for(verb, path)
code, body = request(verb, url_path, headers=headers, data=data)
above_floor += 1
probed += 1
by_verb[verb] += 1
_record_result(
major=major,
verb=verb,
path=path,
code=code,
body=body,
buckets=buckets,
failures=failures,
deep_stub=deep_stub,
)
return {
"major": major,
"version": applied.get("runtime_version"),
"method_count": len(entries),
"by_verb": dict(by_verb),
"probed": probed,
"above_floor_checked": above_floor,
"buckets": dict(buckets),
"deep_vs_stub": dict(deep_stub),
"failures": failures,
"failed": len(failures),
}
def run_rest_matrix(*, majors: list[int] | None = None) -> dict[str, Any]:
"""Probe IMPLEMENTED × majors. Returns summary suitable for suite JSON/HTML."""
if majors is None:
majors = [6, 7, 8, 9]
for major in majors:
if major not in VERSIONS:
raise ValueError(f"unknown major {major}")
session = login()
reports: list[dict[str, Any]] = []
all_failures: list[dict[str, Any]] = []
verb_totals: Counter[str] = Counter()
for major in majors:
report = probe_major(major, session)
reports.append(report)
all_failures.extend(report["failures"])
for verb, count in report["by_verb"].items():
verb_totals[verb] += count
session = login()
apply_major(9, {"vmware-api-session-id": session, "Content-Type": "application/json"})
total = sum(r["probed"] for r in reports)
failed = len(all_failures)
return {
"base": _base(),
"majors": reports,
"by_verb": dict(verb_totals),
"total": total,
"failed": failed,
"failures": all_failures[:120],
"ok": failed == 0,
}
+648
View File
@@ -0,0 +1,648 @@
"""SOAP WSDL ops probe for pulumi-tests hybrid suite.
Covers every operation advertised in /sdk/vimService.wsdl (same list as
app/vsphere/soap/router.py). Fail on HTTP 5xx. Create/Power/Clone/Reconfig/Destroy
ops additionally assert task return + inventory side-effects via FindByInventoryPath
or RetrieveProperties where applicable.
"""
from __future__ import annotations
import os
import re
import secrets
import ssl
import urllib.error
import urllib.request
from typing import Any
from xml.sax.saxutils import escape
# Keep in sync with app/vsphere/soap/router.py sdk_wsdl ops list.
WSDL_OPS: list[str] = [
"RetrieveServiceContent",
"Login",
"Logout",
"RetrieveProperties",
"RetrievePropertiesEx",
"ContinueRetrievePropertiesEx",
"CreateFilter",
"WaitForUpdatesEx",
"CreateContainerView",
"DestroyPropertyFilter",
"FindByInventoryPath",
"FindByUuid",
"FindByDnsName",
"FindByIp",
"FindChild",
"CreateVM_Task",
"CreateChildVM_Task",
"CreateFolder",
"PowerOnVM_Task",
"PowerOffVM_Task",
"CloneVM_Task",
"CreateSnapshot_Task",
"Rename_Task",
"ReconfigVM_Task",
"RelocateVM_Task",
"Destroy_Task",
"CustomizeVM_Task",
"CancelTask",
"CurrentTime",
"InitiateFileTransferToGuest",
"InitiateFileTransferFromGuest",
"ListFilesInGuest",
"DeleteFileInGuest",
"MakeDirectoryInGuest",
"ImportVApp_Task",
"CreateImportSpec",
"HttpNfcLeaseComplete",
"HttpNfcLeaseProgress",
"HttpNfcLeaseAbort",
"HttpNfcLeaseGetManifest",
"QueryConfigOption",
"QueryConfigOptionEx",
"QueryConfigOptionDescriptor",
"QueryConfigTarget",
]
def _base() -> str:
explicit = os.environ.get("VSPHERE_BASE")
if explicit:
return explicit.rstrip("/")
server = os.environ.get("VSPHERE_SERVER", "localhost")
if server.startswith("http://") or server.startswith("https://"):
return server.rstrip("/")
return f"https://{server}"
def _creds() -> tuple[str, str]:
return (
os.environ.get("VSPHERE_USER", "administrator@vsphere.local"),
os.environ.get("VSPHERE_PASSWORD", "VMware1!"),
)
def _ctx() -> ssl.SSLContext | None:
if not _base().startswith("https://"):
return None
return ssl._create_unverified_context() # noqa: S323
def _envelope(inner: str) -> str:
return (
'<?xml version="1.0" encoding="UTF-8"?>'
'<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/"'
' xmlns:urn="urn:vim25">'
f"<soapenv:Body>{inner}</soapenv:Body>"
"</soapenv:Envelope>"
)
def _post(
body: str, *, cookie: str | None = None, session_id: str | None = None
) -> tuple[int, str, dict[str, str]]:
headers = {
"Content-Type": 'text/xml; charset="utf-8"',
"SOAPAction": '""',
}
if cookie:
headers["Cookie"] = cookie
if session_id:
headers["vmware-api-session-id"] = session_id
req = urllib.request.Request(
f"{_base()}/sdk",
data=body.encode(),
method="POST",
headers=headers,
)
try:
with urllib.request.urlopen(req, context=_ctx(), timeout=120) as resp: # noqa: S310
raw = resp.read().decode("utf-8", errors="replace")
return int(resp.status), raw, {k.lower(): v for k, v in resp.headers.items()}
except urllib.error.HTTPError as error:
raw = error.read().decode("utf-8", errors="replace")
return int(error.code), raw, {k.lower(): v for k, v in error.headers.items()}
def _xml_text(body: str, tag: str) -> str | None:
match = re.search(rf"<(?:\w+:)?{re.escape(tag)}[^>]*>([^<]*)</(?:\w+:)?{re.escape(tag)}>", body)
return match.group(1) if match else None
def _task_id(body: str) -> str | None:
match = re.search(r'type="Task">([^<]+)<', body) or re.search(r">(task-[^<]+)<", body)
return match.group(1) if match else None
def _login() -> tuple[str, str]:
user, password = _creds()
code, body, headers = _post(
_envelope(
"<urn:Login>"
'<urn:_this type="SessionManager">SessionManager</urn:_this>'
f"<urn:userName>{escape(user)}</urn:userName>"
f"<urn:password>{escape(password)}</urn:password>"
"</urn:Login>"
)
)
if code >= 500:
raise RuntimeError(f"SOAP Login 5xx: {code} {body[:200]}")
if code >= 400:
raise RuntimeError(f"SOAP Login failed: {code} {body[:200]}")
set_cookie = headers.get("set-cookie") or ""
cookie = set_cookie.split(";")[0] if set_cookie else ""
session_id = headers.get("vmware-api-session-id") or ""
if "vmware_soap_session" in set_cookie and not cookie.startswith("vmware_soap_session"):
# normalize
for part in set_cookie.split(","):
part = part.strip()
if part.startswith("vmware_soap_session"):
cookie = part.split(";")[0]
break
if not cookie and session_id:
cookie = f'vmware_soap_session="{session_id}"'
if not cookie and not session_id:
# body may still indicate success — use header-less cookie from LoginResponse key
key = _xml_text(body, "key")
if key:
cookie = f'vmware_soap_session="{key}"'
session_id = key
if not cookie and not session_id:
raise RuntimeError(f"SOAP Login missing session: {body[:200]}")
return cookie, session_id
def _op_body(op: str, *, suffix: str, lab_vm: str = "vm-101") -> str:
"""Minimal SOAP body for each WSDL op."""
if op == "RetrieveServiceContent":
return (
"<urn:RetrieveServiceContent>"
'<urn:_this type="ServiceInstance">ServiceInstance</urn:_this>'
"</urn:RetrieveServiceContent>"
)
if op == "Login":
user, password = _creds()
return (
"<urn:Login>"
'<urn:_this type="SessionManager">SessionManager</urn:_this>'
f"<urn:userName>{escape(user)}</urn:userName>"
f"<urn:password>{escape(password)}</urn:password>"
"</urn:Login>"
)
if op == "Logout":
return (
'<urn:Logout><urn:_this type="SessionManager">SessionManager</urn:_this></urn:Logout>'
)
if op in {"RetrieveProperties", "RetrievePropertiesEx"}:
return (
f"<urn:{op}>"
'<urn:_this type="PropertyCollector">propertyCollector</urn:_this>'
"<urn:specSet>"
"<urn:propSet><urn:type>VirtualMachine</urn:type><urn:pathSet>name</urn:pathSet></urn:propSet>"
'<urn:objectSet><urn:obj type="VirtualMachine">vm-101</urn:obj></urn:objectSet>'
"</urn:specSet>"
f"</urn:{op}>"
)
if op == "ContinueRetrievePropertiesEx":
return (
"<urn:ContinueRetrievePropertiesEx>"
'<urn:_this type="PropertyCollector">propertyCollector</urn:_this>'
"<urn:token>token-none</urn:token>"
"</urn:ContinueRetrievePropertiesEx>"
)
if op == "CreateFilter":
return (
"<urn:CreateFilter>"
'<urn:_this type="PropertyCollector">propertyCollector</urn:_this>'
"<urn:spec>"
"<urn:propSet><urn:type>Folder</urn:type><urn:all>true</urn:all></urn:propSet>"
'<urn:objectSet><urn:obj type="Folder">group-d1</urn:obj></urn:objectSet>'
"</urn:spec>"
"<urn:partialUpdates>false</urn:partialUpdates>"
"</urn:CreateFilter>"
)
if op == "WaitForUpdatesEx":
return (
"<urn:WaitForUpdatesEx>"
'<urn:_this type="PropertyCollector">propertyCollector</urn:_this>'
"<urn:version></urn:version>"
"</urn:WaitForUpdatesEx>"
)
if op == "CreateContainerView":
return (
"<urn:CreateContainerView>"
'<urn:_this type="ViewManager">ViewManager</urn:_this>'
'<urn:container type="Folder">group-d1</urn:container>'
"<urn:type>VirtualMachine</urn:type>"
"<urn:recursive>true</urn:recursive>"
"</urn:CreateContainerView>"
)
if op == "DestroyPropertyFilter":
return (
"<urn:DestroyPropertyFilter>"
'<urn:_this type="PropertyFilter">filter-1</urn:_this>'
"</urn:DestroyPropertyFilter>"
)
if op == "FindByInventoryPath":
return (
"<urn:FindByInventoryPath>"
'<urn:_this type="SearchIndex">SearchIndex</urn:_this>'
"<urn:inventoryPath>/Datacenter/vm/web-01</urn:inventoryPath>"
"</urn:FindByInventoryPath>"
)
if op == "FindByUuid":
return (
"<urn:FindByUuid>"
'<urn:_this type="SearchIndex">SearchIndex</urn:_this>'
"<urn:uuid>00000000-0000-0000-0000-000000000000</urn:uuid>"
"<urn:vmSearch>true</urn:vmSearch>"
"</urn:FindByUuid>"
)
if op == "FindByDnsName":
return (
"<urn:FindByDnsName>"
'<urn:_this type="SearchIndex">SearchIndex</urn:_this>'
"<urn:dnsName>web-01.lab.local</urn:dnsName>"
"<urn:vmSearch>true</urn:vmSearch>"
"</urn:FindByDnsName>"
)
if op == "FindByIp":
return (
"<urn:FindByIp>"
'<urn:_this type="SearchIndex">SearchIndex</urn:_this>'
"<urn:ip>10.0.0.10</urn:ip>"
"<urn:vmSearch>true</urn:vmSearch>"
"</urn:FindByIp>"
)
if op == "FindChild":
return (
"<urn:FindChild>"
'<urn:_this type="SearchIndex">SearchIndex</urn:_this>'
'<urn:entity type="Folder">group-v23</urn:entity>'
"<urn:name>web-01</urn:name>"
"</urn:FindChild>"
)
if op == "CreateVM_Task":
return (
"<urn:CreateVM_Task>"
'<urn:_this type="Folder">group-v23</urn:_this>'
"<urn:config>"
f"<urn:name>soap-create-{suffix}</urn:name>"
"<urn:guestId>otherGuest64</urn:guestId>"
"<urn:numCPUs>1</urn:numCPUs>"
"<urn:memoryMB>512</urn:memoryMB>"
"<urn:files><urn:vmPathName>[datastore1]</urn:vmPathName></urn:files>"
"</urn:config>"
'<urn:pool type="ResourcePool">resgroup-22</urn:pool>'
'<urn:host type="HostSystem">host-11</urn:host>'
"</urn:CreateVM_Task>"
)
if op == "CreateChildVM_Task":
return (
"<urn:CreateChildVM_Task>"
'<urn:_this type="ResourcePool">resgroup-22</urn:_this>'
"<urn:config>"
f"<urn:name>soap-child-{suffix}</urn:name>"
"<urn:guestId>otherGuest64</urn:guestId>"
"<urn:numCPUs>1</urn:numCPUs>"
"<urn:memoryMB>512</urn:memoryMB>"
"<urn:files><urn:vmPathName>[datastore1]</urn:vmPathName></urn:files>"
"</urn:config>"
'<urn:host type="HostSystem">host-11</urn:host>'
"</urn:CreateChildVM_Task>"
)
if op == "CreateFolder":
return (
"<urn:CreateFolder>"
'<urn:_this type="Folder">group-v23</urn:_this>'
f"<urn:name>soap-folder-{suffix}</urn:name>"
"</urn:CreateFolder>"
)
if op == "PowerOnVM_Task":
return (
"<urn:PowerOnVM_Task>"
f'<urn:_this type="VirtualMachine">{lab_vm}</urn:_this>'
"</urn:PowerOnVM_Task>"
)
if op == "PowerOffVM_Task":
return (
"<urn:PowerOffVM_Task>"
f'<urn:_this type="VirtualMachine">{lab_vm}</urn:_this>'
"</urn:PowerOffVM_Task>"
)
if op == "CloneVM_Task":
return (
"<urn:CloneVM_Task>"
f'<urn:_this type="VirtualMachine">{lab_vm}</urn:_this>'
'<urn:folder type="Folder">group-v23</urn:folder>'
f"<urn:name>soap-clone-{suffix}</urn:name>"
"<urn:spec><urn:powerOn>false</urn:powerOn><urn:template>false</urn:template></urn:spec>"
"</urn:CloneVM_Task>"
)
if op == "CreateSnapshot_Task":
return (
"<urn:CreateSnapshot_Task>"
f'<urn:_this type="VirtualMachine">{lab_vm}</urn:_this>'
f"<urn:name>soap-snap-{suffix}</urn:name>"
"<urn:description>probe</urn:description>"
"<urn:memory>false</urn:memory>"
"<urn:quiesce>false</urn:quiesce>"
"</urn:CreateSnapshot_Task>"
)
if op == "Rename_Task":
return (
"<urn:Rename_Task>"
f'<urn:_this type="VirtualMachine">{lab_vm}</urn:_this>'
f"<urn:newName>web-01-renamed-{suffix}</urn:newName>"
"</urn:Rename_Task>"
)
if op == "ReconfigVM_Task":
return (
"<urn:ReconfigVM_Task>"
f'<urn:_this type="VirtualMachine">{lab_vm}</urn:_this>'
"<urn:spec><urn:numCPUs>2</urn:numCPUs></urn:spec>"
"</urn:ReconfigVM_Task>"
)
if op == "RelocateVM_Task":
return (
"<urn:RelocateVM_Task>"
f'<urn:_this type="VirtualMachine">{lab_vm}</urn:_this>'
"<urn:spec>"
'<urn:host type="HostSystem">host-11</urn:host>'
'<urn:datastore type="Datastore">datastore-31</urn:datastore>'
"</urn:spec>"
"</urn:RelocateVM_Task>"
)
if op == "Destroy_Task":
return (
"<urn:Destroy_Task>"
f'<urn:_this type="VirtualMachine">{lab_vm}</urn:_this>'
"</urn:Destroy_Task>"
)
if op == "CustomizeVM_Task":
return (
"<urn:CustomizeVM_Task>"
f'<urn:_this type="VirtualMachine">{lab_vm}</urn:_this>'
"<urn:spec><urn:identity/></urn:spec>"
"</urn:CustomizeVM_Task>"
)
if op == "CancelTask":
return '<urn:CancelTask><urn:_this type="Task">task-1</urn:_this></urn:CancelTask>'
if op == "CurrentTime":
return (
"<urn:CurrentTime>"
'<urn:_this type="ServiceInstance">ServiceInstance</urn:_this>'
"</urn:CurrentTime>"
)
if op in {
"InitiateFileTransferToGuest",
"InitiateFileTransferFromGuest",
"ListFilesInGuest",
"DeleteFileInGuest",
"MakeDirectoryInGuest",
}:
return (
f"<urn:{op}>"
f'<urn:_this type="GuestFileManager">guestFileManager-{lab_vm}</urn:_this>'
f'<urn:vm type="VirtualMachine">{lab_vm}</urn:vm>'
"<urn:auth><urn:username>root</urn:username><urn:password>lab</urn:password></urn:auth>"
f"<urn:filePath>/tmp/soap-{suffix}</urn:filePath>"
f"</urn:{op}>"
)
if op == "ImportVApp_Task":
return (
"<urn:ImportVApp_Task>"
'<urn:_this type="ResourcePool">resgroup-22</urn:_this>'
"<urn:spec/>"
'<urn:folder type="Folder">group-v23</urn:folder>'
'<urn:host type="HostSystem">host-11</urn:host>'
"</urn:ImportVApp_Task>"
)
if op == "CreateImportSpec":
return (
"<urn:CreateImportSpec>"
'<urn:_this type="OvfManager">OvfManager</urn:_this>'
"<urn:ovfDescriptor>unused</urn:ovfDescriptor>"
'<urn:resourcePool type="ResourcePool">resgroup-22</urn:resourcePool>'
'<urn:datastore type="Datastore">datastore-31</urn:datastore>'
"</urn:CreateImportSpec>"
)
if op in {
"HttpNfcLeaseComplete",
"HttpNfcLeaseProgress",
"HttpNfcLeaseAbort",
"HttpNfcLeaseGetManifest",
}:
return (
f"<urn:{op}>"
'<urn:_this type="HttpNfcLease">lease-1</urn:_this>'
"<urn:percent>100</urn:percent>"
f"</urn:{op}>"
)
if op in {
"QueryConfigOption",
"QueryConfigOptionEx",
"QueryConfigOptionDescriptor",
"QueryConfigTarget",
}:
return f'<urn:{op}><urn:_this type="EnvironmentBrowser">envbrowser-1</urn:_this></urn:{op}>'
return f'<urn:{op}><urn:_this type="ServiceInstance">ServiceInstance</urn:_this></urn:{op}>'
def _find_by_path(cookie: str, session_id: str, path: str) -> tuple[int, str]:
code, body, _ = _post(
_envelope(
"<urn:FindByInventoryPath>"
'<urn:_this type="SearchIndex">SearchIndex</urn:_this>'
f"<urn:inventoryPath>{escape(path)}</urn:inventoryPath>"
"</urn:FindByInventoryPath>"
),
cookie=cookie,
session_id=session_id,
)
return code, body
def _verify_side_effect(
op: str,
response: str,
*,
cookie: str,
session_id: str,
suffix: str,
) -> str | None:
"""Return error string if side-effect check fails; None if ok/not applicable."""
if op in {
"CreateVM_Task",
"CreateChildVM_Task",
"CloneVM_Task",
"PowerOnVM_Task",
"PowerOffVM_Task",
"Destroy_Task",
"ReconfigVM_Task",
"CreateFolder",
}:
if op.endswith("_Task") and not _task_id(response) and "Task" not in response:
return "missing Task returnval"
if op in {"CreateVM_Task", "CreateChildVM_Task"}:
name = f"soap-create-{suffix}" if op == "CreateVM_Task" else f"soap-child-{suffix}"
code, body = _find_by_path(cookie, session_id, f"/Datacenter/vm/{name}")
if code >= 500:
return f"FindByInventoryPath 5xx after {op}"
if "VirtualMachine" not in body and name not in body:
# Some seeds place under production folder — also try that path.
code2, body2 = _find_by_path(cookie, session_id, f"/Datacenter/vm/production/{name}")
if "VirtualMachine" not in body2 and name not in body2:
return f"created VM {name} not found in inventory"
if op == "CreateFolder":
folder_moid = _xml_text(response, "returnval")
if not folder_moid:
return "CreateFolder missing Folder returnval"
if op == "CloneVM_Task":
name = f"soap-clone-{suffix}"
code, body = _find_by_path(cookie, session_id, f"/Datacenter/vm/{name}")
if code >= 500:
return f"FindByInventoryPath 5xx after clone"
if "VirtualMachine" not in body and name not in body:
code2, body2 = _find_by_path(cookie, session_id, f"/Datacenter/vm/production/{name}")
if "VirtualMachine" not in body2 and name not in body2:
return f"clone {name} not found"
if op == "Destroy_Task":
# Destroy uses a disposable VM created earlier in the suite — checked by caller via lab_vm.
pass
return None
def run_soap_ops() -> dict[str, Any]:
"""Exercise all WSDL SOAP ops. Mutating ops use disposable VMs where needed."""
cookie, session_id = _login()
results: list[dict[str, Any]] = []
failures: list[dict[str, Any]] = []
# Disposable VM for destroy / power cycles (do not destroy seeded web-01).
suffix = secrets.token_hex(3)
create_body = _envelope(_op_body("CreateVM_Task", suffix=f"lab-{suffix}"))
code, body, _ = _post(create_body, cookie=cookie, session_id=session_id)
disposable_vm = "vm-101"
if code < 500 and _task_id(body):
# Resolve created VM name via FindByInventoryPath
name = f"soap-create-lab-{suffix}"
_, found = _find_by_path(cookie, session_id, f"/Datacenter/vm/{name}")
moid = re.search(r'type="VirtualMachine">([^<]+)<', found)
if moid:
disposable_vm = moid.group(1)
else:
_, found2 = _find_by_path(cookie, session_id, f"/Datacenter/vm/production/{name}")
moid = re.search(r'type="VirtualMachine">([^<]+)<', found2)
if moid:
disposable_vm = moid.group(1)
# Prefer a clone as destroy target so we never delete the only disposable if create failed.
clone_suffix = secrets.token_hex(3)
clone_body = _envelope(_op_body("CloneVM_Task", suffix=clone_suffix, lab_vm=disposable_vm))
code, body, _ = _post(clone_body, cookie=cookie, session_id=session_id)
destroy_target = disposable_vm
if code < 500:
cname = f"soap-clone-{clone_suffix}"
_, found = _find_by_path(cookie, session_id, f"/Datacenter/vm/{cname}")
moid = re.search(r'type="VirtualMachine">([^<]+)<', found)
if not moid:
_, found = _find_by_path(cookie, session_id, f"/Datacenter/vm/production/{cname}")
moid = re.search(r'type="VirtualMachine">([^<]+)<', found)
if moid:
destroy_target = moid.group(1)
for op in WSDL_OPS:
op_suffix = secrets.token_hex(3)
lab_vm = destroy_target if op == "Destroy_Task" else disposable_vm
# Avoid Logout killing the suite session mid-run — probe with a fresh login at end.
if op == "Logout":
tmp_cookie, tmp_sid = _login()
code, body, _ = _post(
_envelope(_op_body(op, suffix=op_suffix, lab_vm=lab_vm)),
cookie=tmp_cookie,
session_id=tmp_sid,
)
elif op == "Login":
code, body, _ = _post(_envelope(_op_body(op, suffix=op_suffix)))
elif op == "Rename_Task":
# Rename disposable VM then rename back via another call is heavy; use folder instead.
folder_code, folder_body, _ = _post(
_envelope(_op_body("CreateFolder", suffix=f"rn-{op_suffix}")),
cookie=cookie,
session_id=session_id,
)
folder_id = _xml_text(folder_body, "returnval") or "group-v23"
if folder_code >= 500:
code, body = folder_code, folder_body
else:
code, body, _ = _post(
_envelope(
"<urn:Rename_Task>"
f'<urn:_this type="Folder">{folder_id}</urn:_this>'
f"<urn:newName>soap-renamed-{op_suffix}</urn:newName>"
"</urn:Rename_Task>"
),
cookie=cookie,
session_id=session_id,
)
else:
code, body, _ = _post(
_envelope(_op_body(op, suffix=op_suffix, lab_vm=lab_vm)),
cookie=cookie,
session_id=session_id,
)
entry: dict[str, Any] = {
"op": op,
"status": code,
"ok": True,
"error": "",
}
if code >= 500:
entry["ok"] = False
entry["error"] = f"HTTP {code}: {body[:200]}"
else:
side = _verify_side_effect(
op,
body,
cookie=cookie,
session_id=session_id,
suffix=op_suffix if op != "CreateVM_Task" else op_suffix,
)
# CreateVM_Task in the loop creates yet another VM — verify with its suffix.
if (
op in {"CreateVM_Task", "CreateChildVM_Task", "CloneVM_Task", "CreateFolder"}
and side
):
entry["ok"] = False
entry["error"] = side
elif op in {"PowerOnVM_Task", "PowerOffVM_Task", "ReconfigVM_Task", "Destroy_Task"}:
if not _task_id(body) and "Task" not in body and "Response" not in body:
entry["ok"] = False
entry["error"] = "missing task/response"
elif op == "Destroy_Task":
# Confirm target is gone
_, found = _find_by_path(
cookie, session_id, f"/Datacenter/vm/soap-clone-{clone_suffix}"
)
if 'type="VirtualMachine"' in found and destroy_target in found:
entry["ok"] = False
entry["error"] = "VM still present after Destroy_Task"
if not entry["ok"]:
failures.append(entry)
results.append(entry)
return {
"ops": results,
"total": len(results),
"failed": len(failures),
"failures": failures,
"ok": not failures,
"wsdl_ops": len(WSDL_OPS),
}