Files
inecs f8d3cbdd59 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.
2026-07-18 04:42:11 +03:00

649 lines
24 KiB
Python

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