"""Minimal VIM SOAP SDK for pyvmomi / govmomi style clients.""" from __future__ import annotations import re from xml.sax.saxutils import escape from fastapi import APIRouter, Depends, Request, Response from fastapi.responses import JSONResponse, PlainTextResponse from app.db.pool import Database from app.dependencies import get_database from app.vsphere import inventory from app.vsphere.security.session import ( SESSION_HEADER, create_session, delete_session, ensure_default_credentials, lookup_session, verify_password, ) router = APIRouter(tags=["vSphere SOAP"]) NS_SOAP = "http://schemas.xmlsoap.org/soap/envelope/" # Keep ≤3 dotted components — hashicorp/vsphere parses AboutInfo.version strictly. VIM_VERSION = "8.0.2" class SoapFaultError(Exception): def __init__(self, fault_code: str, message: str) -> None: self.fault_code = fault_code self.message = message super().__init__(message) @router.get("/sdk") @router.get("/sdk/") async def sdk_get() -> PlainTextResponse: return PlainTextResponse( "VMware VIM SDK simulator — POST SOAP bodies to /sdk", media_type="text/plain", ) @router.get("/sdk/about.do") @router.get("/about.do") async def sdk_about() -> Response: html = ( "VMware vCenter" f"

VMware vCenter Server

Version {VIM_VERSION} (API Simulator)

" "

SOAP endpoint: /sdk · " 'WSDL

