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:
@@ -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),
|
||||
}
|
||||
Reference in New Issue
Block a user