"""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 (
''
''
f"{inner}"
""
)
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(
""
'SessionManager'
f"{escape(user)}"
f"{escape(password)}"
""
)
)
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 (
""
'ServiceInstance'
""
)
if op == "Login":
user, password = _creds()
return (
""
'SessionManager'
f"{escape(user)}"
f"{escape(password)}"
""
)
if op == "Logout":
return (
'SessionManager'
)
if op in {"RetrieveProperties", "RetrievePropertiesEx"}:
return (
f""
'propertyCollector'
""
"VirtualMachinename"
'vm-101'
""
f""
)
if op == "ContinueRetrievePropertiesEx":
return (
""
'propertyCollector'
"token-none"
""
)
if op == "CreateFilter":
return (
""
'propertyCollector'
""
"Foldertrue"
'group-d1'
""
"false"
""
)
if op == "WaitForUpdatesEx":
return (
""
'propertyCollector'
""
""
)
if op == "CreateContainerView":
return (
""
'ViewManager'
'group-d1'
"VirtualMachine"
"true"
""
)
if op == "DestroyPropertyFilter":
return (
""
'filter-1'
""
)
if op == "FindByInventoryPath":
return (
""
'SearchIndex'
"/Datacenter/vm/web-01"
""
)
if op == "FindByUuid":
return (
""
'SearchIndex'
"00000000-0000-0000-0000-000000000000"
"true"
""
)
if op == "FindByDnsName":
return (
""
'SearchIndex'
"web-01.lab.local"
"true"
""
)
if op == "FindByIp":
return (
""
'SearchIndex'
"10.0.0.10"
"true"
""
)
if op == "FindChild":
return (
""
'SearchIndex'
'group-v23'
"web-01"
""
)
if op == "CreateVM_Task":
return (
""
'group-v23'
""
f"soap-create-{suffix}"
"otherGuest64"
"1"
"512"
"[datastore1]"
""
'resgroup-22'
'host-11'
""
)
if op == "CreateChildVM_Task":
return (
""
'resgroup-22'
""
f"soap-child-{suffix}"
"otherGuest64"
"1"
"512"
"[datastore1]"
""
'host-11'
""
)
if op == "CreateFolder":
return (
""
'group-v23'
f"soap-folder-{suffix}"
""
)
if op == "PowerOnVM_Task":
return (
""
f'{lab_vm}'
""
)
if op == "PowerOffVM_Task":
return (
""
f'{lab_vm}'
""
)
if op == "CloneVM_Task":
return (
""
f'{lab_vm}'
'group-v23'
f"soap-clone-{suffix}"
"falsefalse"
""
)
if op == "CreateSnapshot_Task":
return (
""
f'{lab_vm}'
f"soap-snap-{suffix}"
"probe"
"false"
"false"
""
)
if op == "Rename_Task":
return (
""
f'{lab_vm}'
f"web-01-renamed-{suffix}"
""
)
if op == "ReconfigVM_Task":
return (
""
f'{lab_vm}'
"2"
""
)
if op == "RelocateVM_Task":
return (
""
f'{lab_vm}'
""
'host-11'
'datastore-31'
""
""
)
if op == "Destroy_Task":
return (
""
f'{lab_vm}'
""
)
if op == "CustomizeVM_Task":
return (
""
f'{lab_vm}'
""
""
)
if op == "CancelTask":
return 'task-1'
if op == "CurrentTime":
return (
""
'ServiceInstance'
""
)
if op in {
"InitiateFileTransferToGuest",
"InitiateFileTransferFromGuest",
"ListFilesInGuest",
"DeleteFileInGuest",
"MakeDirectoryInGuest",
}:
return (
f""
f'guestFileManager-{lab_vm}'
f'{lab_vm}'
"rootlab"
f"/tmp/soap-{suffix}"
f""
)
if op == "ImportVApp_Task":
return (
""
'resgroup-22'
""
'group-v23'
'host-11'
""
)
if op == "CreateImportSpec":
return (
""
'OvfManager'
"unused"
'resgroup-22'
'datastore-31'
""
)
if op in {
"HttpNfcLeaseComplete",
"HttpNfcLeaseProgress",
"HttpNfcLeaseAbort",
"HttpNfcLeaseGetManifest",
}:
return (
f""
'lease-1'
"100"
f""
)
if op in {
"QueryConfigOption",
"QueryConfigOptionEx",
"QueryConfigOptionDescriptor",
"QueryConfigTarget",
}:
return f'envbrowser-1'
return f'ServiceInstance'
def _find_by_path(cookie: str, session_id: str, path: str) -> tuple[int, str]:
code, body, _ = _post(
_envelope(
""
'SearchIndex'
f"{escape(path)}"
""
),
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(
""
f'{folder_id}'
f"soap-renamed-{op_suffix}"
""
),
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),
}