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:
parent
7888494efb
commit
4fa2286e57
@ -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",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user