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:
parent
539aa913e9
commit
b743da7666
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user