From 539aa913e9b66317bdad75dba2a6ab42461c7e27 Mon Sep 17 00:00:00 2001 From: Ryan Malloy Date: Mon, 8 Jun 2026 04:28:49 -0600 Subject: [PATCH] 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. --- src/mcvsphere/mixins/ovf_management.py | 103 +++++++++++++++++++++++++ 1 file changed, 103 insertions(+) diff --git a/src/mcvsphere/mixins/ovf_management.py b/src/mcvsphere/mixins/ovf_management.py index 25742b6..30575c4 100644 --- a/src/mcvsphere/mixins/ovf_management.py +++ b/src/mcvsphere/mixins/ovf_management.py @@ -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",