"""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