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 device
|
||||||
return None
|
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(
|
@mcp_tool(
|
||||||
name="add_disk",
|
name="add_disk",
|
||||||
description="Add a new virtual disk to a VM",
|
description="Add a new virtual disk to a VM",
|
||||||
|
|||||||
@ -364,6 +364,9 @@ class OVFManagementMixin(MCPMixin):
|
|||||||
power_on: bool = False,
|
power_on: bool = False,
|
||||||
disk_provisioning: str = "thin",
|
disk_provisioning: str = "thin",
|
||||||
deployment_option: str | None = None,
|
deployment_option: str | None = None,
|
||||||
|
iso_path: str | None = None,
|
||||||
|
iso_datastore: str | None = None,
|
||||||
|
boot_from_iso: bool = True,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Deploy a virtual machine from an OVA file.
|
"""Deploy a virtual machine from an OVA file.
|
||||||
|
|
||||||
@ -382,6 +385,11 @@ class OVFManagementMixin(MCPMixin):
|
|||||||
deployment_option: OVF configuration id for multi-config templates
|
deployment_option: OVF configuration id for multi-config templates
|
||||||
(e.g. Cisco 'S'/'M'/'L'); defaults to the OVF's default config.
|
(e.g. Cisco 'S'/'M'/'L'); defaults to the OVF's default config.
|
||||||
Use inspect_ova to list available options.
|
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:
|
Returns:
|
||||||
Dict with deployment details
|
Dict with deployment details
|
||||||
@ -505,6 +513,10 @@ class OVFManagementMixin(MCPMixin):
|
|||||||
}
|
}
|
||||||
if vm:
|
if vm:
|
||||||
result["uuid"] = vm.config.uuid
|
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:
|
if power_on:
|
||||||
task = vm.PowerOnVM_Task()
|
task = vm.PowerOnVM_Task()
|
||||||
self.conn.wait_for_task(task)
|
self.conn.wait_for_task(task)
|
||||||
@ -513,6 +525,86 @@ class OVFManagementMixin(MCPMixin):
|
|||||||
result["power_state"] = "poweredOff"
|
result["power_state"] = "poweredOff"
|
||||||
return result
|
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(
|
@mcp_tool(
|
||||||
name="inspect_ova",
|
name="inspect_ova",
|
||||||
description="Inspect an OVA (local path or URL): product, deployment configurations, networks, and disks",
|
description="Inspect an OVA (local path or URL): product, deployment configurations, networks, and disks",
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user