"""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)}[^>]*>([^<]*)", 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), }