diff --git a/src/mcvsphere/mixins/ovf_management.py b/src/mcvsphere/mixins/ovf_management.py index 5242b15..25742b6 100644 --- a/src/mcvsphere/mixins/ovf_management.py +++ b/src/mcvsphere/mixins/ovf_management.py @@ -1,11 +1,18 @@ """OVF/OVA Management - deploy and export virtual appliances.""" +import contextlib +import http.client +import shutil import ssl import tarfile import tempfile +import threading +import time import urllib.request +import xml.etree.ElementTree as ET from pathlib import Path from typing import TYPE_CHECKING, Any +from urllib.parse import urlparse from fastmcp.contrib.mcp_mixin import MCPMixin, mcp_tool from mcp.types import ToolAnnotations @@ -263,6 +270,243 @@ class OVFManagementMixin(MCPMixin): # For now, document this limitation pass + def _resolve_ova_file(self, ova_path: str) -> tuple[str, bool]: + """Return (local_path, is_temp); downloads the OVA first if a URL is given.""" + if ova_path.startswith(("http://", "https://")): + context = ssl.create_default_context() + if self.conn.settings.vcenter_insecure: + context.check_hostname = False + context.verify_mode = ssl.CERT_NONE + fd, tmp = tempfile.mkstemp(suffix=".ova", prefix="ova_dl_") + with open(fd, "wb") as out: + req = urllib.request.Request(ova_path) + with urllib.request.urlopen(req, context=context) as resp: + shutil.copyfileobj(resp, out) + return tmp, True + if not Path(ova_path).is_file(): + raise ValueError(f"OVA file not found: {ova_path}") + return ova_path, False + + @staticmethod + def _parse_ovf_network_names(ovf_xml: str) -> list[str]: + """Extract declared network names from an OVF descriptor's NetworkSection.""" + names: list[str] = [] + try: + root = ET.fromstring(ovf_xml) + except ET.ParseError: + return names + for el in root.iter(): + if el.tag.rsplit("}", 1)[-1] == "Network": + for key, val in el.attrib.items(): + if key.rsplit("}", 1)[-1] == "name": + names.append(val) + return names + + def _put_stream_to_lease(self, device_url: str, fileobj: Any, size: int) -> None: + """Stream a file object to an NFC lease device URL via chunked HTTP PUT.""" + parsed = urlparse(device_url) + host = parsed.hostname + # ESXi returns '*' (meaning "the host you connected to") or its own + # mgmt name. For a direct host connection the only address we know is + # reachable is the one we connected on, so use that. + if ( + not host + or host == "*" + or self.conn.content.about.apiType == "HostAgent" + ): + host = self.conn.settings.vcenter_host + port = parsed.port or 443 + path = parsed.path + (f"?{parsed.query}" if parsed.query else "") + + context = ssl.create_default_context() + if self.conn.settings.vcenter_insecure: + context.check_hostname = False + context.verify_mode = ssl.CERT_NONE + + conn = http.client.HTTPSConnection(host, port, context=context, timeout=900) + try: + conn.putrequest("PUT", path, skip_host=True, skip_accept_encoding=True) + conn.putheader("Host", host) + conn.putheader("Content-Length", str(size)) + conn.putheader("Content-Type", "application/x-vnd.vmware-streamVmdk") + # ImportVApp pre-creates the disk file, so the NFC stream must + # explicitly overwrite it (else ESXi returns 403 "File exists"). + conn.putheader("Overwrite", "t") + cookie = getattr(self.conn.si._stub, "cookie", None) + if cookie: + conn.putheader("Cookie", cookie) + conn.endheaders() + while True: + chunk = fileobj.read(1024 * 1024) + if not chunk: + break + conn.send(chunk) + resp = conn.getresponse() + resp.read() + if resp.status not in (200, 201): + raise ValueError( + f"Disk upload failed: HTTP {resp.status} {resp.reason}" + ) + finally: + conn.close() + + @mcp_tool( + name="deploy_ova", + description="Deploy a VM from an OVA file (local path or URL) by streaming it to the host over an NFC lease", + annotations=ToolAnnotations(destructiveHint=True, idempotentHint=False), + ) + def deploy_ova( + self, + ova_path: str, + vm_name: str, + datastore: str | None = None, + network: str | None = None, + power_on: bool = False, + disk_provisioning: str = "thin", + ) -> dict[str, Any]: + """Deploy a virtual machine from an OVA file. + + The OVA is read from the MCP server host (local filesystem path) or + downloaded if an http(s) URL is given, then streamed to the ESXi host + over an NFC lease. Unlike datastore-based deploy, the OVA does not need + to be pre-staged on a datastore. + + Args: + ova_path: Local path to the .ova file, or an http(s) URL to download + vm_name: Name for the new VM + datastore: Target datastore (default: largest available) + 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' + + Returns: + Dict with deployment details + """ + ds = self.conn.find_datastore(datastore) if datastore else self.conn.datastore + if not ds: + raise ValueError(f"Datastore '{datastore}' not found") + resource_pool = self.conn.resource_pool + vm_folder = self.conn.datacenter.vmFolder + + # An ESXi host is required as the import target + view = self.conn.content.viewManager.CreateContainerView( + self.conn.content.rootFolder, [vim.HostSystem], True + ) + hosts = list(view.view) + view.Destroy() + host_system = hosts[0] if hosts else None + + local_ova, is_temp = self._resolve_ova_file(ova_path) + import_spec = None + 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") + + # Build the import spec, mapping every OVF network to the target + spec_params = vim.OvfManager.CreateImportSpecParams( + entityName=vm_name, + diskProvisioning=disk_provisioning, + ) + if network: + net = self.conn.find_network(network) + if not net: + raise ValueError(f"Network '{network}' not found") + ovf_nets = self._parse_ovf_network_names(ovf_xml) or ["VM Network"] + spec_params.networkMapping = [ + vim.OvfManager.NetworkMapping(name=n, network=net) + for n in ovf_nets + ] + + ovf_manager = self.conn.content.ovfManager + import_spec = ovf_manager.CreateImportSpec( + ovfDescriptor=ovf_xml, + resourcePool=resource_pool, + datastore=ds, + cisp=spec_params, + ) + if import_spec.error: + raise ValueError( + "OVF import errors: " + + "; ".join(str(e.msg) for e in import_spec.error) + ) + + lease = resource_pool.ImportVApp( + spec=import_spec.importSpec, folder=vm_folder, host=host_system + ) + while lease.state == vim.HttpNfcLease.State.initializing: + time.sleep(0.1) + if lease.state == vim.HttpNfcLease.State.error: + raise ValueError(f"NFC lease error: {lease.error}") + + # Stream each disk to its lease URL, keeping the lease alive + total = sum(max(fi.size, 1) for fi in import_spec.fileItem) or 1 + uploaded = [0] + stop = threading.Event() + + def _keepalive() -> None: + while not stop.wait(20): + try: + lease.HttpNfcLeaseProgress( + min(99, int(uploaded[0] * 100 / total)) + ) + except Exception: + return + + keeper = threading.Thread(target=_keepalive, daemon=True) + keeper.start() + try: + url_by_key = {du.importKey: du.url for du in lease.info.deviceUrl} + with tarfile.open(local_ova) as tar: + by_base = {m.name.rsplit("/", 1)[-1]: m for m in tar.getmembers()} + for fi in import_spec.fileItem: + device_url = url_by_key.get(fi.deviceId) + if not device_url: + raise ValueError(f"No lease URL for device {fi.deviceId}") + member = by_base.get(fi.path.rsplit("/", 1)[-1]) + if not member: + raise ValueError(f"Disk '{fi.path}' missing from OVA") + src = tar.extractfile(member) + self._put_stream_to_lease(device_url, src, member.size) + uploaded[0] += member.size + lease.HttpNfcLeaseProgress( + min(99, int(uploaded[0] * 100 / total)) + ) + lease.HttpNfcLeaseProgress(100) + lease.Complete() + except Exception: + with contextlib.suppress(Exception): + lease.Abort() + raise + finally: + stop.set() + finally: + if is_temp: + Path(local_ova).unlink(missing_ok=True) + + vm = self.conn.find_vm(vm_name) + result: dict[str, Any] = { + "vm": vm_name, + "action": "ova_deployed", + "datastore": ds.name, + "source": ova_path, + "disks": len(import_spec.fileItem) if import_spec else 0, + } + if vm: + result["uuid"] = vm.config.uuid + if power_on: + task = vm.PowerOnVM_Task() + self.conn.wait_for_task(task) + result["power_state"] = "poweredOn" + else: + result["power_state"] = "poweredOff" + return result + @mcp_tool( name="export_vm_ovf", description="Export a VM to OVF format on a datastore",