Add CD/DVD + ISO-boot support for appliance installs

Adds add_cdrom (attach a CD/DVD drive to an existing VM, optionally with
an ISO and CD-first boot) and extends deploy_ova with iso_path/
iso_datastore/boot_from_iso so a diskless appliance OVA can be deployed
and pointed at a bootable installer ISO in one call.

deploy_ova reuses the OVA's existing primary CD/DVD drive rather than
adding a new one — Cisco templates (CUCM) already define empty drives and
the BIOS boots the first, so the ISO must land there. Verified end to end:
CUCM 15 Small OVA + bootable ISO boots to the UCOS installer.
This commit is contained in:
Ryan Malloy 2026-06-08 04:49:25 -06:00
parent 539aa913e9
commit b743da7666
2 changed files with 187 additions and 0 deletions

View File

@ -65,6 +65,101 @@ class DiskManagementMixin(MCPMixin):
return device
return None
def _find_free_ide_slot(
self, vm: vim.VirtualMachine
) -> tuple[int | None, int | None]:
"""Return (controllerKey, unitNumber) for a free IDE slot, or (None, None)."""
ide_controllers = [
d
for d in vm.config.hardware.device
if isinstance(d, vim.vm.device.VirtualIDEController)
]
used: dict[int, set[int]] = {}
for d in vm.config.hardware.device:
if hasattr(d, "controllerKey") and hasattr(d, "unitNumber"):
used.setdefault(d.controllerKey, set()).add(d.unitNumber)
for controller in ide_controllers:
for unit in (0, 1): # each IDE controller holds two devices
if unit not in used.get(controller.key, set()):
return controller.key, unit
return None, None
@mcp_tool(
name="add_cdrom",
description="Add a CD/DVD drive to a VM, optionally mounting an ISO and booting from it",
annotations=ToolAnnotations(destructiveHint=True),
)
def add_cdrom(
self,
vm_name: str,
iso_path: str | None = None,
iso_datastore: str | None = None,
boot_from_iso: bool = False,
) -> dict[str, Any]:
"""Add a CD/DVD drive to an existing VM.
Useful for appliances deployed from an OVA (e.g. Cisco CUCM), which
ship without a CD/DVD drive and must install from a bootable ISO.
Args:
vm_name: Name of the virtual machine
iso_path: ISO path on a datastore (e.g. 'iso/installer.iso') to mount
iso_datastore: Datastore holding the ISO (default: the VM's datastore)
boot_from_iso: Put the CD/DVD first in the boot order (for installers)
Returns:
Dict with the CD/DVD drive details
"""
vm = self.conn.find_vm(vm_name)
if not vm:
raise ValueError(f"VM '{vm_name}' not found")
controller_key, unit = self._find_free_ide_slot(vm)
if controller_key is None:
raise ValueError("No free IDE slot available for a CD/DVD drive")
cdrom_spec = vim.vm.device.VirtualDeviceSpec()
cdrom_spec.operation = vim.vm.device.VirtualDeviceSpec.Operation.add
cdrom_spec.device = vim.vm.device.VirtualCdrom()
cdrom_spec.device.controllerKey = controller_key
cdrom_spec.device.unitNumber = unit
cdrom_spec.device.key = -1
connectable = vim.vm.device.VirtualDevice.ConnectInfo()
connectable.allowGuestControl = True
connectable.connected = False
mounted_iso = None
if iso_path:
ds_name = iso_datastore or vm.config.files.vmPathName.split("]")[0].strip("[ ")
mounted_iso = f"[{ds_name}] {iso_path}"
backing = vim.vm.device.VirtualCdrom.IsoBackingInfo()
backing.fileName = mounted_iso
connectable.startConnected = True
else:
backing = vim.vm.device.VirtualCdrom.RemotePassthroughBackingInfo()
backing.deviceName = ""
backing.exclusive = False
connectable.startConnected = False
cdrom_spec.device.backing = backing
cdrom_spec.device.connectable = connectable
config_spec = vim.vm.ConfigSpec(deviceChange=[cdrom_spec])
if iso_path and boot_from_iso:
config_spec.bootOptions = vim.vm.BootOptions(
bootOrder=[vim.vm.BootOptions.BootableCdromDevice()]
)
task = vm.ReconfigVM_Task(spec=config_spec)
self.conn.wait_for_task(task)
return {
"vm": vm_name,
"action": "cdrom_added",
"iso": mounted_iso,
"boot_from_iso": bool(iso_path and boot_from_iso),
}
@mcp_tool(
name="add_disk",
description="Add a new virtual disk to a VM",

View File

@ -364,6 +364,9 @@ class OVFManagementMixin(MCPMixin):
power_on: bool = False,
disk_provisioning: str = "thin",
deployment_option: str | None = None,
iso_path: str | None = None,
iso_datastore: str | None = None,
boot_from_iso: bool = True,
) -> dict[str, Any]:
"""Deploy a virtual machine from an OVA file.
@ -382,6 +385,11 @@ class OVFManagementMixin(MCPMixin):
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.
iso_path: ISO on a datastore to mount in a new CD/DVD drive after
deploy (for diskless appliance OVAs like CUCM that install from
a bootable ISO). A CD/DVD drive is added automatically.
iso_datastore: Datastore holding the ISO (default: the VM's datastore)
boot_from_iso: When an ISO is given, put the CD/DVD first in boot order
Returns:
Dict with deployment details
@ -505,6 +513,10 @@ class OVFManagementMixin(MCPMixin):
}
if vm:
result["uuid"] = vm.config.uuid
if iso_path:
result["iso"] = self._add_cdrom_with_iso(
vm, iso_path, iso_datastore, boot_from_iso
)
if power_on:
task = vm.PowerOnVM_Task()
self.conn.wait_for_task(task)
@ -513,6 +525,86 @@ class OVFManagementMixin(MCPMixin):
result["power_state"] = "poweredOff"
return result
def _add_cdrom_with_iso(
self,
vm: vim.VirtualMachine,
iso_path: str,
iso_datastore: str | None,
boot_from_iso: bool,
) -> str:
"""Mount an ISO in the VM's CD/DVD drive (reusing the first existing
drive, or adding one if none exist) and optionally boot from it.
Reusing the existing primary drive matters: many appliance OVAs (e.g.
Cisco CUCM) already define empty CD/DVD drives, and the BIOS boots the
first one, so the ISO must land there rather than on a newly-added
secondary drive.
Returns the mounted datastore ISO path.
"""
ds_name = iso_datastore or vm.config.files.vmPathName.split("]")[0].strip("[ ")
mounted = f"[{ds_name}] {iso_path}"
backing = vim.vm.device.VirtualCdrom.IsoBackingInfo()
backing.fileName = mounted
connectable = vim.vm.device.VirtualDevice.ConnectInfo()
connectable.allowGuestControl = True
connectable.startConnected = True
connectable.connected = (
vm.runtime.powerState == vim.VirtualMachinePowerState.poweredOn
)
existing = next(
(
d
for d in vm.config.hardware.device
if isinstance(d, vim.vm.device.VirtualCdrom)
),
None,
)
cdrom_spec = vim.vm.device.VirtualDeviceSpec()
if existing is not None:
existing.backing = backing
existing.connectable = connectable
cdrom_spec.operation = vim.vm.device.VirtualDeviceSpec.Operation.edit
cdrom_spec.device = existing
else:
# No drive present — add one on a free IDE slot
used: dict[int, set[int]] = {}
ide_controllers = []
for d in vm.config.hardware.device:
if isinstance(d, vim.vm.device.VirtualIDEController):
ide_controllers.append(d)
if hasattr(d, "controllerKey") and hasattr(d, "unitNumber"):
used.setdefault(d.controllerKey, set()).add(d.unitNumber)
controller_key = unit = None
for controller in ide_controllers:
for candidate in (0, 1):
if candidate not in used.get(controller.key, set()):
controller_key, unit = controller.key, candidate
break
if controller_key is not None:
break
if controller_key is None:
raise ValueError("No free IDE slot for a CD/DVD drive")
cdrom_spec.operation = vim.vm.device.VirtualDeviceSpec.Operation.add
cdrom_spec.device = vim.vm.device.VirtualCdrom()
cdrom_spec.device.controllerKey = controller_key
cdrom_spec.device.unitNumber = unit
cdrom_spec.device.key = -1
cdrom_spec.device.backing = backing
cdrom_spec.device.connectable = connectable
config_spec = vim.vm.ConfigSpec(deviceChange=[cdrom_spec])
if boot_from_iso:
config_spec.bootOptions = vim.vm.BootOptions(
bootOrder=[vim.vm.BootOptions.BootableCdromDevice()]
)
task = vm.ReconfigVM_Task(spec=config_spec)
self.conn.wait_for_task(task)
return mounted
@mcp_tool(
name="inspect_ova",
description="Inspect an OVA (local path or URL): product, deployment configurations, networks, and disks",