' ) return Response(content=html, media_type="text/html") @router.get("/sdk/vimService.wsdl") @router.get("/sdk/vim.wsdl") async def sdk_wsdl() -> Response: ops = [ "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", ] elements = "".join(f'' for op in ops) messages = "".join( f'' for op in ops ) operations = "".join( f'' for op in ops ) wsdl = f""" {elements} {messages} {operations} """ return Response(content=wsdl, media_type="text/xml") @router.post("/sdk") @router.post("/sdk/") async def sdk_post( request: Request, database: Database = Depends(get_database), ) -> Response: body = (await request.body()).decode("utf-8", errors="replace") action = _soap_action(request, body) try: xml, session_id = await _dispatch(action, body, request, database) except SoapFaultError as fault: return Response( content=_fault_envelope(fault), media_type='text/xml; charset="utf-8"', status_code=500, ) headers: dict[str, str] = {} if session_id: headers["Set-Cookie"] = f'vmware_soap_session="{session_id}"; Path=/; Secure; HttpOnly' headers[SESSION_HEADER] = session_id return Response(content=xml, media_type='text/xml; charset="utf-8"', headers=headers) @router.post("/sdk/vim25/{version}/SessionManager/SessionManager/Login") async def vim25_json_login( version: str, request: Request, database: Database = Depends(get_database), ) -> Response: del version await ensure_default_credentials(database) payload = await request.json() username = str(payload.get("userName") or payload.get("username") or "") password = str(payload.get("password") or "") if not await verify_password(database, username, password): return JSONResponse({"error": "InvalidLogin"}, status_code=401) session_id = await create_session(database, username) return Response( content="null", media_type="application/json", headers={SESSION_HEADER: session_id}, status_code=200, ) async def _dispatch( action: str, body: str, request: Request, database: Database, ) -> tuple[str, str | None]: if "RetrieveServiceContent" in action or "RetrieveServiceContent" in body: return _service_content_response(), None if _has_op(action, body, "Login"): xml, session_id = await _login_response(body, database) return xml, session_id if _has_op(action, body, "Logout"): session_id = request.headers.get(SESSION_HEADER) or _cookie_session(request) if session_id: await delete_session(database, session_id) return _empty_response("LogoutResponse"), None if _has_op(action, body, "CreateContainerView"): return await _create_container_view(body, database), None if _has_op(action, body, "ContinueRetrievePropertiesEx"): return await _continue_retrieve_properties(body, request, database), None if ( "RetrieveProperties" in action or "RetrievePropertiesEx" in body or _has_op(action, body, "RetrieveProperties") or _has_op(action, body, "RetrievePropertiesEx") ): return await _retrieve_properties(body, request, database), None if "DestroyPropertyFilter" in body or "DestroyContainerView" in body or "DestroyView" in body: return _empty_response("DestroyPropertyFilterResponse"), None if "CurrentTime" in action or _has_op(action, body, "CurrentTime"): return _wrap("CurrentTimeResponse", "2026-01-01T00:00:00.000Z"), None if _has_op(action, body, "FindByInventoryPath"): return await _find_by_path(body, database), None if _has_op(action, body, "FindChild"): return await _find_child(body, database), None if ( _has_op(action, body, "FindByUuid") or _has_op(action, body, "FindByDnsName") or _has_op(action, body, "FindByIp") ): return await _find_by_attr(body, database), None if _has_op(action, body, "CreateFilter"): return _wrap( "CreateFilterResponse", 'filter-1', ), None if ( _has_op(action, body, "WaitForUpdatesEx") or _has_op(action, body, "WaitForUpdates") or _has_op(action, body, "CheckForUpdates") ): return await _wait_for_updates(body, request, database), None if _has_op(action, body, "CancelTask"): return await _cancel_task(body, database), None if _has_op(action, body, "CreateVM_Task") or _has_op(action, body, "CreateChildVM_Task"): return await _create_vm_task(body, request, database), None if _has_op(action, body, "CreateFolder"): return await _create_folder(body, request, database), None if _has_op(action, body, "CloneVM_Task"): return await _clone_vm_task(body, request, database), None if _has_op(action, body, "CreateSnapshot_Task"): return await _snapshot_task(body, request, database), None if _has_op(action, body, "CustomizeVM_Task"): return await _customize_vm_task(body, request, database), None if _has_op(action, body, "QueryTask") or ("TaskManager" in body and "info" in body): return await _query_task(body, database), None if ( _has_op(action, body, "PowerOnVM_Task") or _has_op(action, body, "PowerOffVM_Task") or _has_op(action, body, "ResetVM_Task") or _has_op(action, body, "SuspendVM_Task") ): return await _power_task(body, request, database), None if _has_op(action, body, "Rename_Task"): return await _rename_task(body, request, database), None if _has_op(action, body, "MarkAsTemplate") or _has_op(action, body, "MarkAsVirtualMachine"): return await _template_task(body, request, database), None if _has_op(action, body, "UnregisterVM") or _has_op(action, body, "Destroy_Task"): return await _destroy_or_unregister(body, request, database), None if _has_op(action, body, "ReconfigVM_Task"): return await _reconfig_vm_task(body, request, database), None if _has_op(action, body, "MigrateVM_Task") or _has_op(action, body, "RelocateVM_Task"): return await _relocate_task(body, request, database), None if ( _has_op(action, body, "ListFilesInGuest") or _has_op(action, body, "InitiateFileTransferToGuest") or _has_op(action, body, "InitiateFileTransferFromGuest") or _has_op(action, body, "DeleteFileInGuest") or _has_op(action, body, "MakeDirectoryInGuest") ): return await _guest_file_ops(body, request, database), None if ( _has_op(action, body, "ImportVApp_Task") or _has_op(action, body, "CreateImportSpec") or _has_op(action, body, "HttpNfcLeaseComplete") or _has_op(action, body, "HttpNfcLeaseProgress") or _has_op(action, body, "HttpNfcLeaseAbort") or _has_op(action, body, "HttpNfcLeaseGetManifest") or _has_op(action, body, "HttpNfcLease") ): return await _nfc_lease_ops(body, request, database), None if ( _has_op(action, body, "QueryConfigOptionEx") or _has_op(action, body, "QueryConfigOption") or _has_op(action, body, "QueryConfigOptionDescriptor") or _has_op(action, body, "QueryConfigTarget") ): return _environment_browser_ops(action, body), None if _has_op(action, body, "QueryEvents") or _has_op(action, body, "RetrieveArgumentDescription"): return _wrap("QueryEventsResponse", ""), None if ( _has_op(action, body, "QueryAlarmState") or _has_op(action, body, "GetAlarm") or _has_op(action, body, "AreAlarmActionsEnabled") ): return _wrap("QueryAlarmStateResponse", ""), None if _has_op(action, body, "QueryPerf") or _has_op(action, body, "QueryPerfProviderSummary"): return _wrap("QueryPerfResponse", ""), None return _empty_response("MethodFaultResponse"), None def _has_op(action: str, body: str, name: str) -> bool: """Match SOAP op in SOAPAction or namespaced element tags (e.g. ````).""" if name in action: return True return re.search(rf"<(?:\w+:)?{re.escape(name)}(?:\s|>|/)", body) is not None def _soap_action(request: Request, body: str) -> str: header = request.headers.get("SOAPAction") or request.headers.get("soapaction") or "" if header: return header.strip('"') match = re.search(r"<(\w+)", body) return match.group(1) if match else "" def _cookie_session(request: Request) -> str | None: cookie = request.headers.get("cookie") or "" for part in cookie.split(";"): part = part.strip() if part.startswith("vmware_soap_session="): return part.split("=", 1)[1].strip('"') return None async def _require_soap_session(request: Request, database: Database) -> str: session_id = request.headers.get(SESSION_HEADER) or _cookie_session(request) if not session_id: raise SoapFaultError("NotAuthenticated", "Session required") info = await lookup_session(database, session_id) if info is None: raise SoapFaultError("NotAuthenticated", "Invalid session") return session_id async def _login_response(body: str, database: Database) -> tuple[str, str]: await ensure_default_credentials(database) username = _xml_text(body, "userName") or _xml_text(body, "username") or "" password = _xml_text(body, "password") or "" if not await verify_password(database, username, password): raise SoapFaultError( "InvalidLogin", "Cannot complete login due to an incorrect user name or password", ) session_id = await create_session(database, username) xml = _wrap( "LoginResponse", f""" {escape(session_id)} {escape(username)} {escape(username)} 2026-01-01T00:00:00.000Z 2026-01-01T00:00:00.000Z en en """, ) return xml, session_id async def _create_container_view(body: str, database: Database) -> str: from app.vsphere.soap import property_collector as pc types = pc.parse_view_types(body) or ["VirtualMachine"] objects = await inventory.list_objects(database) moids = [o.moid for o in objects if o.type in types] view_id = await pc.next_view_id(database) await pc.register_container_view(database, view_id, moids) return _wrap( "CreateContainerViewResponse", f'{escape(view_id)}', ) async def _retrieve_properties(body: str, request: Request, database: Database) -> str: from app.vsphere.domain import tasks as task_store from app.vsphere.soap import property_collector as pc await _require_soap_session(request, database) selected, path_sets = await pc.select_objects_for_retrieve(database, body) all_objects = await inventory.list_objects(database) by_moid = {o.moid: o for o in all_objects} enriched = [] for obj in selected: if obj.type == "Task": task = await task_store.get_task(database, obj.moid) result = (task or {}).get("result") or {} props = { "state": { "SUCCEEDED": "success", "FAILED": "error", "RUNNING": "running", "PENDING": "queued", }.get((task or {}).get("status", ""), "error"), "operation": (task or {}).get("operation") or "task", "entity": result.get("vm"), "result_moid": result.get("vm"), "result_type": "VirtualMachine" if result.get("vm") else None, } from app.vsphere.inventory import ManagedObject enriched.append( ManagedObject( moid=obj.moid, type="Task", name=obj.moid, parent_moid=None, props=props ) ) else: enriched.append(obj) selected = enriched # Match SOAP op name carefully — ContinueRetrievePropertiesEx also contains this substring. is_ex = "RetrievePropertiesEx" in body and "ContinueRetrievePropertiesEx" not in body max_objects = 100 if is_ex else None token = None if max_objects is not None and len(selected) > max_objects: page, rest = selected[:max_objects], selected[max_objects:] selected = page token = await pc.store_page_token( database, [o.moid for o in rest], path_sets=path_sets or None ) parts = [ pc.object_content_xml( obj, children=pc.children_of(obj.moid, all_objects), all_by_moid=by_moid, path_sets=path_sets or None, ) for obj in selected ] tag = ( "RetrievePropertiesExResponse" if "RetrievePropertiesEx" in body else "RetrievePropertiesResponse" ) # RetrieveResult.objects (plural) — govmomi/Terraform panic on . if token: objects_xml = "".join( p.replace("", "").replace("", "") for p in parts ) return _wrap(tag, f"{objects_xml}{escape(token)}") if "RetrievePropertiesEx" in body: objects_xml = "".join( p.replace("", "").replace("", "") for p in parts ) return _wrap(tag, f"{objects_xml}") return _wrap(tag, "".join(parts)) async def _continue_retrieve_properties(body: str, request: Request, database: Database) -> str: """ContinueRetrievePropertiesEx — required when RetrievePropertiesEx paginates.""" from app.vsphere.soap import property_collector as pc await _require_soap_session(request, database) token = pc.parse_continue_token(body) if not token: return _wrap("ContinueRetrievePropertiesExResponse", "") page_full = await pc.take_page_token_full(database, token) if page_full is None: return _wrap("ContinueRetrievePropertiesExResponse", "") remaining, path_sets = page_full all_objects = await inventory.list_objects(database) by_moid = {o.moid: o for o in all_objects} selected = [by_moid[m] for m in remaining if m in by_moid] max_objects = 100 next_token = None if len(selected) > max_objects: page, rest = selected[:max_objects], selected[max_objects:] selected = page next_token = await pc.store_page_token( database, [o.moid for o in rest], path_sets=path_sets or None ) parts = [ pc.object_content_xml( obj, children=pc.children_of(obj.moid, all_objects), all_by_moid=by_moid, path_sets=path_sets or None, ) for obj in selected ] objects_xml = "".join( p.replace("", "").replace("", "") for p in parts ) if next_token: return _wrap( "ContinueRetrievePropertiesExResponse", f"{objects_xml}{escape(next_token)}", ) return _wrap("ContinueRetrievePropertiesExResponse", f"{objects_xml}") async def _power_task(body: str, request: Request, database: Database) -> str: await _require_soap_session(request, database) from app.vsphere.domain import tasks as task_store moid = _xml_text(body, "_this") or "" obj = await inventory.get_object(database, moid) if obj is None or obj.type != "VirtualMachine": raise SoapFaultError("ManagedObjectNotFound", f"VM {moid} not found") props = dict(obj.props) if "PowerOnVM_Task" in body or "ResetVM_Task" in body: props["power_state"] = "POWERED_ON" op = "PowerOn" elif "PowerOffVM_Task" in body: props["power_state"] = "POWERED_OFF" op = "PowerOff" else: props["power_state"] = "SUSPENDED" op = "Suspend" await inventory.update_props(database, moid, props) task_id = await task_store.create_task( database, description=f"{op} {moid}", service="vim.VirtualMachine", operation=op.lower(), result={"vm": moid, "power_state": props["power_state"]}, ) tag = f"{op}VM_TaskResponse" if op != "Suspend" else "SuspendVM_TaskResponse" if op == "PowerOn" and "ResetVM_Task" in body: tag = "ResetVM_TaskResponse" return _wrap(tag, f'{escape(task_id)}') async def _wait_for_updates(body: str, request: Request, database: Database) -> str: from app.vsphere.soap import property_collector as pc await _require_soap_session(request, database) session_key = request.headers.get(SESSION_HEADER) or _cookie_session(request) or "anon" objects = await inventory.list_objects(database) inner = await pc.wait_updates_xml(database, session_key=session_key, body=body, objects=objects) if "CheckForUpdates" in body: return _wrap("CheckForUpdatesResponse", inner) if "WaitForUpdatesEx" in body: return _wrap("WaitForUpdatesExResponse", inner) return _wrap("WaitForUpdatesResponse", inner) async def _cancel_task(body: str, database: Database) -> str: from app.vsphere.domain import tasks as task_store task_id = _xml_text(body, "_this") or "" task = await task_store.get_task(database, task_id) if task is None: raise SoapFaultError("ManagedObjectNotFound", f"Task {task_id} not found") return _empty_response("CancelTaskResponse") async def _find_by_attr(body: str, database: Database) -> str: uuid = _xml_text(body, "uuid") dns = _xml_text(body, "dnsName") ip = _xml_text(body, "ip") objects = await inventory.list_objects(database) for obj in objects: identity = obj.props.get("identity") or {} if uuid and uuid in { str(identity.get("instance_uuid") or ""), str(identity.get("bios_uuid") or ""), }: return _wrap( "FindByUuidResponse", f'{escape(obj.moid)}', ) if dns and (obj.name == dns or obj.props.get("ip_address") == dns): return _wrap( "FindByDnsNameResponse", f'{escape(obj.moid)}', ) if ip and str(obj.props.get("ip_address") or "") == ip: return _wrap( "FindByIpResponse", f'{escape(obj.moid)}', ) tag = "FindByUuidResponse" if uuid else "FindByDnsNameResponse" if dns else "FindByIpResponse" return _wrap(tag, "") def _config_option_xml() -> str: """Minimal VirtualMachineConfigOption for Terraform DefaultDevices / OSFamily.""" xsi = 'xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"' guests = [ ("otherGuest64", "otherGuestFamily", "Other (64-bit)"), ("otherGuest", "otherGuestFamily", "Other (32-bit)"), ("ubuntu64Guest", "linuxGuest", "Ubuntu Linux (64-bit)"), ("rhel8_64Guest", "linuxGuest", "Red Hat Enterprise Linux 8 (64-bit)"), ("windows9_64Guest", "windowsGuest", "Microsoft Windows 10 (64-bit)"), ("windows2019srv_64Guest", "windowsGuest", "Microsoft Windows Server 2019 (64-bit)"), ] guest_xml = "".join( "" f"{escape(gid)}{escape(family)}" f"{escape(full)}" "128" "16777216" "4" "60" "0" "67108864" "60" "60" "1024" "16" "8" "VirtualLsiLogicController" "VirtualLsiLogicSASController" "ParaVirtualSCSIController" "VirtualVmxnet3" "VirtualE1000e" "true" "false" "true" "true" "true" f"" for gid, family, full in guests ) defaults = ( f'' "2000" "IDE 0" f'' "2011" "IDE 1" f'' "300" "PS2 controller 0" f'' "100" "PCI controller 0" f'' "400" "SIO Controller 0" f'' "600" "Keyboard" f'' "700" "Pointing device" f'' "autodetect" f'' "500" "Video card" "4096" ) return ( "vmx-19" "Default hardware for lab simulator" "0" f"{guest_xml}{defaults}" "" "19" "mhz" "" ) def _environment_browser_ops(action: str, body: str) -> str: if _has_op(action, body, "QueryConfigOptionDescriptor"): return _wrap( "QueryConfigOptionDescriptorResponse", "" "vmx-19ESXi 8.0 and later" 'host-11' "true" "true" "true" "true" "", ) if _has_op(action, body, "QueryConfigTarget"): return _wrap( "QueryConfigTargetResponse", "" "6464" "1" "false" "", ) # QueryConfigOption / QueryConfigOptionEx tag = ( "QueryConfigOptionExResponse" if _has_op(action, body, "QueryConfigOptionEx") else "QueryConfigOptionResponse" ) return _wrap(tag, f"{_config_option_xml()}") async def _create_vm_task(body: str, request: Request, database: Database) -> str: await _require_soap_session(request, database) from app.vsphere.domain import vm_ops # CreateChildVM_Task is invoked on a ResourcePool/VirtualApp; place the VM in the # datacenter VM folder. CreateVM_Task is invoked on a Folder. if "CreateChildVM_Task" in body: folder = "group-v23" else: folder = _xml_text(body, "_this") or "group-v23" name = _xml_text(body, "name") or "unnamed-vm" guest = _xml_text(body, "guestId") or "otherGuest64" cpu = int(_xml_text(body, "numCPUs") or _xml_text(body, "numCpu") or "1") memory = int(_xml_text(body, "memoryMB") or "1024") pool = _xml_attr_or_text(body, "pool") or "resgroup-22" host = _xml_attr_or_text(body, "host") or "host-11" # Parse datastore from [datastore1] path or datastore MOR. datastore = _xml_attr_or_text(body, "datastore") or "datastore-31" path_name = _xml_text(body, "vmPathName") or "" if path_name.startswith("[") and "]" in path_name: ds_name = path_name[1 : path_name.index("]")] objects = await inventory.list_objects(database, type_name="Datastore") match = next((o for o in objects if o.name == ds_name or o.moid == ds_name), None) if match: datastore = match.moid # Optional network from VirtualEthernetCard backing. network = _xml_attr_or_text(body, "network") or "network-41" moid, task_id = await vm_ops.create_vm( database, name=name, folder=folder, host=host, datastore=datastore, resource_pool=pool, guest_os=_rest_guest_from_vim(guest), cpu_count=cpu, memory_size_mib=memory, networks=[network], ) del moid tag = "CreateChildVM_TaskResponse" if "CreateChildVM_Task" in body else "CreateVM_TaskResponse" return _wrap(tag, f'{escape(task_id)}') async def _create_folder(body: str, request: Request, database: Database) -> str: await _require_soap_session(request, database) from app.vsphere.domain import inventory_ops parent = _xml_text(body, "_this") or "group-v23" name = _xml_text(body, "name") or "New Folder" moid = await inventory_ops.create_folder(database, parent=parent, name=name) return _wrap("CreateFolderResponse", f'{escape(moid)}') async def _clone_vm_task(body: str, request: Request, database: Database) -> str: await _require_soap_session(request, database) from app.vsphere.domain import vm_ops source = _xml_text(body, "_this") or "" name = _xml_text(body, "name") or f"{source}-clone" folder = _xml_attr_or_text(body, "folder") host = _xml_attr_or_text(body, "host") datastore = _xml_attr_or_text(body, "datastore") pool = _xml_attr_or_text(body, "pool") power_on = (_xml_text(body, "powerOn") or "false").lower() == "true" moid, task_id = await vm_ops.clone_vm( database, source_vm=source, name=name, folder=folder, host=host, datastore=datastore, resource_pool=pool, power_on=power_on, ) del moid return _wrap("CloneVM_TaskResponse", f'{escape(task_id)}') async def _customize_vm_task(body: str, request: Request, database: Database) -> str: await _require_soap_session(request, database) from app.vsphere.domain import vm_ops vm = _xml_text(body, "_this") or "" hostname = _xml_text(body, "hostName") or _xml_text(body, "name") ip = _xml_text(body, "ipAddress") or _xml_text(body, "ip") task_id = await vm_ops.customize_vm( database, vm, {"hostname": hostname, "ip": ip, "raw": True}, ) return _wrap( "CustomizeVM_TaskResponse", f'{escape(task_id)}', ) async def _snapshot_task(body: str, request: Request, database: Database) -> str: await _require_soap_session(request, database) from app.vsphere.domain import vm_ops vm = _xml_text(body, "_this") or "" name = _xml_text(body, "name") or "snapshot" _, task_id = await vm_ops.create_snapshot(database, vm, name=name) return _wrap( "CreateSnapshot_TaskResponse", f'{escape(task_id)}', ) async def _query_task(body: str, database: Database) -> str: from app.vsphere.domain import tasks as task_store task_id = _xml_text(body, "_this") or "" # Also accept obj refs in RetrieveProperties body if not task_id or not task_id.startswith("task-"): import re as _re m = _re.search(r'type="Task"[^>]*>([^<]+)<', body) if m: task_id = m.group(1) task = await task_store.get_task(database, task_id) if task is None: state = "error" description = "unknown" entity = "" else: state = { "SUCCEEDED": "success", "FAILED": "error", "RUNNING": "running", "PENDING": "queued", }.get(task["status"], "running") description = task.get("operation") or "task" entity = (task.get("result") or {}).get("vm") or "" entity_xml = ( f'info.entity{escape(entity)}' if entity else "" ) return _wrap( "RetrievePropertiesResponse", f""" {escape(task_id)} info.state{state} info.descriptionId{escape(description)} info.queueTime{escape(str((task or {}).get("created_at") or ""))} info.completeTime{escape(str((task or {}).get("completed_at") or ""))} {entity_xml} """, ) async def _rename_task(body: str, request: Request, database: Database) -> str: await _require_soap_session(request, database) from app.vsphere.domain import inventory_ops from app.vsphere.domain import tasks as task_store moid = _xml_text(body, "_this") or "" name = _xml_text(body, "newName") or _xml_text(body, "name") or moid await inventory_ops.rename_object(database, moid, name) task_id = await task_store.create_task( database, description=f"Rename {moid}", service="vim.ManagedEntity", operation="rename", result={"moid": moid, "name": name}, ) return _wrap("Rename_TaskResponse", f'{escape(task_id)}') async def _template_task(body: str, request: Request, database: Database) -> str: await _require_soap_session(request, database) from app.vsphere.domain import vm_ops vm = _xml_text(body, "_this") or "" as_template = "MarkAsTemplate" in body task_id = await vm_ops.set_template(database, vm, template=as_template) tag = "MarkAsTemplateResponse" if as_template else "MarkAsVirtualMachineResponse" return _wrap(tag, f'{escape(task_id)}') async def _destroy_or_unregister(body: str, request: Request, database: Database) -> str: await _require_soap_session(request, database) from app.vsphere.domain import vm_ops vm = _xml_text(body, "_this") or "" if "UnregisterVM" in body: task_id = await vm_ops.unregister_vm(database, vm) return _wrap("UnregisterVMResponse", "") # Destroy_Task — power off then delete obj = await inventory.get_object(database, vm) if obj and obj.props.get("power_state") == "POWERED_ON": await vm_ops.set_power(database, vm, "stop") await inventory.delete_object(database, vm) from app.vsphere.domain import tasks as task_store task_id = await task_store.create_task( database, description=f"Destroy {vm}", service="vim.ManagedEntity", operation="destroy", result={"vm": vm}, ) return _wrap("Destroy_TaskResponse", f'{escape(task_id)}') async def _reconfig_vm_task(body: str, request: Request, database: Database) -> str: await _require_soap_session(request, database) from app.vsphere.domain import tasks as task_store from app.vsphere.domain import vm_ops vm = _xml_text(body, "_this") or "" obj = await inventory.get_object(database, vm) if obj is None: raise SoapFaultError("ManagedObjectNotFound", vm) props = dict(obj.props) num_cpu = _xml_text(body, "numCPUs") or _xml_text(body, "numCpu") memory = _xml_text(body, "memoryMB") if num_cpu: props["cpu_count"] = int(num_cpu) if memory: props["memory_size_mib"] = int(memory) # Parse deviceChange adds: VirtualDisk capacity + VirtualEthernetCard network. if "VirtualDisk" in body: capacities = re.findall( r"<(?:\w+:)?capacityInKB[^>]*>(\d+)", body, ) for cap_kb in capacities: capacity = int(cap_kb) * 1024 # Prefer vm_ops when adding; avoid double-adding on pure edit. if "operation>add" in body.replace(" ", "") or "add" in body: await vm_ops.add_disk(database, vm, capacity) obj = await inventory.get_object(database, vm) props = dict(obj.props) if obj else props elif props.get("disks"): disks = list(props["disks"]) value = dict(disks[0].get("value") or disks[0]) value["capacity"] = capacity disks[0] = {**disks[0], "value": value} if "value" in disks[0] else value props["disks"] = disks if "VirtualEthernetCard" in body or "VirtualVmxnet3" in body: network = _xml_attr_or_text(body, "network") if network and ( "add" in body or "operation>add" in body.replace(" ", "") ): await vm_ops.add_nic(database, vm, network) obj = await inventory.get_object(database, vm) props = dict(obj.props) if obj else props elif network: props["networks"] = [network] nics = list(props.get("nics") or []) if nics: value = dict(nics[0].get("value") or nics[0]) backing = dict(value.get("backing") or {}) backing["network"] = network value["backing"] = backing nics[0] = {**nics[0], "value": value} if "value" in nics[0] else value props["nics"] = nics name = _xml_text(body, "name") if name: await inventory.upsert_object( database, moid=vm, type_name="VirtualMachine", name=name, parent_moid=obj.parent_moid, props=props, ) else: await inventory.update_props(database, vm, props) task_id = await task_store.create_task( database, description=f"Reconfig {vm}", service="vim.VirtualMachine", operation="reconfig", result={"vm": vm}, ) return _wrap("ReconfigVM_TaskResponse", f'{escape(task_id)}') async def _guest_file_ops(body: str, request: Request, database: Database) -> str: await _require_soap_session(request, database) from app.vsphere.domain import vm_ops # Guest ops target GuestOperationsManager; vm MoRef often in element. vm = _xml_attr_or_text(body, "vm") or _xml_text(body, "_this") or "" if vm in {"guestOperationsManager", "GuestOperationsManager", "fileManager"}: vm = _xml_attr_or_text(body, "vm") or "" path = ( _xml_text(body, "filePath") or _xml_text(body, "guestFilePath") or _xml_text(body, "path") or "/" ) if "ListFilesInGuest" in body: entries = await vm_ops.guest_list_files(database, vm, path) items = "".join( f"{escape(e['path'])}" f"{escape(str(e['type']))}" f"{int(e.get('size') or 0)}" for e in entries ) return _wrap("ListFilesInGuestResponse", f"{items}") if "InitiateFileTransferToGuest" in body: content = _xml_text(body, "content") or "" await vm_ops.guest_write_file(database, vm, path, content or f"lab-upload:{path}") url = f"https://localhost/api/vcenter/vm/{vm}/guest/filesystem?path={path}" return _wrap("InitiateFileTransferToGuestResponse", f"{escape(url)}") if "InitiateFileTransferFromGuest" in body: url = f"https://localhost/api/vcenter/vm/{vm}/guest/filesystem?path={path}" return _wrap( "InitiateFileTransferFromGuestResponse", f"{escape(url)}", ) if "DeleteFileInGuest" in body: await vm_ops.guest_delete_file(database, vm, path) return _empty_response("DeleteFileInGuestResponse") if "MakeDirectoryInGuest" in body: await vm_ops.guest_write_file(database, vm, path.rstrip("/") + "/.keep", "") return _empty_response("MakeDirectoryInGuestResponse") return _empty_response("MethodFaultResponse") async def _nfc_lease_ops(body: str, request: Request, database: Database) -> str: await _require_soap_session(request, database) from app.vsphere.domain import platform_surface from app.vsphere.domain import tasks as task_store if "CreateImportSpec" in body: return _wrap( "CreateImportSpecResponse", """ imported-lab-vmotherGuest64 11024 """, ) if "ImportVApp_Task" in body: # VIM: _this is ResourcePool; folder/host are separate args. name = _xml_text(body, "name") or "imported-lab-vm" folder = _xml_text(body, "folder") or "group-v23" pool = _xml_text(body, "_this") or "resgroup-22" host = _xml_text(body, "host") or "host-11" from app.vsphere.domain import vm_ops moid, _create_task = await vm_ops.create_vm( database, name=name, folder=folder, host=host, resource_pool=pool, guest_os="OTHER_GUEST_64", cpu_count=1, memory_size_mib=1024, ) lease = await platform_surface.create_nfc_lease( database, vm=moid, files=[f"{name}.vmdk", f"{name}.nvram"] ) task_id = await task_store.create_task( database, description=f"ImportVApp {moid}", service="vim.ResourcePool", operation="import_vapp", result={"vm": moid, "lease": lease["lease"]}, ) return _wrap( "ImportVApp_TaskResponse", f'{escape(task_id)}', ) lease_id = _xml_text(body, "_this") or "" lease = await platform_surface.get_nfc_lease(database, lease_id) if "HttpNfcLeaseComplete" in body: await platform_surface.complete_nfc_lease(database, lease_id) return _empty_response("HttpNfcLeaseCompleteResponse") if "HttpNfcLeaseAbort" in body: if lease: lease["state"] = "error" await platform_surface._save_nfc_lease(database, lease_id, lease) return _empty_response("HttpNfcLeaseAbortResponse") if "HttpNfcLeaseProgress" in body: percent = int(_xml_text(body, "percent") or "100") if lease: lease["transferProgress"] = percent await platform_surface._save_nfc_lease(database, lease_id, lease) return _empty_response("HttpNfcLeaseProgressResponse") if "HttpNfcLeaseGetManifest" in body or lease is not None: files_xml = "" if lease: for name, meta in (lease.get("files") or {}).items(): files_xml += ( f"{escape(name)}" f"{int(meta.get('size') or 0)}" ) return _wrap("HttpNfcLeaseGetManifestResponse", f"{files_xml}") return _empty_response("MethodFaultResponse") async def _relocate_task(body: str, request: Request, database: Database) -> str: await _require_soap_session(request, database) from app.vsphere.domain import vm_ops vm = _xml_text(body, "_this") or "" host = _xml_text(body, "host") datastore = _xml_text(body, "datastore") task_id = await vm_ops.relocate_vm(database, vm, host=host, datastore=datastore) tag = "MigrateVM_TaskResponse" if "MigrateVM_Task" in body else "RelocateVM_TaskResponse" return _wrap(tag, f'{escape(task_id)}') async def _find_by_path(body: str, database: Database) -> str: from app.vsphere.soap import property_collector as pc path = _xml_text(body, "inventoryPath") or "" objects = await inventory.list_objects(database) obj = pc.resolve_inventory_path(path, objects) if obj is None: return _wrap("FindByInventoryPathResponse", "") return _wrap( "FindByInventoryPathResponse", f'{escape(obj.moid)}', ) async def _find_child(body: str, database: Database) -> str: from app.vsphere.soap import property_collector as pc parent = _xml_attr_or_text(body, "entity") or _xml_text(body, "_this") or "" name = _xml_text(body, "name") or "" objects = await inventory.list_objects(database) obj = pc.find_child(parent, name, objects) if obj is None: return _wrap("FindChildResponse", "") return _wrap( "FindChildResponse", f'{escape(obj.moid)}', ) def _parent_type(moid: str) -> str: if moid.startswith("group-"): return "Folder" if moid.startswith("datacenter-"): return "Datacenter" if moid.startswith("domain-"): return "ClusterComputeResource" if moid.startswith("host-"): return "HostSystem" return "ManagedEntity" def _vim_power(state: str) -> str: return { "POWERED_ON": "poweredOn", "POWERED_OFF": "poweredOff", "SUSPENDED": "suspended", }.get(state, "poweredOff") def _prop(name: str, value: str) -> str: return f"{escape(name)}{escape(value)}" def _service_content_response() -> str: return _wrap( "RetrieveServiceContentResponse", f""" group-d1 propertyCollector ViewManager VMware vCenter Server VMware vCenter Server {VIM_VERSION} simulator VMware, Inc. {VIM_VERSION} 22361780 INTL 000 linux-x64 vpx VirtualCenter {VIM_VERSION} 5029aaaa-bbbb-cccc-dddd-eeeeeeeeeeee VMware VirtualCenter Server 8.0 SessionManager AuthorizationManager SearchIndex EventManager TaskManager guestOperationsManager FileManager VirtualDiskManager OvfManager IpPoolManager CustomizationSpecManager """, ) def _empty_response(tag: str) -> str: return _wrap(tag, "") def _wrap(response_tag: str, inner: str) -> str: return f""" <{response_tag} xmlns="urn:vim25"> {inner} """ def _fault_envelope(fault: SoapFaultError) -> str: return f""" {escape(fault.fault_code)} {escape(fault.message)} """ 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 _xml_attr_or_text(body: str, tag: str) -> str | None: """Read MOR-style tags: host-11 or plain text.""" match = re.search( rf"<(?:\w+:)?{re.escape(tag)}\b[^>]*>([^<]*)", body, ) if match and match.group(1).strip(): return match.group(1).strip() return None def _rest_guest_from_vim(guest_id: str) -> str: mapping = { "ubuntu64Guest": "UBUNTU_64_GUEST", "centos64Guest": "CENTOS_64_GUEST", "rhel8_64Guest": "RHEL_8_64_GUEST", "windows2019srv_64Guest": "WINDOWS_2019_64_GUEST", "otherGuest64": "OTHER_GUEST_64", "otherGuest": "OTHER_GUEST", } return mapping.get(guest_id, guest_id.upper() if guest_id.islower() else guest_id)