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.
1640 lines
67 KiB
Python
1640 lines
67 KiB
Python
"""PropertyCollector helpers: filter specs, updates, folder traversal props."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import re
|
|
import secrets
|
|
from typing import Any
|
|
from xml.sax.saxutils import escape
|
|
|
|
from app.db.pool import Database
|
|
from app.vsphere import inventory
|
|
from app.vsphere.inventory import ManagedObject
|
|
|
|
# Types that inherit ManagedEntity (govmomi Ancestors / Finder propSet Type=ManagedEntity).
|
|
_MANAGED_ENTITY_TYPES = frozenset(
|
|
{
|
|
"Folder",
|
|
"Datacenter",
|
|
"VirtualMachine",
|
|
"HostSystem",
|
|
"ClusterComputeResource",
|
|
"ComputeResource",
|
|
"ResourcePool",
|
|
"VirtualApp",
|
|
"StoragePod",
|
|
"Datastore",
|
|
"Network",
|
|
"DistributedVirtualPortgroup",
|
|
"VmwareDistributedVirtualSwitch",
|
|
"DistributedVirtualSwitch",
|
|
"OpaqueNetwork",
|
|
}
|
|
)
|
|
|
|
|
|
def _decode(value: Any) -> Any:
|
|
current = value
|
|
while isinstance(current, str):
|
|
try:
|
|
current = json.loads(current)
|
|
except json.JSONDecodeError:
|
|
break
|
|
return current
|
|
|
|
|
|
async def clear_pc_state(database: Database | None = None) -> None:
|
|
"""Drop durable PC views/tokens/versions (and orphan ContainerView inventory rows)."""
|
|
|
|
if database is None:
|
|
return
|
|
pool = database.pool # type: ignore[attr-defined]
|
|
async with pool.acquire() as conn:
|
|
await conn.execute(
|
|
"""
|
|
DO $$ BEGIN
|
|
IF to_regclass('public.vsphere_pc_state') IS NOT NULL THEN
|
|
DELETE FROM vsphere_pc_state;
|
|
END IF;
|
|
END $$;
|
|
"""
|
|
)
|
|
await conn.execute("DELETE FROM vsphere_objects WHERE type = 'ContainerView'")
|
|
|
|
|
|
async def _pc_put(database: Database, kind: str, key: str, payload: Any) -> None:
|
|
pool = database.pool # type: ignore[attr-defined]
|
|
async with pool.acquire() as conn:
|
|
await conn.execute(
|
|
"""
|
|
INSERT INTO vsphere_pc_state (kind, key, payload, updated_at)
|
|
VALUES ($1, $2, $3::jsonb, now())
|
|
ON CONFLICT (kind, key) DO UPDATE SET
|
|
payload = EXCLUDED.payload,
|
|
updated_at = now()
|
|
""",
|
|
kind,
|
|
key,
|
|
json.dumps(payload),
|
|
)
|
|
|
|
|
|
async def _pc_get(database: Database, kind: str, key: str) -> Any | None:
|
|
pool = database.pool # type: ignore[attr-defined]
|
|
async with pool.acquire() as conn:
|
|
row = await conn.fetchrow(
|
|
"SELECT payload FROM vsphere_pc_state WHERE kind = $1 AND key = $2",
|
|
kind,
|
|
key,
|
|
)
|
|
if row is None:
|
|
return None
|
|
return _decode(row["payload"])
|
|
|
|
|
|
async def _pc_pop(database: Database, kind: str, key: str) -> Any | None:
|
|
pool = database.pool # type: ignore[attr-defined]
|
|
async with pool.acquire() as conn:
|
|
row = await conn.fetchrow(
|
|
"""
|
|
DELETE FROM vsphere_pc_state
|
|
WHERE kind = $1 AND key = $2
|
|
RETURNING payload
|
|
""",
|
|
kind,
|
|
key,
|
|
)
|
|
if row is None:
|
|
return None
|
|
return _decode(row["payload"])
|
|
|
|
|
|
def parse_path_sets(body: str) -> list[str]:
|
|
return re.findall(r"<(?:\w+:)?pathSet[^>]*>([^<]+)</(?:\w+:)?pathSet>", body)
|
|
|
|
|
|
def parse_obj_refs(body: str) -> list[tuple[str, str]]:
|
|
return [
|
|
(m.group(1), m.group(2))
|
|
for m in re.finditer(
|
|
r'<(?:\w+:)?obj[^>]*type="([^"]+)"[^>]*>([^<]+)</(?:\w+:)?obj>',
|
|
body,
|
|
)
|
|
]
|
|
|
|
|
|
def parse_view_types(body: str) -> list[str]:
|
|
return re.findall(r"<(?:\w+:)?type[^>]*>([^<]+)</(?:\w+:)?type>", body)
|
|
|
|
|
|
def parse_prop_types(body: str) -> list[str]:
|
|
"""Types listed under propSet (what the client wants returned)."""
|
|
|
|
return re.findall(
|
|
r"<(?:\w+:)?propSet\b[^>]*>\s*<(?:\w+:)?type[^>]*>([^<]+)</(?:\w+:)?type>",
|
|
body,
|
|
flags=re.DOTALL,
|
|
)
|
|
|
|
|
|
def parse_continue_token(body: str) -> str | None:
|
|
match = re.search(r"<(?:\w+:)?token[^>]*>([^<]+)</(?:\w+:)?token>", body)
|
|
return match.group(1).strip() if match else None
|
|
|
|
|
|
async def next_view_id(database: Database) -> str:
|
|
pool = database.pool # type: ignore[attr-defined]
|
|
async with pool.acquire() as conn:
|
|
row = await conn.fetchrow(
|
|
"""
|
|
INSERT INTO vsphere_pc_state (kind, key, payload, updated_at)
|
|
VALUES ('meta', 'view_seq', '{"n": 1}'::jsonb, now())
|
|
ON CONFLICT (kind, key) DO UPDATE SET
|
|
payload = jsonb_build_object(
|
|
'n', COALESCE((vsphere_pc_state.payload->>'n')::int, 0) + 1
|
|
),
|
|
updated_at = now()
|
|
RETURNING payload
|
|
"""
|
|
)
|
|
n = int((_decode(row["payload"]) or {}).get("n") or 1)
|
|
return f"view-{n}"
|
|
|
|
|
|
async def register_container_view(database: Database, view_id: str, moids: list[str]) -> None:
|
|
payload = {"moids": list(moids)}
|
|
await _pc_put(database, "view", view_id, payload)
|
|
await inventory.upsert_object(
|
|
database,
|
|
moid=view_id,
|
|
type_name="ContainerView",
|
|
name=view_id,
|
|
parent_moid=None,
|
|
props={"view_moids": list(moids)},
|
|
)
|
|
|
|
|
|
def view_moids_from_object(obj: ManagedObject) -> list[str]:
|
|
props = obj.props or {}
|
|
return [str(m) for m in (props.get("view_moids") or [])]
|
|
|
|
|
|
async def store_page_token(
|
|
database: Database,
|
|
remaining_moids: list[str],
|
|
path_sets: list[str] | None = None,
|
|
) -> str:
|
|
token = f"token-{secrets.token_hex(8)}"
|
|
await _pc_put(
|
|
database,
|
|
"token",
|
|
token,
|
|
{"moids": list(remaining_moids), "path_sets": list(path_sets or [])},
|
|
)
|
|
return token
|
|
|
|
|
|
async def take_page_token(database: Database, token: str) -> list[str] | None:
|
|
payload = await _pc_pop(database, "token", token)
|
|
if payload is None:
|
|
return None
|
|
if isinstance(payload, list):
|
|
return payload
|
|
return list(payload.get("moids") or [])
|
|
|
|
|
|
async def take_page_token_full(
|
|
database: Database, token: str
|
|
) -> tuple[list[str], list[str]] | None:
|
|
payload = await _pc_pop(database, "token", token)
|
|
if payload is None:
|
|
return None
|
|
if isinstance(payload, list):
|
|
return payload, []
|
|
return list(payload.get("moids") or []), list(payload.get("path_sets") or [])
|
|
|
|
|
|
def _xsi(type_name: str) -> str:
|
|
return f'xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:type="{escape(type_name)}"'
|
|
|
|
|
|
def _prop(name: str, value: str, *, xsi_type: str | None = None) -> str:
|
|
# govmomi SOAP AnyType decode requires xsi:type; bare <val> leaves Name empty → Ancestors panic.
|
|
type_name = xsi_type or "xsd:string"
|
|
return (
|
|
f"<propSet><name>{escape(name)}</name>"
|
|
f"<val {_xsi(type_name)}>{escape(value)}</val></propSet>"
|
|
)
|
|
|
|
|
|
def _prop_bool(name: str, value: bool) -> str:
|
|
return _prop(name, "true" if value else "false", xsi_type="xsd:boolean")
|
|
|
|
|
|
def _prop_int(name: str, value: int) -> str:
|
|
return _prop(name, str(value), xsi_type="xsd:int")
|
|
|
|
|
|
def _prop_long(name: str, value: int) -> str:
|
|
return _prop(name, str(value), xsi_type="xsd:long")
|
|
|
|
|
|
def _prop_raw(name: str, inner_xml: str, *, xsi_type: str) -> str:
|
|
return f"<propSet><name>{escape(name)}</name><val {_xsi(xsi_type)}>{inner_xml}</val></propSet>"
|
|
|
|
|
|
def _prop_mor(name: str, type_name: str, moid: str) -> str:
|
|
return (
|
|
f"<propSet><name>{escape(name)}</name>"
|
|
f'<val {_xsi("ManagedObjectReference")} type="{escape(type_name)}">'
|
|
f"{escape(moid)}</val></propSet>"
|
|
)
|
|
|
|
|
|
def _prop_array_mor(name: str, items: list[tuple[str, str]]) -> str:
|
|
if not items:
|
|
return (
|
|
f"<propSet><name>{escape(name)}</name>"
|
|
f"<val {_xsi('ArrayOfManagedObjectReference')}></val></propSet>"
|
|
)
|
|
inner = "".join(
|
|
f'<ManagedObjectReference type="{escape(t)}">{escape(m)}</ManagedObjectReference>'
|
|
for t, m in items
|
|
)
|
|
return (
|
|
f"<propSet><name>{escape(name)}</name>"
|
|
f"<val {_xsi('ArrayOfManagedObjectReference')}>{inner}</val></propSet>"
|
|
)
|
|
|
|
|
|
def _vim_power(state: str) -> str:
|
|
return {
|
|
"POWERED_ON": "poweredOn",
|
|
"POWERED_OFF": "poweredOff",
|
|
"SUSPENDED": "suspended",
|
|
}.get(state, "poweredOff")
|
|
|
|
|
|
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"
|
|
if moid.startswith("resgroup-"):
|
|
return "ResourcePool"
|
|
if moid.startswith("network-"):
|
|
return "Network"
|
|
if moid.startswith("dvportgroup-"):
|
|
return "DistributedVirtualPortgroup"
|
|
if moid.startswith("dvs-"):
|
|
return "VmwareDistributedVirtualSwitch"
|
|
if moid.startswith("datastore-"):
|
|
return "Datastore"
|
|
return "ManagedEntity"
|
|
|
|
|
|
def _tools_status(props: dict[str, Any]) -> str:
|
|
raw = str(props.get("tools_status") or "")
|
|
mapping = {
|
|
"GUEST_TOOLS_RUNNING": "toolsOk",
|
|
"GUEST_TOOLS_NOT_RUNNING": "toolsNotRunning",
|
|
"GUEST_TOOLS_OLD": "toolsOld",
|
|
}
|
|
return mapping.get(
|
|
raw, "toolsOk" if props.get("power_state") == "POWERED_ON" else "toolsNotRunning"
|
|
)
|
|
|
|
|
|
def _guest_id(props: dict[str, Any]) -> str:
|
|
guest = str(props.get("guest_OS") or "OTHER_GUEST_64")
|
|
# REST enums often use UBUNTU_64_GUEST; SOAP guestId uses ubuntu64Guest.
|
|
mapping = {
|
|
"UBUNTU_64_GUEST": "ubuntu64Guest",
|
|
"CENTOS_64_GUEST": "centos64Guest",
|
|
"RHEL_8_64_GUEST": "rhel8_64Guest",
|
|
"WINDOWS_2019_64_GUEST": "windows2019srv_64Guest",
|
|
"OTHER_GUEST_64": "otherGuest64",
|
|
"OTHER_GUEST": "otherGuest",
|
|
}
|
|
if guest in mapping:
|
|
return mapping[guest]
|
|
if guest.endswith("_GUEST"):
|
|
return guest.lower().replace("_guest", "Guest").replace("_64", "64")
|
|
return guest
|
|
|
|
|
|
def _instance_uuid(obj: ManagedObject) -> str:
|
|
identity = obj.props.get("identity") or {}
|
|
if identity.get("instance_uuid"):
|
|
return str(identity["instance_uuid"])
|
|
# Stable synthetic UUID from moid digits.
|
|
digits = "".join(ch for ch in obj.moid if ch.isdigit()) or "0"
|
|
n = int(digits) % 10_000_000_000_000
|
|
return f"5029aaaa-bbbb-cccc-dddd-{n:012d}"
|
|
|
|
|
|
def _bios_uuid(obj: ManagedObject) -> str:
|
|
identity = obj.props.get("identity") or {}
|
|
if identity.get("bios_uuid"):
|
|
return str(identity["bios_uuid"])
|
|
digits = "".join(ch for ch in obj.moid if ch.isdigit()) or "0"
|
|
n = int(digits) % 10_000_000_000_000
|
|
return f"4200aaaa-bbbb-cccc-dddd-{n:012d}"
|
|
|
|
|
|
def _folder_child_types(obj: ManagedObject, children: list[ManagedObject]) -> list[str]:
|
|
"""Declare Folder.childType the way vCenter does (not ManagedEntity)."""
|
|
|
|
from_children = sorted({c.type for c in children})
|
|
name = (obj.name or "").lower()
|
|
moid = obj.moid or ""
|
|
if name in {"datacenters", "datacenter"} or moid.startswith("group-d"):
|
|
return ["Folder", "Datacenter"]
|
|
if name == "vm" or moid.startswith("group-v"):
|
|
return ["Folder", "VirtualMachine", "VirtualApp"]
|
|
if name == "host" or moid.startswith("group-h"):
|
|
return ["Folder", "ComputeResource", "ClusterComputeResource", "HostSystem"]
|
|
if name in {"datastore", "datastores"} or moid.startswith("group-s"):
|
|
return ["Folder", "Datastore", "StoragePod"]
|
|
if name == "network" or moid.startswith("group-n"):
|
|
return [
|
|
"Folder",
|
|
"Network",
|
|
"DistributedVirtualPortgroup",
|
|
"VmwareDistributedVirtualSwitch",
|
|
"DistributedVirtualSwitch",
|
|
]
|
|
if from_children:
|
|
return ["Folder", *from_children]
|
|
return ["Folder", "VirtualMachine"]
|
|
|
|
|
|
def _vm_devices_xml(props: dict[str, Any]) -> str:
|
|
"""Minimal ArrayOfVirtualDevice suitable for govmomi / Terraform device reads."""
|
|
|
|
xsi = 'xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"'
|
|
datastore = str(props.get("datastore") or "datastore-31")
|
|
devices: list[str] = []
|
|
# PCI root + SCSI — Terraform device sorter requires controller assignment.
|
|
devices.append(
|
|
f'<VirtualDevice {xsi} xsi:type="VirtualPCIController">'
|
|
"<key>100</key><deviceInfo><label>PCI controller 0</label>"
|
|
"<summary>PCI controller 0</summary></deviceInfo>"
|
|
"<busNumber>0</busNumber></VirtualDevice>"
|
|
)
|
|
devices.append(
|
|
f'<VirtualDevice {xsi} xsi:type="VirtualLsiLogicController">'
|
|
"<key>1000</key><deviceInfo><label>SCSI controller 0</label>"
|
|
"<summary>LSI Logic</summary></deviceInfo>"
|
|
"<controllerKey>100</controllerKey><unitNumber>3</unitNumber>"
|
|
"<busNumber>0</busNumber><sharedBus>noSharing</sharedBus>"
|
|
"<scsiCtlrUnitNumber>7</scsiCtlrUnitNumber>"
|
|
"</VirtualDevice>"
|
|
)
|
|
devices.append(
|
|
f'<VirtualDevice {xsi} xsi:type="VirtualIDEController">'
|
|
"<key>200</key><deviceInfo><label>IDE 0</label><summary>IDE 0</summary></deviceInfo>"
|
|
"<busNumber>0</busNumber></VirtualDevice>"
|
|
)
|
|
# Terraform/pulumi-vsphere getVirtualMachine requires a video card device.
|
|
devices.append(
|
|
f'<VirtualDevice {xsi} xsi:type="VirtualMachineVideoCard">'
|
|
"<key>500</key><deviceInfo><label>Video card</label>"
|
|
"<summary>Video card</summary></deviceInfo>"
|
|
"<controllerKey>100</controllerKey><unitNumber>0</unitNumber>"
|
|
"<videoRamSizeInKB>4096</videoRamSizeInKB>"
|
|
"<numDisplays>1</numDisplays><useAutoDetect>false</useAutoDetect>"
|
|
"<enable3DSupport>false</enable3DSupport>"
|
|
"</VirtualDevice>"
|
|
)
|
|
disks = list(props.get("disks") or [])
|
|
if not disks:
|
|
disks = [{"key": "2000", "value": {"label": "Hard disk 1", "capacity": 42949672960}}]
|
|
for disk in disks:
|
|
key = int(str(disk.get("key") or 2000))
|
|
value = disk.get("value") or disk
|
|
capacity = int(value.get("capacity") or 42949672960)
|
|
capacity_kb = max(capacity // 1024, 1)
|
|
label = escape(str(value.get("label") or f"Hard disk {key}"))
|
|
uuid = escape(str(value.get("uuid") or f"6000C29{key:08x}-0000-0000-0000-000000000001"))
|
|
file_name = escape(str(value.get("file_name") or f"[datastore1] disk-{key}.vmdk"))
|
|
devices.append(
|
|
f'<VirtualDevice {xsi} xsi:type="VirtualDisk">'
|
|
f"<key>{key}</key><deviceInfo><label>{label}</label>"
|
|
f"<summary>{capacity_kb} KB</summary></deviceInfo>"
|
|
f"<controllerKey>1000</controllerKey><unitNumber>{max(key - 2000, 0)}</unitNumber>"
|
|
f"<capacityInKB>{capacity_kb}</capacityInKB>"
|
|
f"<capacityInBytes>{capacity}</capacityInBytes>"
|
|
f'<backing {xsi} xsi:type="VirtualDiskFlatVer2BackingInfo">'
|
|
f"<fileName>{file_name}</fileName>"
|
|
f'<datastore type="Datastore">{escape(datastore)}</datastore>'
|
|
f"<diskMode>persistent</diskMode>"
|
|
f"<thinProvisioned>true</thinProvisioned>"
|
|
f"<eagerlyScrub>false</eagerlyScrub>"
|
|
f"<writeThrough>false</writeThrough>"
|
|
f"<uuid>{uuid}</uuid>"
|
|
f"</backing></VirtualDevice>"
|
|
)
|
|
nics = list(props.get("nics") or [])
|
|
if not nics:
|
|
nics = [
|
|
{
|
|
"key": "4000",
|
|
"value": {
|
|
"label": "Network adapter 1",
|
|
"mac": "00:50:56:01:00:01",
|
|
"state": "CONNECTED",
|
|
"backing": {"network": "network-41"},
|
|
},
|
|
}
|
|
]
|
|
for nic in nics:
|
|
key = int(str(nic.get("key") or 4000))
|
|
value = nic.get("value") or nic
|
|
backing = value.get("backing") or {}
|
|
network = str(backing.get("network") or "network-41")
|
|
mac = escape(str(value.get("mac") or "00:50:56:00:00:01"))
|
|
label = escape(str(value.get("label") or f"Network adapter {key}"))
|
|
connected = "true" if str(value.get("state") or "").upper() == "CONNECTED" else "false"
|
|
devices.append(
|
|
f'<VirtualDevice {xsi} xsi:type="VirtualVmxnet3">'
|
|
f"<key>{key}</key><deviceInfo><label>{label}</label>"
|
|
f"<summary>VMXNET3</summary></deviceInfo>"
|
|
f"<controllerKey>100</controllerKey><unitNumber>7</unitNumber>"
|
|
f"<macAddress>{mac}</macAddress>"
|
|
f"<addressType>assigned</addressType>"
|
|
f"<connectable><connected>{connected}</connected>"
|
|
f"<startConnected>true</startConnected><allowGuestControl>true</allowGuestControl>"
|
|
f"</connectable>"
|
|
"<resourceAllocation><reservation>0</reservation><limit>-1</limit>"
|
|
"<share><shares>50</shares><level>normal</level></share>"
|
|
"</resourceAllocation>"
|
|
f'<backing {xsi} xsi:type="VirtualEthernetCardNetworkBackingInfo">'
|
|
f"<deviceName>VM Network</deviceName>"
|
|
f'<network type="Network">{escape(network)}</network>'
|
|
f"</backing></VirtualDevice>"
|
|
)
|
|
for cdrom in props.get("cdroms") or []:
|
|
key = int(str(cdrom.get("cdrom") or cdrom.get("key") or 3000))
|
|
label = escape(str(cdrom.get("label") or "CD/DVD drive 1"))
|
|
devices.append(
|
|
f'<VirtualDevice {xsi} xsi:type="VirtualCdrom">'
|
|
f"<key>{key}</key><deviceInfo><label>{label}</label>"
|
|
f"<summary>CD/DVD</summary></deviceInfo>"
|
|
f"<controllerKey>200</controllerKey><unitNumber>0</unitNumber>"
|
|
f'<backing {xsi} xsi:type="VirtualCdromIsoBackingInfo">'
|
|
f"<fileName>[datastore1] ISO/ubuntu.iso</fileName>"
|
|
f"</backing></VirtualDevice>"
|
|
)
|
|
return "".join(devices)
|
|
|
|
|
|
def build_prop_map(
|
|
obj: ManagedObject,
|
|
*,
|
|
children: list[ManagedObject],
|
|
all_by_moid: dict[str, ManagedObject],
|
|
) -> dict[str, str]:
|
|
"""Map property path -> already-escaped XML fragment for <propSet>…."""
|
|
|
|
props = obj.props
|
|
out: dict[str, str] = {
|
|
"name": _prop("name", obj.name),
|
|
"overallStatus": _prop("overallStatus", "green"),
|
|
}
|
|
if obj.parent_moid:
|
|
out["parent"] = _prop_mor("parent", _parent_type(obj.parent_moid), obj.parent_moid)
|
|
|
|
child_items = [(c.type, c.moid) for c in children]
|
|
if obj.type == "Folder":
|
|
out["childEntity"] = _prop_array_mor("childEntity", child_items)
|
|
# Folder.childType is ArrayOfString (govmomi folder helpers reject scalar "ManagedEntity").
|
|
child_types = _folder_child_types(obj, children)
|
|
out["childType"] = _prop_raw(
|
|
"childType",
|
|
"".join(f"<string>{escape(t)}</string>" for t in child_types),
|
|
xsi_type="ArrayOfString",
|
|
)
|
|
if obj.type == "Datacenter":
|
|
out["hostFolder"] = _prop_mor(
|
|
"hostFolder", "Folder", str(props.get("host_folder") or "group-h23")
|
|
)
|
|
out["vmFolder"] = _prop_mor(
|
|
"vmFolder", "Folder", str(props.get("vm_folder") or "group-v23")
|
|
)
|
|
out["datastoreFolder"] = _prop_mor(
|
|
"datastoreFolder", "Folder", str(props.get("datastore_folder") or "group-s23")
|
|
)
|
|
out["networkFolder"] = _prop_mor(
|
|
"networkFolder", "Folder", str(props.get("network_folder") or "group-n23")
|
|
)
|
|
datastores = [("Datastore", o.moid) for o in all_by_moid.values() if o.type == "Datastore"]
|
|
networks = [
|
|
(o.type, o.moid)
|
|
for o in all_by_moid.values()
|
|
if o.type in {"Network", "DistributedVirtualPortgroup"}
|
|
]
|
|
out["datastore"] = _prop_array_mor("datastore", datastores)
|
|
out["network"] = _prop_array_mor("network", networks)
|
|
if obj.type == "ClusterComputeResource":
|
|
hosts = [
|
|
("HostSystem", c.moid)
|
|
for c in all_by_moid.values()
|
|
if c.type == "HostSystem" and c.parent_moid == obj.moid
|
|
]
|
|
out["host"] = _prop_array_mor("host", hosts)
|
|
out["resourcePool"] = _prop_mor(
|
|
"resourcePool", "ResourcePool", str(props.get("resource_pool") or "resgroup-22")
|
|
)
|
|
# Terraform CreateVM loads default devices via EnvironmentBrowser.
|
|
env_browser = str(props.get("environment_browser") or f"envbrowser-{obj.moid}")
|
|
out["environmentBrowser"] = _prop_mor(
|
|
"environmentBrowser", "EnvironmentBrowser", env_browser
|
|
)
|
|
out["datastore"] = _prop_array_mor(
|
|
"datastore",
|
|
[("Datastore", o.moid) for o in all_by_moid.values() if o.type == "Datastore"][:8],
|
|
)
|
|
out["network"] = _prop_array_mor(
|
|
"network",
|
|
[
|
|
(o.type, o.moid)
|
|
for o in all_by_moid.values()
|
|
if o.type in {"Network", "DistributedVirtualPortgroup"}
|
|
][:20],
|
|
)
|
|
out["name"] = _prop("name", obj.name)
|
|
out["summary.effectiveCpu"] = _prop_int("summary.effectiveCpu", 48000)
|
|
out["summary.effectiveMemory"] = _prop_long("summary.effectiveMemory", 256 * 1024)
|
|
out["summary.numHosts"] = _prop_int("summary.numHosts", len(hosts))
|
|
out["configuration.drsConfig.enabled"] = _prop_bool("configuration.drsConfig.enabled", True)
|
|
out["configuration.dasConfig.enabled"] = _prop_bool(
|
|
"configuration.dasConfig.enabled", False
|
|
)
|
|
if obj.type == "ResourcePool":
|
|
out["owner"] = _prop_mor("owner", "ClusterComputeResource", obj.parent_moid or "domain-c21")
|
|
vms = [
|
|
("VirtualMachine", o.moid)
|
|
for o in all_by_moid.values()
|
|
if o.type == "VirtualMachine" and str(o.props.get("resource_pool") or "") == obj.moid
|
|
]
|
|
if not vms:
|
|
vms = [
|
|
("VirtualMachine", o.moid)
|
|
for o in all_by_moid.values()
|
|
if o.type == "VirtualMachine"
|
|
and o.parent_moid
|
|
and "group-v" in (o.parent_moid or "")
|
|
][:50]
|
|
out["vm"] = _prop_array_mor("vm", vms)
|
|
out["resourcePool"] = _prop_array_mor("resourcePool", [])
|
|
out["config.cpuAllocation.limit"] = _prop_long("config.cpuAllocation.limit", -1)
|
|
out["config.memoryAllocation.limit"] = _prop_long("config.memoryAllocation.limit", -1)
|
|
if obj.type == "VirtualMachine":
|
|
power = _vim_power(str(props.get("power_state", "POWERED_OFF")))
|
|
cpu = int(props.get("cpu_count") or 1)
|
|
mem = int(props.get("memory_size_mib") or 1024)
|
|
guest_id = _guest_id(props)
|
|
inst_uuid = _instance_uuid(obj)
|
|
bios_uuid = _bios_uuid(obj)
|
|
host = str(props.get("host") or "")
|
|
datastore = str(props.get("datastore") or "datastore-31")
|
|
ds_obj = all_by_moid.get(datastore)
|
|
datastore_name = ds_obj.name if ds_obj is not None else "datastore1"
|
|
pool = str(props.get("resource_pool") or "resgroup-22")
|
|
networks = [str(n) for n in (props.get("networks") or ["network-41"])]
|
|
guest_ip = str(props.get("guest_ip") or props.get("ip_address") or "")
|
|
hostname = str((props.get("identity") or {}).get("name") or obj.name)
|
|
template = bool(props.get("template"))
|
|
tools = _tools_status(props)
|
|
|
|
out["runtime.powerState"] = _prop("runtime.powerState", power)
|
|
out["summary.runtime.powerState"] = _prop("summary.runtime.powerState", power)
|
|
out["config.hardware.numCPU"] = _prop_int("config.hardware.numCPU", cpu)
|
|
out["config.hardware.memoryMB"] = _prop_int("config.hardware.memoryMB", mem)
|
|
out["config.hardware.numCoresPerSocket"] = _prop_int("config.hardware.numCoresPerSocket", 1)
|
|
out["config.uuid"] = _prop("config.uuid", bios_uuid)
|
|
out["config.instanceUuid"] = _prop("config.instanceUuid", inst_uuid)
|
|
out["config.guestId"] = _prop("config.guestId", guest_id)
|
|
out["config.guestFullName"] = _prop(
|
|
"config.guestFullName", str(props.get("guest_OS") or guest_id)
|
|
)
|
|
out["config.name"] = _prop("config.name", obj.name)
|
|
out["config.version"] = _prop(
|
|
"config.version", str(props.get("hardware_version") or "vmx-19").lower()
|
|
)
|
|
out["config.template"] = _prop_bool("config.template", template)
|
|
out["config.files.vmPathName"] = _prop(
|
|
"config.files.vmPathName", f"[{datastore_name}] {obj.name}/{obj.name}.vmx"
|
|
)
|
|
out["config.hardware.device"] = _prop_raw(
|
|
"config.hardware.device", _vm_devices_xml(props), xsi_type="ArrayOfVirtualDevice"
|
|
)
|
|
# Terraform flattenVirtualMachineConfigInfo requires non-nil Tools / allocations / bootOptions.
|
|
out["config.tools"] = _prop_raw(
|
|
"config.tools",
|
|
"<toolsUpgradePolicy>manual</toolsUpgradePolicy>"
|
|
"<afterPowerOn>true</afterPowerOn>"
|
|
"<afterResume>true</afterResume>"
|
|
"<beforeGuestStandby>true</beforeGuestStandby>"
|
|
"<beforeGuestShutdown>true</beforeGuestShutdown>"
|
|
"<beforeGuestReboot>true</beforeGuestReboot>"
|
|
"<syncTimeWithHost>false</syncTimeWithHost>"
|
|
"<syncTimeWithHostAllowed>true</syncTimeWithHostAllowed>",
|
|
xsi_type="ToolsConfigInfo",
|
|
)
|
|
out["config.firmware"] = _prop("config.firmware", str(props.get("firmware") or "bios"))
|
|
out["config.changeVersion"] = _prop("config.changeVersion", "1")
|
|
out["config.annotation"] = _prop("config.annotation", str(props.get("annotation") or ""))
|
|
out["config.alternateGuestName"] = _prop("config.alternateGuestName", "")
|
|
out["config.memoryHotAddEnabled"] = _prop_bool("config.memoryHotAddEnabled", False)
|
|
out["config.cpuHotAddEnabled"] = _prop_bool("config.cpuHotAddEnabled", False)
|
|
out["config.cpuHotRemoveEnabled"] = _prop_bool("config.cpuHotRemoveEnabled", False)
|
|
out["config.memoryReservationLockedToMax"] = _prop_bool(
|
|
"config.memoryReservationLockedToMax", False
|
|
)
|
|
out["config.nestedHVEnabled"] = _prop_bool("config.nestedHVEnabled", False)
|
|
out["config.vPMCEnabled"] = _prop_bool("config.vPMCEnabled", False)
|
|
out["config.swapPlacement"] = _prop("config.swapPlacement", "inherit")
|
|
out["config.flags"] = _prop_raw(
|
|
"config.flags",
|
|
"<enableLogging>true</enableLogging>"
|
|
"<diskUuidEnabled>false</diskUuidEnabled>"
|
|
"<virtualMmuUsage>automatic</virtualMmuUsage>"
|
|
"<virtualExecUsage>hvAuto</virtualExecUsage>"
|
|
"<snapshotLocked>false</snapshotLocked>"
|
|
"<monitorType>release</monitorType>"
|
|
"<vbsEnabled>false</vbsEnabled>"
|
|
"<vvtdEnabled>false</vvtdEnabled>",
|
|
xsi_type="VirtualMachineFlagInfo",
|
|
)
|
|
alloc = (
|
|
"<reservation>0</reservation><limit>-1</limit>"
|
|
"<shares><shares>1000</shares><level>normal</level></shares>"
|
|
)
|
|
out["config.cpuAllocation"] = _prop_raw(
|
|
"config.cpuAllocation", alloc, xsi_type="ResourceAllocationInfo"
|
|
)
|
|
out["config.memoryAllocation"] = _prop_raw(
|
|
"config.memoryAllocation",
|
|
"<reservation>0</reservation><limit>-1</limit>"
|
|
"<shares><shares>20480</shares><level>normal</level></shares>",
|
|
xsi_type="ResourceAllocationInfo",
|
|
)
|
|
out["config.bootOptions"] = _prop_raw(
|
|
"config.bootOptions",
|
|
"<bootDelay>0</bootDelay>"
|
|
"<enterBIOSSetup>false</enterBIOSSetup>"
|
|
"<bootRetryEnabled>false</bootRetryEnabled>"
|
|
"<bootRetryDelay>10000</bootRetryDelay>"
|
|
"<efiSecureBootEnabled>false</efiSecureBootEnabled>",
|
|
xsi_type="VirtualMachineBootOptions",
|
|
)
|
|
out["summary.config.numCpu"] = _prop_int("summary.config.numCpu", cpu)
|
|
out["summary.config.memorySizeMB"] = _prop_int("summary.config.memorySizeMB", mem)
|
|
out["summary.config.name"] = _prop("summary.config.name", obj.name)
|
|
out["summary.config.uuid"] = _prop("summary.config.uuid", bios_uuid)
|
|
out["summary.config.instanceUuid"] = _prop("summary.config.instanceUuid", inst_uuid)
|
|
out["summary.config.guestId"] = _prop("summary.config.guestId", guest_id)
|
|
out["summary.guest.ipAddress"] = _prop("summary.guest.ipAddress", guest_ip)
|
|
out["summary.guest.hostName"] = _prop("summary.guest.hostName", hostname)
|
|
out["summary.guest.toolsStatus"] = _prop("summary.guest.toolsStatus", tools)
|
|
out["guest.ipAddress"] = _prop("guest.ipAddress", guest_ip)
|
|
out["guest.hostName"] = _prop("guest.hostName", hostname)
|
|
out["guest.toolsStatus"] = _prop("guest.toolsStatus", tools)
|
|
out["guest.guestId"] = _prop("guest.guestId", guest_id)
|
|
out["guest.guestState"] = _prop(
|
|
"guest.guestState", "running" if power == "poweredOn" else "notRunning"
|
|
)
|
|
if host:
|
|
out["runtime.host"] = _prop_mor("runtime.host", "HostSystem", host)
|
|
out["summary.runtime.host"] = _prop_mor("summary.runtime.host", "HostSystem", host)
|
|
out["resourcePool"] = _prop_mor("resourcePool", "ResourcePool", pool)
|
|
out["datastore"] = _prop_array_mor("datastore", [("Datastore", datastore)])
|
|
net_items: list[tuple[str, str]] = []
|
|
for net_id in networks:
|
|
net_obj = all_by_moid.get(net_id)
|
|
net_items.append((net_obj.type if net_obj else "Network", net_id))
|
|
out["network"] = _prop_array_mor("network", net_items)
|
|
out["layoutEx.file"] = _prop_raw(
|
|
"layoutEx.file",
|
|
f"<VirtualMachineFileLayoutExFileInfo><key>0</key>"
|
|
f"<name>[{escape(datastore_name)}] {escape(obj.name)}/{escape(obj.name)}.vmx</name>"
|
|
f"<type>config</type><size>4096</size></VirtualMachineFileLayoutExFileInfo>",
|
|
xsi_type="ArrayOfVirtualMachineFileLayoutExFileInfo",
|
|
)
|
|
if obj.type == "HostSystem":
|
|
cpu_mhz = int(props.get("cpu_mhz") or 2400)
|
|
cpu_cores = int(props.get("cpu_cores") or props.get("num_cpu_cores") or 16)
|
|
mem_bytes = int(props.get("memory_bytes") or props.get("memory_size") or 137438953472)
|
|
out["runtime.connectionState"] = _prop("runtime.connectionState", "connected")
|
|
out["runtime.powerState"] = _prop("runtime.powerState", "poweredOn")
|
|
out["runtime.inMaintenanceMode"] = _prop_bool(
|
|
"runtime.inMaintenanceMode", bool(props.get("maintenance_mode"))
|
|
)
|
|
out["summary.config.name"] = _prop("summary.config.name", obj.name)
|
|
out["summary.hardware.uuid"] = _prop(
|
|
"summary.hardware.uuid", str(props.get("uuid") or f"host-uuid-{obj.moid}")
|
|
)
|
|
out["summary.hardware.memorySize"] = _prop_long("summary.hardware.memorySize", mem_bytes)
|
|
out["summary.hardware.numCpuCores"] = _prop_int("summary.hardware.numCpuCores", cpu_cores)
|
|
out["summary.hardware.cpuMhz"] = _prop_int("summary.hardware.cpuMhz", cpu_mhz)
|
|
out["hardware.memorySize"] = _prop_long("hardware.memorySize", mem_bytes)
|
|
out["hardware.cpuInfo.numCpuCores"] = _prop_int("hardware.cpuInfo.numCpuCores", cpu_cores)
|
|
out["hardware.cpuInfo.hz"] = _prop_long("hardware.cpuInfo.hz", cpu_mhz * 1_000_000)
|
|
out["config.product.version"] = _prop(
|
|
"config.product.version", str(props.get("version") or "8.0.0")
|
|
)
|
|
out["config.product.fullName"] = _prop(
|
|
"config.product.fullName", f"VMware ESXi {props.get('version') or '8.0.0'}"
|
|
)
|
|
ds_ids = (
|
|
props.get("datastores")
|
|
or [o.moid for o in all_by_moid.values() if o.type == "Datastore"][:4]
|
|
)
|
|
out["datastore"] = _prop_array_mor("datastore", [("Datastore", str(d)) for d in ds_ids])
|
|
net_ids = (
|
|
props.get("networks")
|
|
or [o.moid for o in all_by_moid.values() if o.type == "Network"][:4]
|
|
)
|
|
out["network"] = _prop_array_mor("network", [("Network", str(n)) for n in net_ids])
|
|
vms = [
|
|
("VirtualMachine", o.moid)
|
|
for o in all_by_moid.values()
|
|
if o.type == "VirtualMachine" and str(o.props.get("host") or "") == obj.moid
|
|
]
|
|
out["vm"] = _prop_array_mor("vm", vms)
|
|
out["environmentBrowser"] = _prop_mor(
|
|
"environmentBrowser",
|
|
"EnvironmentBrowser",
|
|
str(props.get("environment_browser") or f"envbrowser-{obj.moid}"),
|
|
)
|
|
if obj.type == "EnvironmentBrowser" or obj.moid.startswith("envbrowser-"):
|
|
out["name"] = _prop("name", obj.name)
|
|
if obj.type == "Datastore":
|
|
capacity = int(props.get("capacity") or 0)
|
|
free = int(props.get("free_space") or 0)
|
|
out["summary.capacity"] = _prop_long("summary.capacity", capacity)
|
|
out["summary.freeSpace"] = _prop_long("summary.freeSpace", free)
|
|
out["summary.type"] = _prop("summary.type", str(props.get("type") or "VMFS"))
|
|
out["summary.name"] = _prop("summary.name", obj.name)
|
|
out["summary.url"] = _prop("summary.url", f"ds:///vmfs/volumes/{obj.moid}/")
|
|
out["summary.accessible"] = _prop_bool("summary.accessible", True)
|
|
out["summary.multipleHostAccess"] = _prop_bool("summary.multipleHostAccess", True)
|
|
out["info.name"] = _prop("info.name", obj.name)
|
|
out["info.url"] = _prop("info.url", f"ds:///vmfs/volumes/{obj.moid}/")
|
|
out["info.freeSpace"] = _prop_long("info.freeSpace", free)
|
|
out["info.maxFileSize"] = _prop_long("info.maxFileSize", 62 * 1024**4)
|
|
hosts = [o.moid for o in all_by_moid.values() if o.type == "HostSystem"][:20]
|
|
# Datastore.host is ArrayOfDatastoreHostMount — not ArrayOfManagedObjectReference.
|
|
host_mounts = "".join(
|
|
"<DatastoreHostMount>"
|
|
f'<key type="HostSystem">{escape(hid)}</key>'
|
|
"<mountInfo>"
|
|
f"<path>/vmfs/volumes/{escape(obj.moid)}</path>"
|
|
"<accessMode>readWrite</accessMode>"
|
|
"<mounted>true</mounted>"
|
|
"<accessible>true</accessible>"
|
|
"</mountInfo>"
|
|
"</DatastoreHostMount>"
|
|
for hid in hosts
|
|
)
|
|
out["host"] = _prop_raw("host", host_mounts, xsi_type="ArrayOfDatastoreHostMount")
|
|
vms = [
|
|
("VirtualMachine", o.moid)
|
|
for o in all_by_moid.values()
|
|
if o.type == "VirtualMachine" and str(o.props.get("datastore") or "") == obj.moid
|
|
][:100]
|
|
out["vm"] = _prop_array_mor("vm", vms)
|
|
if obj.type in {"Network", "DistributedVirtualPortgroup", "VmwareDistributedVirtualSwitch"}:
|
|
out["summary.name"] = _prop("summary.name", obj.name)
|
|
out["summary.accessible"] = _prop_bool("summary.accessible", True)
|
|
hosts = [("HostSystem", o.moid) for o in all_by_moid.values() if o.type == "HostSystem"][
|
|
:20
|
|
]
|
|
out["host"] = _prop_array_mor("host", hosts)
|
|
vms = [
|
|
("VirtualMachine", o.moid)
|
|
for o in all_by_moid.values()
|
|
if o.type == "VirtualMachine"
|
|
and (
|
|
obj.moid in (o.props.get("networks") or [])
|
|
or obj.name in (o.props.get("networks") or [])
|
|
)
|
|
][:100]
|
|
out["vm"] = _prop_array_mor("vm", vms)
|
|
if obj.type == "DistributedVirtualPortgroup":
|
|
key = str(props.get("key") or obj.moid)
|
|
out["key"] = _prop("key", key)
|
|
out["config.name"] = _prop("config.name", obj.name)
|
|
out["config.key"] = _prop("config.key", key)
|
|
dvs = str(props.get("dvs") or props.get("distributed_switch") or "dvs-41")
|
|
out["config.distributedVirtualSwitch"] = _prop_mor(
|
|
"config.distributedVirtualSwitch", "VmwareDistributedVirtualSwitch", dvs
|
|
)
|
|
if obj.type == "VmwareDistributedVirtualSwitch":
|
|
out["uuid"] = _prop("uuid", str(props.get("uuid") or f"dvs-uuid-{obj.moid}"))
|
|
out["summary.name"] = _prop("summary.name", obj.name)
|
|
out["config.uuid"] = _prop(
|
|
"config.uuid", str(props.get("uuid") or f"dvs-uuid-{obj.moid}")
|
|
)
|
|
pgs = [
|
|
("DistributedVirtualPortgroup", o.moid)
|
|
for o in all_by_moid.values()
|
|
if o.type == "DistributedVirtualPortgroup"
|
|
]
|
|
out["portgroup"] = _prop_array_mor("portgroup", pgs)
|
|
if obj.type == "ContainerView" or obj.moid.startswith("view-"):
|
|
moids = view_moids_from_object(obj)
|
|
items = []
|
|
for moid in moids:
|
|
child = all_by_moid.get(moid)
|
|
if child:
|
|
items.append((child.type, child.moid))
|
|
out["view"] = _prop_array_mor("view", items)
|
|
if obj.type == "Task":
|
|
state = str(props.get("state") or "success")
|
|
progress = 100 if state == "success" else 50
|
|
xsi = 'xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"'
|
|
result_xml = ""
|
|
entity_xml = ""
|
|
if props.get("result_moid"):
|
|
rtype = str(props.get("result_type") or "VirtualMachine")
|
|
rmoid = escape(str(props["result_moid"]))
|
|
result_xml = (
|
|
f'<result {xsi} xsi:type="ManagedObjectReference" type="{escape(rtype)}">'
|
|
f"{rmoid}</result>"
|
|
)
|
|
entity_xml = (
|
|
f'<entity {xsi} xsi:type="ManagedObjectReference" type="{escape(rtype)}">'
|
|
f"{rmoid}</entity><entityName>{escape(rtype)}</entityName>"
|
|
)
|
|
info_inner = (
|
|
f"<key>{escape(obj.moid)}</key>"
|
|
f'<task type="Task">{escape(obj.moid)}</task>'
|
|
f"<state>{escape(state)}</state>"
|
|
"<cancelled>false</cancelled><cancelable>false</cancelable>"
|
|
f"<descriptionId>{escape(str(props.get('operation') or 'task'))}</descriptionId>"
|
|
f"<progress>{progress}</progress>"
|
|
f"{entity_xml}{result_xml}"
|
|
)
|
|
out["info"] = _prop_raw("info", info_inner, xsi_type="TaskInfo")
|
|
out["info.state"] = _prop("info.state", state)
|
|
out["info.descriptionId"] = _prop(
|
|
"info.descriptionId", str(props.get("operation") or "task")
|
|
)
|
|
if props.get("entity"):
|
|
out["info.entity"] = _prop_mor("info.entity", "VirtualMachine", str(props["entity"]))
|
|
out["info.progress"] = _prop_int("info.progress", progress)
|
|
if props.get("result_moid"):
|
|
out["info.result"] = _prop_mor(
|
|
"info.result",
|
|
str(props.get("result_type") or "VirtualMachine"),
|
|
str(props["result_moid"]),
|
|
)
|
|
if obj.type == "HttpNfcLease" or obj.moid.startswith("lease-"):
|
|
# Lease document is durable in vsphere_objects.props (+ vsphere_nfc_leases).
|
|
lease = props if isinstance(props, dict) else {}
|
|
state = str(lease.get("state") or "ready")
|
|
out["state"] = _prop("state", state)
|
|
out["info.state"] = _prop("info.state", state)
|
|
out["info.entity"] = _prop_mor(
|
|
"info.entity", "VirtualMachine", str(lease.get("entity") or "vm-101")
|
|
)
|
|
out["info.initializeProgress"] = _prop_int(
|
|
"info.initializeProgress", int(lease.get("initializeProgress") or 100)
|
|
)
|
|
out["info.transferProgress"] = _prop_int(
|
|
"info.transferProgress", int(lease.get("transferProgress") or 0)
|
|
)
|
|
urls = (lease.get("info") or {}).get("deviceUrl") or []
|
|
url_xml = "".join(
|
|
f"<HttpNfcLeaseDeviceUrl><key>{escape(str(u.get('key')))}</key>"
|
|
f"<importKey>{escape(str(u.get('importKey')))}</importKey>"
|
|
f"<url>{escape(str(u.get('url')))}</url>"
|
|
f"<sslThumbprint>{escape(str(u.get('sslThumbprint') or ''))}</sslThumbprint>"
|
|
f"</HttpNfcLeaseDeviceUrl>"
|
|
for u in urls
|
|
)
|
|
out["info.deviceUrl"] = _prop_raw(
|
|
"info.deviceUrl", url_xml, xsi_type="ArrayOfHttpNfcLeaseDeviceUrl"
|
|
)
|
|
return out
|
|
|
|
|
|
def object_content_xml(
|
|
obj: ManagedObject,
|
|
*,
|
|
children: list[ManagedObject],
|
|
all_by_moid: dict[str, ManagedObject],
|
|
path_sets: list[str] | None = None,
|
|
) -> str:
|
|
if obj.props.get("_not_found"):
|
|
xsi = 'xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"'
|
|
# Shape must decode to types.ManagedObjectNotFound so terraform
|
|
# viapi.IsManagedObjectNotFoundError is true (CreateVM vs CreateChildVM).
|
|
# MissingProperty.fault is the MethodFault itself (no nested <fault>).
|
|
path = (path_sets[0] if path_sets else None) or "name"
|
|
return (
|
|
f'<returnval><obj type="{escape(obj.type)}">{escape(obj.moid)}</obj>'
|
|
f"<missingSet><path>{escape(path)}</path>"
|
|
f'<fault {xsi} xsi:type="ManagedObjectNotFound">'
|
|
f'<obj type="{escape(obj.type)}">{escape(obj.moid)}</obj>'
|
|
f"</fault>"
|
|
f"</missingSet></returnval>"
|
|
)
|
|
prop_map = build_prop_map(obj, children=children, all_by_moid=all_by_moid)
|
|
if path_sets:
|
|
selected = []
|
|
for path in path_sets:
|
|
if path in prop_map:
|
|
selected.append(prop_map[path])
|
|
else:
|
|
# prefix match e.g. runtime / config
|
|
for key, frag in prop_map.items():
|
|
if key == path or key.startswith(path + "."):
|
|
selected.append(frag)
|
|
prop_xml = selected or list(prop_map.values())
|
|
else:
|
|
prop_xml = list(prop_map.values())
|
|
return (
|
|
f'<returnval><obj type="{escape(obj.type)}">{escape(obj.moid)}</obj>'
|
|
+ "".join(prop_xml)
|
|
+ "</returnval>"
|
|
)
|
|
|
|
|
|
def resolve_inventory_path(path: str, objects: list[ManagedObject]) -> ManagedObject | None:
|
|
"""Resolve inventory paths (govmomi omits root ``Datacenters`` folder)."""
|
|
|
|
parts = [p for p in path.strip("/").split("/") if p]
|
|
if not parts:
|
|
return None
|
|
by_moid = {o.moid: o for o in objects}
|
|
|
|
def _walk(start_parent: str | None) -> ManagedObject | None:
|
|
current_parent = start_parent
|
|
match: ManagedObject | None = None
|
|
for part in parts:
|
|
found = None
|
|
if current_parent:
|
|
found = find_child(current_parent, part, objects)
|
|
if found is None:
|
|
found = next(
|
|
(o for o in objects if o.parent_moid == current_parent and o.name == part),
|
|
None,
|
|
)
|
|
if found is None and current_parent is None:
|
|
hits = [o for o in objects if o.name == part]
|
|
if len(hits) == 1:
|
|
found = hits[0]
|
|
# Standard DC folder names even if the Folder.name was renamed in lab.
|
|
if found is None and current_parent and current_parent in by_moid:
|
|
parent = by_moid[current_parent]
|
|
if parent.type == "Datacenter":
|
|
key = {
|
|
"vm": "vm_folder",
|
|
"host": "host_folder",
|
|
"datastore": "datastore_folder",
|
|
"network": "network_folder",
|
|
}.get(part.lower())
|
|
if key and parent.props.get(key):
|
|
found = by_moid.get(str(parent.props[key]))
|
|
if found is None:
|
|
return None
|
|
match = found
|
|
current_parent = found.moid
|
|
return match
|
|
|
|
hit = _walk(None)
|
|
if hit is not None:
|
|
return hit
|
|
for root in (o for o in objects if o.parent_moid is None):
|
|
hit = _walk(root.moid)
|
|
if hit is not None:
|
|
return hit
|
|
return None
|
|
|
|
|
|
def find_child(parent_moid: str, name: str, objects: list[ManagedObject]) -> ManagedObject | None:
|
|
"""SearchIndex.FindChild — direct child by name."""
|
|
|
|
hits = [o for o in objects if o.parent_moid == parent_moid and o.name == name]
|
|
if len(hits) == 1:
|
|
return hits[0]
|
|
# Datacenter folder props (vmFolder etc.) are not parent_moid edges for DC itself.
|
|
parent = next((o for o in objects if o.moid == parent_moid), None)
|
|
if parent and parent.type == "Datacenter":
|
|
for key in ("host_folder", "vm_folder", "datastore_folder", "network_folder"):
|
|
folder_moid = parent.props.get(key)
|
|
if not folder_moid:
|
|
continue
|
|
folder = next((o for o in objects if o.moid == folder_moid and o.name == name), None)
|
|
if folder:
|
|
return folder
|
|
if hits:
|
|
return hits[0]
|
|
return None
|
|
|
|
|
|
def _descendants(
|
|
roots: list[ManagedObject],
|
|
all_objects: list[ManagedObject],
|
|
*,
|
|
max_depth: int = 32,
|
|
) -> list[ManagedObject]:
|
|
"""BFS descendants by parent_moid (folder/datacenter/cluster inventory walk)."""
|
|
|
|
by_parent: dict[str | None, list[ManagedObject]] = {}
|
|
for obj in all_objects:
|
|
by_parent.setdefault(obj.parent_moid, []).append(obj)
|
|
seen: dict[str, ManagedObject] = {o.moid: o for o in roots}
|
|
frontier = list(roots)
|
|
depth = 0
|
|
while frontier and depth < max_depth:
|
|
nxt: list[ManagedObject] = []
|
|
for obj in frontier:
|
|
for child in by_parent.get(obj.moid, []):
|
|
if child.moid not in seen:
|
|
seen[child.moid] = child
|
|
nxt.append(child)
|
|
# Datacenter folders live as props, not parent_moid edges.
|
|
if obj.type == "Datacenter":
|
|
for key in ("host_folder", "vm_folder", "datastore_folder", "network_folder"):
|
|
folder_moid = obj.props.get(key)
|
|
if not folder_moid:
|
|
continue
|
|
folder = next((o for o in all_objects if o.moid == folder_moid), None)
|
|
if folder and folder.moid not in seen:
|
|
seen[folder.moid] = folder
|
|
nxt.append(folder)
|
|
if obj.type == "ClusterComputeResource":
|
|
rp = obj.props.get("resource_pool")
|
|
if rp:
|
|
pool = next((o for o in all_objects if o.moid == rp), None)
|
|
if pool and pool.moid not in seen:
|
|
seen[pool.moid] = pool
|
|
nxt.append(pool)
|
|
frontier = nxt
|
|
depth += 1
|
|
return list(seen.values())
|
|
|
|
|
|
async def select_objects_for_retrieve(
|
|
database: Any,
|
|
body: str,
|
|
) -> tuple[list[ManagedObject], list[str]]:
|
|
path_sets = parse_path_sets(body)
|
|
prop_types = parse_prop_types(body)
|
|
continue_token = parse_continue_token(body)
|
|
all_objects = await inventory.list_objects(database)
|
|
by_moid = {o.moid: o for o in all_objects}
|
|
|
|
if continue_token:
|
|
remaining = await take_page_token(database, continue_token)
|
|
if remaining is None:
|
|
return [], path_sets
|
|
selected = [by_moid[m] for m in remaining if m in by_moid]
|
|
return selected, path_sets
|
|
|
|
refs = parse_obj_refs(body)
|
|
selected: list[ManagedObject] = []
|
|
if refs:
|
|
for type_name, moid in refs:
|
|
if type_name == "ContainerView" or moid.startswith("view-"):
|
|
view_obj = by_moid.get(moid)
|
|
if view_obj is not None:
|
|
selected.append(view_obj)
|
|
else:
|
|
stored = await _pc_get(database, "view", moid)
|
|
moids = (
|
|
list((stored or {}).get("moids") or []) if isinstance(stored, dict) else []
|
|
)
|
|
selected.append(
|
|
ManagedObject(
|
|
moid=moid,
|
|
type="ContainerView",
|
|
name=moid,
|
|
parent_moid=None,
|
|
props={"view_moids": moids},
|
|
)
|
|
)
|
|
elif type_name == "Task" or moid.startswith("task-"):
|
|
selected.append(
|
|
ManagedObject(
|
|
moid=moid,
|
|
type="Task",
|
|
name=moid,
|
|
parent_moid=None,
|
|
props={"task": True},
|
|
)
|
|
)
|
|
elif type_name == "HttpNfcLease" or moid.startswith("lease-"):
|
|
selected.append(
|
|
ManagedObject(
|
|
moid=moid,
|
|
type="HttpNfcLease",
|
|
name=moid,
|
|
parent_moid=None,
|
|
props={"lease": True},
|
|
)
|
|
)
|
|
elif type_name == "EnvironmentBrowser" or moid.startswith("envbrowser-"):
|
|
selected.append(
|
|
ManagedObject(
|
|
moid=moid,
|
|
type="EnvironmentBrowser",
|
|
name=moid,
|
|
parent_moid=None,
|
|
props={"environment_browser": True},
|
|
)
|
|
)
|
|
elif moid in by_moid:
|
|
obj = by_moid[moid]
|
|
# Honor requested MOR type — VirtualApp:resgroup-X must NOT resolve a ResourcePool.
|
|
if type_name and not _type_compatible(type_name, obj.type):
|
|
# Return the underlying ResourcePool for VirtualApp probes so the
|
|
# provider proceeds to CreateChildVM_Task (which we implement) instead
|
|
# of failing/hanging on ManagedObjectNotFound fault decoding.
|
|
if type_name == "VirtualApp" and obj.type == "ResourcePool":
|
|
selected.append(obj)
|
|
continue
|
|
selected.append(
|
|
ManagedObject(
|
|
moid=moid,
|
|
type=type_name,
|
|
name=moid,
|
|
parent_moid=None,
|
|
props={"_not_found": True},
|
|
)
|
|
)
|
|
continue
|
|
selected.append(obj)
|
|
elif moid in {"propertyCollector", "TaskManager", "SearchIndex", "ViewManager"}:
|
|
continue
|
|
else:
|
|
pass
|
|
wants_traversal = "selectSet" in body or "TraversalSpec" in body
|
|
if wants_traversal and selected:
|
|
# Include ContainerView — govmomi view.Retrieve traverses Path=view from the view MOR.
|
|
inventory_roots = [o for o in selected if o.type != "Task"]
|
|
if inventory_roots:
|
|
if _wants_parent_traversal(body):
|
|
expanded = _ancestors(inventory_roots, all_objects)
|
|
elif _is_recursive_traversal(body):
|
|
expanded = _descendants(inventory_roots, all_objects)
|
|
else:
|
|
# govmomi list.Lister TraversalSpec is one hop (e.g. Folder.childEntity).
|
|
expanded = _one_level_traverse(inventory_roots, all_objects, body)
|
|
skip_root = _object_set_skip(body)
|
|
if prop_types:
|
|
type_set = set(prop_types)
|
|
filtered = [o for o in expanded if _type_matches(o.type, type_set)]
|
|
if not skip_root:
|
|
for root in inventory_roots:
|
|
if root.type == "ContainerView":
|
|
continue
|
|
if root.moid not in {o.moid for o in filtered}:
|
|
if _type_matches(root.type, type_set) or not type_set:
|
|
filtered.append(root)
|
|
selected = (
|
|
filtered if filtered else [o for o in expanded if o.type != "ContainerView"]
|
|
)
|
|
else:
|
|
selected = [o for o in expanded if o.type != "ContainerView" or not skip_root]
|
|
if not skip_root:
|
|
for root in inventory_roots:
|
|
if root.type == "ContainerView":
|
|
continue
|
|
if root.moid not in {o.moid for o in selected}:
|
|
selected.append(root)
|
|
else:
|
|
selected = all_objects
|
|
if prop_types:
|
|
type_set = set(prop_types)
|
|
selected = [o for o in selected if _type_matches(o.type, type_set)]
|
|
return selected, path_sets
|
|
|
|
|
|
def children_of(moid: str, all_objects: list[ManagedObject]) -> list[ManagedObject]:
|
|
return [o for o in all_objects if o.parent_moid == moid]
|
|
|
|
|
|
def _type_compatible(requested: str, actual: str) -> bool:
|
|
"""Whether an inventory object may satisfy a client MOR of ``requested`` type."""
|
|
|
|
if not requested or requested == actual:
|
|
return True
|
|
if requested == "ManagedEntity":
|
|
return actual in _MANAGED_ENTITY_TYPES
|
|
if requested == "ComputeResource" and actual in {"ComputeResource", "ClusterComputeResource"}:
|
|
return True
|
|
if requested == "ResourcePool" and actual in {"ResourcePool", "VirtualApp"}:
|
|
return True
|
|
# Plain ResourcePool must NOT satisfy VirtualApp (CreateChildVM vs CreateVM).
|
|
# Exception: none — keep strict. CreateChildVM path is handled separately.
|
|
if requested == "VirtualApp":
|
|
return actual == "VirtualApp"
|
|
if requested == "DistributedVirtualSwitch" and actual in {
|
|
"VmwareDistributedVirtualSwitch",
|
|
"DistributedVirtualSwitch",
|
|
}:
|
|
return True
|
|
if requested == "Network" and actual in {
|
|
"Network",
|
|
"DistributedVirtualPortgroup",
|
|
"OpaqueNetwork",
|
|
}:
|
|
return True
|
|
return False
|
|
|
|
|
|
def _type_matches(obj_type: str, type_set: set[str]) -> bool:
|
|
if obj_type in type_set:
|
|
return True
|
|
return any(_type_compatible(t, obj_type) for t in type_set)
|
|
|
|
|
|
def _traversal_paths(body: str) -> list[str]:
|
|
"""Paths from TraversalSpec (<path>), not PropertySpec (<pathSet>)."""
|
|
|
|
return re.findall(r"<(?:\w+:)?path(?:\s[^>]*)?>([^<]+)</(?:\w+:)?path>", body)
|
|
|
|
|
|
def _wants_parent_traversal(body: str) -> bool:
|
|
"""govmomi mo.Ancestors walks parent/parentVApp — not inventory descendants."""
|
|
|
|
paths = {p.strip() for p in _traversal_paths(body)}
|
|
if not paths:
|
|
return "traverseParent" in body
|
|
parentish = {"parent", "parentVApp"}
|
|
childish = {
|
|
"childEntity",
|
|
"hostFolder",
|
|
"vmFolder",
|
|
"datastoreFolder",
|
|
"networkFolder",
|
|
"host",
|
|
"resourcePool",
|
|
"vm",
|
|
"datastore",
|
|
"network",
|
|
"view",
|
|
"portgroup",
|
|
}
|
|
return bool(paths & parentish) and not bool(paths & childish)
|
|
|
|
|
|
def _is_recursive_traversal(body: str) -> bool:
|
|
"""True when a TraversalSpec nests a SelectionSpec (name-only selectSet) to recurse."""
|
|
|
|
if _wants_parent_traversal(body):
|
|
return True
|
|
# Nested SelectionSpec: <selectSet><name>…</name></selectSet> inside TraversalSpec.
|
|
# Do not match the TraversalSpec's own <name> sibling of <path>.
|
|
return bool(
|
|
re.search(
|
|
r'xsi:type="TraversalSpec"[^>]*>[\s\S]*?'
|
|
r"<(?:\w+:)?selectSet(?:\s[^>]*)?>\s*"
|
|
r"<(?:\w+:)?name(?:\s[^>]*)?>[^<]+</(?:\w+:)?name>\s*"
|
|
r"</(?:\w+:)?selectSet>",
|
|
body,
|
|
flags=re.IGNORECASE,
|
|
)
|
|
)
|
|
|
|
|
|
def _object_set_skip(body: str) -> bool:
|
|
"""objectSet/@skip — ListFolder uses skip=true so the folder itself is omitted."""
|
|
|
|
match = re.search(
|
|
r"<(?:\w+:)?objectSet\b[^>]*>.*?<(?:\w+:)?skip[^>]*>([^<]+)</(?:\w+:)?skip>",
|
|
body,
|
|
flags=re.DOTALL | re.IGNORECASE,
|
|
)
|
|
if not match:
|
|
return False
|
|
return match.group(1).strip().lower() == "true"
|
|
|
|
|
|
def _ancestors(roots: list[ManagedObject], all_objects: list[ManagedObject]) -> list[ManagedObject]:
|
|
by_moid = {o.moid: o for o in all_objects}
|
|
seen: dict[str, ManagedObject] = {}
|
|
for root in roots:
|
|
cur: ManagedObject | None = root
|
|
while cur is not None:
|
|
if cur.moid in seen:
|
|
break
|
|
seen[cur.moid] = cur
|
|
parent_moid = cur.parent_moid
|
|
if not parent_moid:
|
|
break
|
|
cur = by_moid.get(parent_moid)
|
|
return list(seen.values())
|
|
|
|
|
|
def _follow_path(
|
|
root: ManagedObject,
|
|
path: str,
|
|
all_objects: list[ManagedObject],
|
|
by_moid: dict[str, ManagedObject],
|
|
) -> list[ManagedObject]:
|
|
"""One PropertyCollector TraversalSpec hop (govmomi list.Lister)."""
|
|
|
|
if path == "childEntity":
|
|
return children_of(root.moid, all_objects)
|
|
if path in {"parent", "parentVApp"}:
|
|
if not root.parent_moid:
|
|
return []
|
|
parent = by_moid.get(root.parent_moid)
|
|
return [parent] if parent else []
|
|
folder_keys = {
|
|
"vmFolder": "vm_folder",
|
|
"hostFolder": "host_folder",
|
|
"datastoreFolder": "datastore_folder",
|
|
"networkFolder": "network_folder",
|
|
}
|
|
if path in folder_keys:
|
|
folder_moid = root.props.get(folder_keys[path])
|
|
if not folder_moid:
|
|
return []
|
|
folder = by_moid.get(str(folder_moid))
|
|
return [folder] if folder else []
|
|
if path == "resourcePool":
|
|
rp = root.props.get("resource_pool") or root.props.get("resourcePool")
|
|
if rp and str(rp) in by_moid:
|
|
return [by_moid[str(rp)]]
|
|
return [o for o in all_objects if o.type == "ResourcePool" and o.parent_moid == root.moid]
|
|
if path == "host":
|
|
return [o for o in all_objects if o.type == "HostSystem" and o.parent_moid == root.moid]
|
|
if path == "vm":
|
|
if root.type == "ResourcePool":
|
|
return [
|
|
o
|
|
for o in all_objects
|
|
if o.type == "VirtualMachine"
|
|
and str(o.props.get("resource_pool") or "") == root.moid
|
|
]
|
|
if root.type == "HostSystem":
|
|
return [
|
|
o
|
|
for o in all_objects
|
|
if o.type == "VirtualMachine" and str(o.props.get("host") or "") == root.moid
|
|
]
|
|
return children_of(root.moid, all_objects)
|
|
if path == "datastore":
|
|
if root.type == "Datacenter":
|
|
return [o for o in all_objects if o.type == "Datastore"]
|
|
ds_ids = root.props.get("datastores") or root.props.get("datastore") or []
|
|
if isinstance(ds_ids, str):
|
|
ds_ids = [ds_ids]
|
|
out = [by_moid[str(d)] for d in ds_ids if str(d) in by_moid]
|
|
if out:
|
|
return out
|
|
return [o for o in all_objects if o.type == "Datastore"][:20]
|
|
if path == "network":
|
|
return [
|
|
o
|
|
for o in all_objects
|
|
if o.type in {"Network", "DistributedVirtualPortgroup", "OpaqueNetwork"}
|
|
][:50]
|
|
if path == "portgroup":
|
|
return [o for o in all_objects if o.type == "DistributedVirtualPortgroup"]
|
|
if path == "view":
|
|
moids = view_moids_from_object(root)
|
|
return [by_moid[m] for m in moids if m in by_moid]
|
|
return []
|
|
|
|
|
|
def _one_level_traverse(
|
|
roots: list[ManagedObject],
|
|
all_objects: list[ManagedObject],
|
|
body: str,
|
|
) -> list[ManagedObject]:
|
|
paths = [p.strip() for p in _traversal_paths(body)] or ["childEntity"]
|
|
by_moid = {o.moid: o for o in all_objects}
|
|
seen: dict[str, ManagedObject] = {}
|
|
for root in roots:
|
|
for path in paths:
|
|
for hit in _follow_path(root, path, all_objects, by_moid):
|
|
seen[hit.moid] = hit
|
|
return list(seen.values())
|
|
|
|
|
|
def _vim_task_state(status: str) -> str:
|
|
return {
|
|
"SUCCEEDED": "success",
|
|
"FAILED": "error",
|
|
"RUNNING": "running",
|
|
"PENDING": "queued",
|
|
}.get(status, "error")
|
|
|
|
|
|
def _parse_max_wait_seconds(body: str) -> float:
|
|
match = re.search(
|
|
r"<(?:\w+:)?maxWaitSeconds[^>]*>([^<]*)</(?:\w+:)?maxWaitSeconds>",
|
|
body,
|
|
)
|
|
if not match:
|
|
return 0.0
|
|
try:
|
|
return max(0.0, float(match.group(1).strip() or "0"))
|
|
except ValueError:
|
|
return 0.0
|
|
|
|
|
|
def _task_object_set_xml(task: dict[str, Any]) -> str:
|
|
"""PropertyCollector update so govmomi ``task.Wait`` observes completion.
|
|
|
|
govmomi waits on the whole ``info`` property (TaskInfo), not ``info.state``.
|
|
"""
|
|
|
|
task_id = str(task.get("task") or "")
|
|
state = _vim_task_state(str(task.get("status") or "FAILED"))
|
|
progress = 100 if state in {"success", "error"} else int(task.get("progress") or 50)
|
|
result = task.get("result") if isinstance(task.get("result"), dict) else {}
|
|
vm_moid = str((result or {}).get("vm") or "")
|
|
xsi = 'xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"'
|
|
result_xml = ""
|
|
entity_xml = ""
|
|
if vm_moid:
|
|
result_xml = (
|
|
f'<result {xsi} xsi:type="ManagedObjectReference" type="VirtualMachine">'
|
|
f"{escape(vm_moid)}</result>"
|
|
)
|
|
entity_xml = (
|
|
f'<entity {xsi} xsi:type="ManagedObjectReference" type="VirtualMachine">'
|
|
f"{escape(vm_moid)}</entity>"
|
|
"<entityName>VirtualMachine</entityName>"
|
|
)
|
|
info_xml = (
|
|
f'<val {xsi} xsi:type="TaskInfo">'
|
|
f"<key>{escape(task_id)}</key>"
|
|
f'<task type="Task">{escape(task_id)}</task>'
|
|
f"<state>{escape(state)}</state>"
|
|
f"<cancelled>false</cancelled>"
|
|
f"<cancelable>false</cancelable>"
|
|
f"<descriptionId>{escape(str(task.get('operation') or 'task'))}</descriptionId>"
|
|
f"<progress>{progress}</progress>"
|
|
f"{entity_xml}{result_xml}"
|
|
f"</val>"
|
|
)
|
|
return (
|
|
"<objectSet>"
|
|
f'<obj type="Task">{escape(task_id)}</obj>'
|
|
"<kind>modify</kind>"
|
|
f"<changeSet><name>info</name><op>assign</op>{info_xml}</changeSet>"
|
|
"</objectSet>"
|
|
)
|
|
|
|
|
|
def _vim_power_state(raw: str) -> str:
|
|
return {
|
|
"POWERED_ON": "poweredOn",
|
|
"POWERED_OFF": "poweredOff",
|
|
"SUSPENDED": "suspended",
|
|
}.get(raw, "poweredOff")
|
|
|
|
|
|
def _vm_runtime_object_set_xml(obj: ManagedObject) -> str:
|
|
"""Push VM runtime/guest props so post-CreateVM waiters can finish."""
|
|
|
|
props = obj.props if isinstance(obj.props, dict) else {}
|
|
power = _vim_power_state(str(props.get("power_state") or "POWERED_OFF"))
|
|
identity = props.get("identity") if isinstance(props.get("identity"), dict) else {}
|
|
uuid = str(identity.get("instance_uuid") or props.get("uuid") or f"uuid-{obj.moid}")
|
|
bios = str(identity.get("bios_uuid") or f"bios-{obj.moid}")
|
|
xsi = 'xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"'
|
|
changes = [
|
|
f"<changeSet><name>name</name><op>assign</op><val>{escape(obj.name)}</val></changeSet>",
|
|
f"<changeSet><name>runtime.powerState</name><op>assign</op>"
|
|
f'<val {xsi} xsi:type="VirtualMachinePowerState">{escape(power)}</val></changeSet>',
|
|
f"<changeSet><name>summary.runtime.powerState</name><op>assign</op>"
|
|
f'<val {xsi} xsi:type="VirtualMachinePowerState">{escape(power)}</val></changeSet>',
|
|
f"<changeSet><name>config.uuid</name><op>assign</op><val>{escape(uuid)}</val></changeSet>",
|
|
f"<changeSet><name>config.instanceUuid</name><op>assign</op>"
|
|
f"<val>{escape(uuid)}</val></changeSet>",
|
|
f"<changeSet><name>summary.config.uuid</name><op>assign</op>"
|
|
f"<val>{escape(uuid)}</val></changeSet>",
|
|
f"<changeSet><name>summary.config.instanceUuid</name><op>assign</op>"
|
|
f"<val>{escape(uuid)}</val></changeSet>",
|
|
f"<changeSet><name>summary.config.guestId</name><op>assign</op>"
|
|
f"<val>otherGuest64</val></changeSet>",
|
|
f"<changeSet><name>guest.toolsStatus</name><op>assign</op>"
|
|
f'<val {xsi} xsi:type="VirtualMachineToolsStatus">toolsOk</val></changeSet>',
|
|
f"<changeSet><name>guest.toolsRunningStatus</name><op>assign</op>"
|
|
f"<val>guestToolsRunning</val></changeSet>",
|
|
f"<changeSet><name>guest.ipAddress</name><op>assign</op>"
|
|
f"<val>192.168.1.50</val></changeSet>",
|
|
f"<changeSet><name>summary.guest.ipAddress</name><op>assign</op>"
|
|
f"<val>192.168.1.50</val></changeSet>",
|
|
f"<changeSet><name>config.hardware.deviceChange</name><op>assign</op>"
|
|
f'<val {xsi} xsi:type="xsd:boolean">false</val></changeSet>',
|
|
]
|
|
del bios # reserved for future BIOS UUID prop parity
|
|
return (
|
|
"<objectSet>"
|
|
f'<obj type="VirtualMachine">{escape(obj.moid)}</obj>'
|
|
"<kind>modify</kind>" + "".join(changes) + "</objectSet>"
|
|
)
|
|
|
|
|
|
async def wait_updates_xml(
|
|
database: Database,
|
|
*,
|
|
session_key: str,
|
|
body: str,
|
|
objects: list[ManagedObject],
|
|
) -> str:
|
|
from app.vsphere.domain import tasks as task_store
|
|
|
|
version_match = re.search(
|
|
r"<(?:\w+:)?version[^>]*>([^<]*)</(?:\w+:)?version>",
|
|
body,
|
|
)
|
|
client_version = (version_match.group(1).strip() if version_match else "") or ""
|
|
stored = await _pc_get(database, "version", session_key)
|
|
last = int((stored or {}).get("n") or 0) if isinstance(stored, dict) else int(stored or 0)
|
|
|
|
notified_raw = await _pc_get(database, "meta", f"tasks_notified:{session_key}")
|
|
notified: set[str] = set()
|
|
if isinstance(notified_raw, dict):
|
|
notified = {str(x) for x in (notified_raw.get("ids") or [])}
|
|
elif isinstance(notified_raw, list):
|
|
notified = {str(x) for x in notified_raw}
|
|
|
|
all_tasks = await task_store.list_tasks(database)
|
|
new_tasks = [t for t in all_tasks if str(t.get("task") or "") not in notified]
|
|
synced = bool(client_version) and int(client_version or "0") >= last and last > 0
|
|
max_wait = _parse_max_wait_seconds(body)
|
|
|
|
# Late task.Wait callers often subscribe after CreateVM already SUCCEEDED and after
|
|
# an earlier WaitForUpdates marked that task "notified". Re-assert the newest task
|
|
# once while the client is blocking (maxWaitSeconds > 0).
|
|
replay_tasks: list[dict[str, Any]] = []
|
|
if synced and not new_tasks and max_wait > 0:
|
|
newest = next(
|
|
(t for t in all_tasks if str(t.get("status") or "") in {"SUCCEEDED", "FAILED"}),
|
|
None,
|
|
)
|
|
if newest is None:
|
|
return f"<returnval><version>{last}</version></returnval>"
|
|
# Cap replay storms: only bump when client still has maxWait (blocking wait).
|
|
replay_tasks = [newest]
|
|
elif synced and not new_tasks:
|
|
return f"<returnval><version>{last}</version></returnval>"
|
|
|
|
next_ver = max(last, 0) + 1
|
|
await _pc_put(database, "version", session_key, {"n": next_ver})
|
|
|
|
objects_xml: list[str] = []
|
|
by_moid = {o.moid: o for o in objects}
|
|
# First update for a session: seed inventory name props (Finder / ContainerView).
|
|
if last == 0:
|
|
limited: list[ManagedObject] = []
|
|
vm_count = 0
|
|
for obj in objects:
|
|
if obj.type == "VirtualMachine":
|
|
if vm_count >= 200:
|
|
continue
|
|
vm_count += 1
|
|
limited.append(obj)
|
|
for obj in limited:
|
|
objects_xml.append(
|
|
"<objectSet>"
|
|
f'<obj type="{escape(obj.type)}">{escape(obj.moid)}</obj>'
|
|
"<kind>enter</kind>"
|
|
f"<changeSet><name>name</name><op>assign</op>"
|
|
f"<val>{escape(obj.name)}</val></changeSet>"
|
|
"</objectSet>"
|
|
)
|
|
|
|
emit_tasks = new_tasks or replay_tasks
|
|
vm_ids: set[str] = set()
|
|
for task in emit_tasks:
|
|
objects_xml.append(_task_object_set_xml(task))
|
|
result = task.get("result") if isinstance(task.get("result"), dict) else {}
|
|
vm_moid = str((result or {}).get("vm") or "")
|
|
if vm_moid:
|
|
vm_ids.add(vm_moid)
|
|
for vm_moid in sorted(vm_ids):
|
|
obj = by_moid.get(vm_moid)
|
|
if obj is not None:
|
|
objects_xml.append(_vm_runtime_object_set_xml(obj))
|
|
if new_tasks:
|
|
notified.update(str(t.get("task") or "") for t in new_tasks)
|
|
await _pc_put(database, "meta", f"tasks_notified:{session_key}", {"ids": sorted(notified)})
|
|
|
|
return (
|
|
"<returnval>"
|
|
f"<version>{next_ver}</version>"
|
|
'<filterSet><filter type="PropertyFilter">filter-1</filter>'
|
|
+ "".join(objects_xml)
|
|
+ "</filterSet></returnval>"
|
|
)
|