f8d3cbdd59
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.
1256 lines
50 KiB
Python
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)
|