deploy_ova: support OVF deployment options + add inspect_ova

Cisco-style OVAs (CUCM/CUC/UCCX) ship multiple deployment configurations
(e.g. Small/Medium/Large) in a DeploymentOptionSection and default to one.
deploy_ova now takes deployment_option to select a config; without it the
OVF default is used.

inspect_ova reads an OVA (local path or URL) and reports product, guest
OS, hardware version, selectable deployment options, networks, and disks
so clients can choose deployment_option/network before deploying.

Verified against CUCM 15 OVA: Small config yields 2 vCPU / 10 GB / 110 GB
/ VMXNET3 exactly per Cisco's spec.
This commit is contained in:
Ryan Malloy 2026-06-08 04:28:49 -06:00
parent 4fa2286e57
commit 539aa913e9

View File

@ -363,6 +363,7 @@ class OVFManagementMixin(MCPMixin):
network: str | None = None,
power_on: bool = False,
disk_provisioning: str = "thin",
deployment_option: str | None = None,
) -> dict[str, Any]:
"""Deploy a virtual machine from an OVA file.
@ -378,6 +379,9 @@ class OVFManagementMixin(MCPMixin):
network: Target port group; every network in the OVF is mapped to it
power_on: Power on the VM after deployment
disk_provisioning: 'thin', 'thick', or 'eagerZeroedThick'
deployment_option: OVF configuration id for multi-config templates
(e.g. Cisco 'S'/'M'/'L'); defaults to the OVF's default config.
Use inspect_ova to list available options.
Returns:
Dict with deployment details
@ -413,6 +417,8 @@ class OVFManagementMixin(MCPMixin):
entityName=vm_name,
diskProvisioning=disk_provisioning,
)
if deployment_option:
spec_params.deploymentOption = deployment_option
if network:
net = self.conn.find_network(network)
if not net:
@ -507,6 +513,103 @@ class OVFManagementMixin(MCPMixin):
result["power_state"] = "poweredOff"
return result
@mcp_tool(
name="inspect_ova",
description="Inspect an OVA (local path or URL): product, deployment configurations, networks, and disks",
annotations=ToolAnnotations(readOnlyHint=True),
)
def inspect_ova(self, ova_path: str) -> dict[str, Any]:
"""Inspect an OVA without deploying it.
Reads the OVF descriptor and reports the product, guest OS, hardware
version, selectable deployment configurations (e.g. Cisco S/M/L), the
networks the OVF expects, and its disks. Useful for picking a
``deployment_option`` and ``network`` before calling ``deploy_ova``.
Args:
ova_path: Local path to the .ova file, or an http(s) URL
Returns:
Dict describing the OVA
"""
local_ova, is_temp = self._resolve_ova_file(ova_path)
try:
with tarfile.open(local_ova) as tar:
ovf_member = next(
(m for m in tar.getmembers() if m.name.lower().endswith(".ovf")),
None,
)
if not ovf_member:
raise ValueError("No .ovf descriptor found in OVA")
ovf_xml = tar.extractfile(ovf_member).read().decode("utf-8")
disk_files = [
m.name for m in tar.getmembers() if m.name.lower().endswith(".vmdk")
]
finally:
if is_temp:
Path(local_ova).unlink(missing_ok=True)
def lname(tag: str) -> str:
return tag.rsplit("}", 1)[-1]
root = ET.fromstring(ovf_xml)
product: str | None = None
os_type: str | None = None
hw_version: str | None = None
configs: list[dict[str, Any]] = []
networks: list[str] = []
disks: list[dict[str, Any]] = []
for el in root.iter():
tag = lname(el.tag)
if tag == "Product" and product is None:
product = (el.text or "").strip()
elif tag == "OperatingSystemSection":
for key, val in el.attrib.items():
if lname(key) == "osType":
os_type = val
elif tag == "VirtualSystemType" and hw_version is None:
hw_version = (el.text or "").strip()
elif tag == "Configuration":
cid = None
default = False
for key, val in el.attrib.items():
if lname(key) == "id":
cid = val
elif lname(key) == "default":
default = val == "true"
label = ""
for child in el:
if lname(child.tag) == "Label":
label = (child.text or "").strip()
configs.append({"id": cid, "label": label, "default": default})
elif tag == "Network":
for key, val in el.attrib.items():
if lname(key) == "name":
networks.append(val)
elif tag == "Disk":
disk: dict[str, Any] = {}
for key, val in el.attrib.items():
name = lname(key)
if name == "diskId":
disk["disk_id"] = val
elif name == "capacity":
disk["capacity"] = val
elif name == "capacityAllocationUnits":
disk["units"] = val
disks.append(disk)
return {
"source": ova_path,
"product": product,
"os_type": os_type,
"hardware_version": hw_version,
"deployment_options": configs,
"networks": networks,
"disks": disks,
"disk_files": disk_files,
}
@mcp_tool(
name="export_vm_ovf",
description="Export a VM to OVF format on a datastore",