Files
inecs f8d3cbdd59 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.
2026-07-18 04:42:11 +03:00

1256 lines
50 KiB
Python

"""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 = (
"<!DOCTYPE html><html><head><title>VMware vCenter</title></head>"
f"<body><h1>VMware vCenter Server</h1><p>Version {VIM_VERSION} (API Simulator)</p>"
"<p>SOAP endpoint: <code>/sdk</code> · "
'<a href="/sdk/vimService.wsdl">WSDL</a></p></body></html>'
)
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'<element name="{op}"><complexType/></element>' for op in ops)
messages = "".join(
f'<message name="{op}Request"><part name="parameters" element="vim25:{op}"/></message>'
for op in ops
)
operations = "".join(
f'<operation name="{op}"><input message="vim25:{op}Request"/></operation>' for op in ops
)
wsdl = f"""<?xml version="1.0" encoding="UTF-8"?>
<definitions name="VimService" targetNamespace="urn:vim25"
xmlns="http://schemas.xmlsoap.org/wsdl/"
xmlns:soap="http://schemas.xmlsoap.org/wsdl/soap/"
xmlns:vim25="urn:vim25">
<types><schema targetNamespace="urn:vim25" xmlns="http://www.w3.org/2001/XMLSchema">{elements}</schema></types>
{messages}
<portType name="VimPortType">{operations}</portType>
<binding name="VimBinding" type="vim25:VimPortType">
<soap:binding style="document" transport="http://schemas.xmlsoap.org/soap/http"/>
</binding>
<service name="VimService">
<port name="VimPort" binding="vim25:VimBinding">
<soap:address location="/sdk"/>
</port>
</service>
</definitions>
"""
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", "<returnval>2026-01-01T00:00:00.000Z</returnval>"), 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",
'<returnval type="PropertyFilter">filter-1</returnval>',
), 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", "<returnval></returnval>"), None
if (
_has_op(action, body, "QueryAlarmState")
or _has_op(action, body, "GetAlarm")
or _has_op(action, body, "AreAlarmActionsEnabled")
):
return _wrap("QueryAlarmStateResponse", "<returnval></returnval>"), None
if _has_op(action, body, "QueryPerf") or _has_op(action, body, "QueryPerfProviderSummary"):
return _wrap("QueryPerfResponse", "<returnval></returnval>"), 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. ``<urn:Login>``)."""
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"""<returnval type="UserSession">
<key>{escape(session_id)}</key>
<userName>{escape(username)}</userName>
<fullName>{escape(username)}</fullName>
<loginTime>2026-01-01T00:00:00.000Z</loginTime>
<lastActiveTime>2026-01-01T00:00:00.000Z</lastActiveTime>
<locale>en</locale>
<messageLocale>en</messageLocale>
</returnval>""",
)
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'<returnval type="ContainerView">{escape(view_id)}</returnval>',
)
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 <object>.
if token:
objects_xml = "".join(
p.replace("<returnval>", "<objects>").replace("</returnval>", "</objects>")
for p in parts
)
return _wrap(tag, f"<returnval>{objects_xml}<token>{escape(token)}</token></returnval>")
if "RetrievePropertiesEx" in body:
objects_xml = "".join(
p.replace("<returnval>", "<objects>").replace("</returnval>", "</objects>")
for p in parts
)
return _wrap(tag, f"<returnval>{objects_xml}</returnval>")
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", "<returnval></returnval>")
page_full = await pc.take_page_token_full(database, token)
if page_full is None:
return _wrap("ContinueRetrievePropertiesExResponse", "<returnval></returnval>")
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("<returnval>", "<objects>").replace("</returnval>", "</objects>") for p in parts
)
if next_token:
return _wrap(
"ContinueRetrievePropertiesExResponse",
f"<returnval>{objects_xml}<token>{escape(next_token)}</token></returnval>",
)
return _wrap("ContinueRetrievePropertiesExResponse", f"<returnval>{objects_xml}</returnval>")
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'<returnval type="Task">{escape(task_id)}</returnval>')
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'<returnval type="{escape(obj.type)}">{escape(obj.moid)}</returnval>',
)
if dns and (obj.name == dns or obj.props.get("ip_address") == dns):
return _wrap(
"FindByDnsNameResponse",
f'<returnval type="{escape(obj.type)}">{escape(obj.moid)}</returnval>',
)
if ip and str(obj.props.get("ip_address") or "") == ip:
return _wrap(
"FindByIpResponse",
f'<returnval type="{escape(obj.type)}">{escape(obj.moid)}</returnval>',
)
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(
"<guestOSDescriptor>"
f"<id>{escape(gid)}</id><family>{escape(family)}</family>"
f"<fullName>{escape(full)}</fullName>"
"<supportedMaxCPUs>128</supportedMaxCPUs>"
"<supportedMaxMemMB>16777216</supportedMaxMemMB>"
"<supportedMinMemMB>4</supportedMinMemMB>"
"<supportedMaxDisks>60</supportedMaxDisks>"
"<supportedMinDisks>0</supportedMinDisks>"
"<supportedMaxDiskSizeMB>67108864</supportedMaxDiskSizeMB>"
"<numSupportedPhysicalDisks>60</numSupportedPhysicalDisks>"
"<numSupportedVirtualDisks>60</numSupportedVirtualDisks>"
"<recommendedMemMB>1024</recommendedMemMB>"
"<recommendedDiskGB>16</recommendedDiskGB>"
"<recommendedColorDepth>8</recommendedColorDepth>"
"<supportedDiskControllerList>VirtualLsiLogicController</supportedDiskControllerList>"
"<supportedDiskControllerList>VirtualLsiLogicSASController</supportedDiskControllerList>"
"<supportedDiskControllerList>ParaVirtualSCSIController</supportedDiskControllerList>"
"<supportedEthernetCard>VirtualVmxnet3</supportedEthernetCard>"
"<supportedEthernetCard>VirtualE1000e</supportedEthernetCard>"
"<supportsWakeOnLan>true</supportsWakeOnLan>"
"<supportsVMI>false</supportsVMI>"
"<supportsMemoryHotAdd>true</supportsMemoryHotAdd>"
"<supportsCpuHotAdd>true</supportsCpuHotAdd>"
"<supportsCpuHotRemove>true</supportsCpuHotRemove>"
f"</guestOSDescriptor>"
for gid, family, full in guests
)
defaults = (
f'<defaultDevice {xsi} xsi:type="VirtualIDEController">'
"<key>200</key><busNumber>0</busNumber><deviceInfo>"
"<label>IDE 0</label><summary>IDE 0</summary></deviceInfo></defaultDevice>"
f'<defaultDevice {xsi} xsi:type="VirtualIDEController">'
"<key>201</key><busNumber>1</busNumber><deviceInfo>"
"<label>IDE 1</label><summary>IDE 1</summary></deviceInfo></defaultDevice>"
f'<defaultDevice {xsi} xsi:type="VirtualPS2Controller">'
"<key>300</key><deviceInfo><label>PS2 controller 0</label>"
"<summary>PS2 controller 0</summary></deviceInfo></defaultDevice>"
f'<defaultDevice {xsi} xsi:type="VirtualPCIController">'
"<key>100</key><deviceInfo><label>PCI controller 0</label>"
"<summary>PCI controller 0</summary></deviceInfo></defaultDevice>"
f'<defaultDevice {xsi} xsi:type="VirtualSIOController">'
"<key>400</key><deviceInfo><label>SIO Controller 0</label>"
"<summary>SIO Controller 0</summary></deviceInfo></defaultDevice>"
f'<defaultDevice {xsi} xsi:type="VirtualKeyboard">'
"<key>600</key><deviceInfo><label>Keyboard</label>"
"<summary>Keyboard</summary></deviceInfo></defaultDevice>"
f'<defaultDevice {xsi} xsi:type="VirtualPointingDevice">'
"<key>700</key><deviceInfo><label>Pointing device</label>"
"<summary>Pointing device</summary></deviceInfo>"
f'<backing {xsi} xsi:type="VirtualPointingDeviceDeviceBackingInfo">'
"<hostPointingDevice>autodetect</hostPointingDevice></backing></defaultDevice>"
f'<defaultDevice {xsi} xsi:type="VirtualMachineVideoCard">'
"<key>500</key><deviceInfo><label>Video card</label>"
"<summary>Video card</summary></deviceInfo>"
"<videoRamSizeInKB>4096</videoRamSizeInKB></defaultDevice>"
)
return (
"<version>vmx-19</version>"
"<description>Default hardware for lab simulator</description>"
"<guestOSDefaultIndex>0</guestOSDefaultIndex>"
f"{guest_xml}{defaults}"
"<hardwareOptions>"
"<hwVersion>19</hwVersion>"
"<virtualCPUCostUnit>mhz</virtualCPUCostUnit>"
"</hardwareOptions>"
)
def _environment_browser_ops(action: str, body: str) -> str:
if _has_op(action, body, "QueryConfigOptionDescriptor"):
return _wrap(
"QueryConfigOptionDescriptorResponse",
"<returnval>"
"<key>vmx-19</key><description>ESXi 8.0 and later</description>"
'<host type="HostSystem">host-11</host>'
"<createSupported>true</createSupported>"
"<defaultConfigOption>true</defaultConfigOption>"
"<runSupported>true</runSupported>"
"<upgradeSupported>true</upgradeSupported>"
"</returnval>",
)
if _has_op(action, body, "QueryConfigTarget"):
return _wrap(
"QueryConfigTargetResponse",
"<returnval>"
"<numCpus>64</numCpus><numCpuCores>64</numCpuCores>"
"<numNumaNodes>1</numNumaNodes>"
"<smcPresent>false</smcPresent>"
"</returnval>",
)
# QueryConfigOption / QueryConfigOptionEx
tag = (
"QueryConfigOptionExResponse"
if _has_op(action, body, "QueryConfigOptionEx")
else "QueryConfigOptionResponse"
)
return _wrap(tag, f"<returnval>{_config_option_xml()}</returnval>")
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'<returnval type="Task">{escape(task_id)}</returnval>')
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'<returnval type="Folder">{escape(moid)}</returnval>')
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'<returnval type="Task">{escape(task_id)}</returnval>')
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'<returnval type="Task">{escape(task_id)}</returnval>',
)
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'<returnval type="Task">{escape(task_id)}</returnval>',
)
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'<propSet><name>info.entity</name><val xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" '
f'xsi:type="ManagedObjectReference" type="VirtualMachine">{escape(entity)}</val></propSet>'
if entity
else ""
)
return _wrap(
"RetrievePropertiesResponse",
f"""<returnval>
<obj type="Task">{escape(task_id)}</obj>
<propSet><name>info.state</name><val>{state}</val></propSet>
<propSet><name>info.descriptionId</name><val>{escape(description)}</val></propSet>
<propSet><name>info.queueTime</name><val>{escape(str((task or {}).get("created_at") or ""))}</val></propSet>
<propSet><name>info.completeTime</name><val>{escape(str((task or {}).get("completed_at") or ""))}</val></propSet>
{entity_xml}
</returnval>""",
)
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'<returnval type="Task">{escape(task_id)}</returnval>')
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'<returnval type="Task">{escape(task_id)}</returnval>')
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'<returnval type="Task">{escape(task_id)}</returnval>')
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+)</(?:\w+:)?capacityInKB>",
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 "<operation>add</operation>" 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 (
"<operation>add</operation>" 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'<returnval type="Task">{escape(task_id)}</returnval>')
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 <vm> 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"<GuestListFileInfo><path>{escape(e['path'])}</path>"
f"<type>{escape(str(e['type']))}</type>"
f"<size>{int(e.get('size') or 0)}</size></GuestListFileInfo>"
for e in entries
)
return _wrap("ListFilesInGuestResponse", f"<returnval>{items}</returnval>")
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"<returnval>{escape(url)}</returnval>")
if "InitiateFileTransferFromGuest" in body:
url = f"https://localhost/api/vcenter/vm/{vm}/guest/filesystem?path={path}"
return _wrap(
"InitiateFileTransferFromGuestResponse",
f"<returnval><url>{escape(url)}</url></returnval>",
)
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",
"""<returnval>
<importSpec xsi:type="VirtualMachineImportSpec" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<configSpec><name>imported-lab-vm</name><guestId>otherGuest64</guestId>
<numCPUs>1</numCPUs><memoryMB>1024</memoryMB></configSpec>
</importSpec>
<warning></warning>
</returnval>""",
)
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'<returnval type="Task">{escape(task_id)}</returnval>',
)
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"<HttpNfcLeaseManifestEntry><key>{escape(name)}</key>"
f"<size>{int(meta.get('size') or 0)}</size></HttpNfcLeaseManifestEntry>"
)
return _wrap("HttpNfcLeaseGetManifestResponse", f"<returnval>{files_xml}</returnval>")
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'<returnval type="Task">{escape(task_id)}</returnval>')
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'<returnval type="{escape(obj.type)}">{escape(obj.moid)}</returnval>',
)
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'<returnval type="{escape(obj.type)}">{escape(obj.moid)}</returnval>',
)
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"<propSet><name>{escape(name)}</name><val>{escape(value)}</val></propSet>"
def _service_content_response() -> str:
return _wrap(
"RetrieveServiceContentResponse",
f"""<returnval>
<rootFolder type="Folder">group-d1</rootFolder>
<propertyCollector type="PropertyCollector">propertyCollector</propertyCollector>
<viewManager type="ViewManager">ViewManager</viewManager>
<about>
<name>VMware vCenter Server</name>
<fullName>VMware vCenter Server {VIM_VERSION} simulator</fullName>
<vendor>VMware, Inc.</vendor>
<version>{VIM_VERSION}</version>
<build>22361780</build>
<localeVersion>INTL</localeVersion>
<localeBuild>000</localeBuild>
<osType>linux-x64</osType>
<productLineId>vpx</productLineId>
<apiType>VirtualCenter</apiType>
<apiVersion>{VIM_VERSION}</apiVersion>
<instanceUuid>5029aaaa-bbbb-cccc-dddd-eeeeeeeeeeee</instanceUuid>
<licenseProductName>VMware VirtualCenter Server</licenseProductName>
<licenseProductVersion>8.0</licenseProductVersion>
</about>
<sessionManager type="SessionManager">SessionManager</sessionManager>
<authorizationManager type="AuthorizationManager">AuthorizationManager</authorizationManager>
<searchIndex type="SearchIndex">SearchIndex</searchIndex>
<eventManager type="EventManager">EventManager</eventManager>
<taskManager type="TaskManager">TaskManager</taskManager>
<guestOperationsManager type="GuestOperationsManager">guestOperationsManager</guestOperationsManager>
<fileManager type="FileManager">FileManager</fileManager>
<virtualDiskManager type="VirtualDiskManager">VirtualDiskManager</virtualDiskManager>
<ovfManager type="OvfManager">OvfManager</ovfManager>
<ipPoolManager type="IpPoolManager">IpPoolManager</ipPoolManager>
<customizationSpecManager type="CustomizationSpecManager">CustomizationSpecManager</customizationSpecManager>
</returnval>""",
)
def _empty_response(tag: str) -> str:
return _wrap(tag, "")
def _wrap(response_tag: str, inner: str) -> str:
return f"""<?xml version="1.0" encoding="UTF-8"?>
<soapenv:Envelope xmlns:soapenv="{NS_SOAP}">
<soapenv:Body>
<{response_tag} xmlns="urn:vim25">
{inner}
</{response_tag}>
</soapenv:Body>
</soapenv:Envelope>
"""
def _fault_envelope(fault: SoapFaultError) -> str:
return f"""<?xml version="1.0" encoding="UTF-8"?>
<soapenv:Envelope xmlns:soapenv="{NS_SOAP}">
<soapenv:Body>
<soapenv:Fault>
<faultcode>{escape(fault.fault_code)}</faultcode>
<faultstring>{escape(fault.message)}</faultstring>
</soapenv:Fault>
</soapenv:Body>
</soapenv:Envelope>
"""
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 _xml_attr_or_text(body: str, tag: str) -> str | None:
"""Read MOR-style tags: <host type="HostSystem">host-11</host> or plain text."""
match = re.search(
rf"<(?:\w+:)?{re.escape(tag)}\b[^>]*>([^<]*)</(?:\w+:)?{re.escape(tag)}>",
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)