Add deploy_ova tool: stream a local/URL OVA to the host via NFC

Implements OVA deployment over an HttpNfcLease, the existing deploy_ovf
explicitly rejected OVA and its disk upload was a no-op stub. deploy_ova
reads an OVA from a local path or http(s) URL, builds an import spec
(mapping every OVF network to the target port group), opens an NFC lease,
and streams each disk to the lease URL with a keepalive thread.

Key details: the NFC PUT needs an 'Overwrite: t' header because
ImportVApp pre-creates the disk file; lease device URLs use '*' for the
host on direct ESXi connections, so the configured host is substituted.
This commit is contained in:
Ryan Malloy 2026-06-08 04:25:06 -06:00
parent 7888494efb
commit 4fa2286e57

View File

@ -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",