diff --git a/src/mcvsphere/mixins/disk_management.py b/src/mcvsphere/mixins/disk_management.py index 3627138..d934caa 100644 --- a/src/mcvsphere/mixins/disk_management.py +++ b/src/mcvsphere/mixins/disk_management.py @@ -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", diff --git a/src/mcvsphere/mixins/ovf_management.py b/src/mcvsphere/mixins/ovf_management.py index 30575c4..c250851 100644 --- a/src/mcvsphere/mixins/ovf_management.py +++ b/src/mcvsphere/mixins/ovf_management.py @@ -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",