Ryan Malloy 7918a78bfa add comprehensive test suites and fix VM creation
- Fix create_vm: add required files property to ConfigSpec
- Fix disk backing: use fileName instead of datastore reference
- test_client.py: comprehensive read-only test suite (86 tools, 6 resources)
- test_destructive.py: destructive test suite covering 29 operations:
  - VM lifecycle (create, info, rename, reconfigure)
  - Power operations (on, suspend, off)
  - Disk management (add, list, extend, remove)
  - NIC management (add, list, connect, remove)
  - Snapshots (create, list, rename, revert, delete)
  - Folder operations (create, move VM)
  - Datastore operations (create folder, delete)
  - vCenter advanced (storage_vmotion, convert_to_template,
    deploy_from_template, convert_to_vm)
2025-12-26 08:52:33 -07:00

323 lines
12 KiB
Python

"""VM Lifecycle operations - create, clone, delete, reconfigure."""
from typing import TYPE_CHECKING, Any
from fastmcp.contrib.mcp_mixin import MCPMixin, mcp_tool
from mcp.types import ToolAnnotations
from pyVmomi import vim
if TYPE_CHECKING:
from esxi_mcp_server.connection import VMwareConnection
class VMLifecycleMixin(MCPMixin):
"""VM lifecycle management tools - CRUD operations for virtual machines."""
def __init__(self, conn: "VMwareConnection"):
self.conn = conn
@mcp_tool(
name="list_vms",
description="List all virtual machines in the vSphere inventory",
annotations=ToolAnnotations(readOnlyHint=True),
)
def list_vms(self) -> list[dict[str, Any]]:
"""List all virtual machines with basic info."""
vms = []
for vm in self.conn.get_all_vms():
vms.append(
{
"name": vm.name,
"power_state": str(vm.runtime.powerState),
"cpu": vm.config.hardware.numCPU if vm.config else None,
"memory_mb": vm.config.hardware.memoryMB if vm.config else None,
"guest_os": vm.config.guestFullName if vm.config else None,
}
)
return vms
@mcp_tool(
name="get_vm_info",
description="Get detailed information about a specific virtual machine",
annotations=ToolAnnotations(readOnlyHint=True),
)
def get_vm_info(self, name: str) -> dict[str, Any]:
"""Get detailed VM information including hardware, network, and storage."""
vm = self.conn.find_vm(name)
if not vm:
raise ValueError(f"VM '{name}' not found")
# Get disk info
disks = []
if vm.config:
for device in vm.config.hardware.device:
if isinstance(device, vim.vm.device.VirtualDisk):
disks.append(
{
"label": device.deviceInfo.label,
"capacity_gb": round(device.capacityInKB / (1024 * 1024), 2),
"thin_provisioned": getattr(
device.backing, "thinProvisioned", None
),
}
)
# Get NIC info
nics = []
if vm.config:
for device in vm.config.hardware.device:
if isinstance(device, vim.vm.device.VirtualEthernetCard):
nics.append(
{
"label": device.deviceInfo.label,
"mac_address": device.macAddress,
"connected": device.connectable.connected
if device.connectable
else None,
}
)
return {
"name": vm.name,
"power_state": str(vm.runtime.powerState),
"cpu": vm.config.hardware.numCPU if vm.config else None,
"memory_mb": vm.config.hardware.memoryMB if vm.config else None,
"guest_os": vm.config.guestFullName if vm.config else None,
"guest_id": vm.config.guestId if vm.config else None,
"uuid": vm.config.uuid if vm.config else None,
"instance_uuid": vm.config.instanceUuid if vm.config else None,
"host": vm.runtime.host.name if vm.runtime.host else None,
"datastore": [ds.name for ds in vm.datastore] if vm.datastore else [],
"ip_address": vm.guest.ipAddress if vm.guest else None,
"hostname": vm.guest.hostName if vm.guest else None,
"tools_status": str(vm.guest.toolsStatus) if vm.guest else None,
"tools_version": vm.guest.toolsVersion if vm.guest else None,
"disks": disks,
"nics": nics,
"annotation": vm.config.annotation if vm.config else None,
}
@mcp_tool(
name="create_vm",
description="Create a new virtual machine with specified resources",
annotations=ToolAnnotations(destructiveHint=False, idempotentHint=False),
)
def create_vm(
self,
name: str,
cpu: int = 2,
memory_mb: int = 4096,
disk_gb: int = 20,
datastore: str | None = None,
network: str | None = None,
guest_id: str = "otherGuest64",
) -> str:
"""Create a new virtual machine with specified configuration."""
# Resolve datastore
datastore_obj = self.conn.datastore
if datastore:
datastore_obj = self.conn.find_datastore(datastore)
if not datastore_obj:
raise ValueError(f"Datastore '{datastore}' not found")
# Resolve network
network_obj = self.conn.network
if network:
network_obj = self.conn.find_network(network)
if not network_obj:
raise ValueError(f"Network '{network}' not found")
# Build VM config spec with required files property
vm_file_info = vim.vm.FileInfo(
vmPathName=f"[{datastore_obj.name}]"
)
vm_spec = vim.vm.ConfigSpec(
name=name,
memoryMB=memory_mb,
numCPUs=cpu,
guestId=guest_id,
files=vm_file_info,
)
device_specs = []
# Add SCSI controller
controller_spec = vim.vm.device.VirtualDeviceSpec()
controller_spec.operation = vim.vm.device.VirtualDeviceSpec.Operation.add
controller_spec.device = vim.vm.device.ParaVirtualSCSIController()
controller_spec.device.busNumber = 0
controller_spec.device.sharedBus = (
vim.vm.device.VirtualSCSIController.Sharing.noSharing
)
controller_spec.device.key = -101
device_specs.append(controller_spec)
# Add virtual disk
disk_spec = vim.vm.device.VirtualDeviceSpec()
disk_spec.operation = vim.vm.device.VirtualDeviceSpec.Operation.add
disk_spec.fileOperation = vim.vm.device.VirtualDeviceSpec.FileOperation.create
disk_spec.device = vim.vm.device.VirtualDisk()
disk_spec.device.capacityInKB = disk_gb * 1024 * 1024
disk_spec.device.backing = vim.vm.device.VirtualDisk.FlatVer2BackingInfo()
disk_spec.device.backing.diskMode = "persistent"
disk_spec.device.backing.thinProvisioned = True
disk_spec.device.backing.fileName = f"[{datastore_obj.name}]"
disk_spec.device.controllerKey = controller_spec.device.key
disk_spec.device.unitNumber = 0
disk_spec.device.key = -1 # Negative key for new device
device_specs.append(disk_spec)
# Add network adapter if network is available
if network_obj:
nic_spec = vim.vm.device.VirtualDeviceSpec()
nic_spec.operation = vim.vm.device.VirtualDeviceSpec.Operation.add
nic_spec.device = vim.vm.device.VirtualVmxnet3()
if isinstance(network_obj, vim.Network):
nic_spec.device.backing = (
vim.vm.device.VirtualEthernetCard.NetworkBackingInfo(
network=network_obj, deviceName=network_obj.name
)
)
elif isinstance(network_obj, vim.dvs.DistributedVirtualPortgroup):
dvs_uuid = network_obj.config.distributedVirtualSwitch.uuid
port_key = network_obj.key
nic_spec.device.backing = (
vim.vm.device.VirtualEthernetCard.DistributedVirtualPortBackingInfo(
port=vim.dvs.PortConnection(
portgroupKey=port_key, switchUuid=dvs_uuid
)
)
)
nic_spec.device.connectable = vim.vm.device.VirtualDevice.ConnectInfo(
startConnected=True, allowGuestControl=True
)
device_specs.append(nic_spec)
vm_spec.deviceChange = device_specs
# Create VM
task = self.conn.datacenter.vmFolder.CreateVM_Task(
config=vm_spec, pool=self.conn.resource_pool
)
self.conn.wait_for_task(task)
return f"VM '{name}' created successfully"
@mcp_tool(
name="clone_vm",
description="Clone a virtual machine from an existing VM or template",
annotations=ToolAnnotations(destructiveHint=False, idempotentHint=False),
)
def clone_vm(
self,
template_name: str,
new_name: str,
power_on: bool = False,
datastore: str | None = None,
) -> str:
"""Clone a VM from a template or existing VM."""
template_vm = self.conn.find_vm(template_name)
if not template_vm:
raise ValueError(f"Template VM '{template_name}' not found")
vm_folder = template_vm.parent
if not isinstance(vm_folder, vim.Folder):
vm_folder = self.conn.datacenter.vmFolder
# Resolve datastore
datastore_obj = self.conn.datastore
if datastore:
datastore_obj = self.conn.find_datastore(datastore)
if not datastore_obj:
raise ValueError(f"Datastore '{datastore}' not found")
resource_pool = template_vm.resourcePool or self.conn.resource_pool
relocate_spec = vim.vm.RelocateSpec(pool=resource_pool, datastore=datastore_obj)
clone_spec = vim.vm.CloneSpec(
powerOn=power_on, template=False, location=relocate_spec
)
task = template_vm.Clone(folder=vm_folder, name=new_name, spec=clone_spec)
self.conn.wait_for_task(task)
return f"VM '{new_name}' cloned from '{template_name}'"
@mcp_tool(
name="delete_vm",
description="Delete a virtual machine permanently (powers off if running)",
annotations=ToolAnnotations(destructiveHint=True, idempotentHint=True),
)
def delete_vm(self, name: str) -> str:
"""Delete a virtual machine permanently."""
vm = self.conn.find_vm(name)
if not vm:
raise ValueError(f"VM '{name}' not found")
# Power off if running
if vm.runtime.powerState == vim.VirtualMachine.PowerState.poweredOn:
task = vm.PowerOffVM_Task()
self.conn.wait_for_task(task)
task = vm.Destroy_Task()
self.conn.wait_for_task(task)
return f"VM '{name}' deleted"
@mcp_tool(
name="reconfigure_vm",
description="Reconfigure VM hardware (CPU, memory). VM should be powered off for most changes.",
annotations=ToolAnnotations(destructiveHint=False, idempotentHint=True),
)
def reconfigure_vm(
self,
name: str,
cpu: int | None = None,
memory_mb: int | None = None,
annotation: str | None = None,
) -> str:
"""Reconfigure VM hardware settings."""
vm = self.conn.find_vm(name)
if not vm:
raise ValueError(f"VM '{name}' not found")
config_spec = vim.vm.ConfigSpec()
changes = []
if cpu is not None:
config_spec.numCPUs = cpu
changes.append(f"CPU: {cpu}")
if memory_mb is not None:
config_spec.memoryMB = memory_mb
changes.append(f"Memory: {memory_mb}MB")
if annotation is not None:
config_spec.annotation = annotation
changes.append("annotation updated")
if not changes:
return f"No changes specified for VM '{name}'"
task = vm.ReconfigVM_Task(spec=config_spec)
self.conn.wait_for_task(task)
return f"VM '{name}' reconfigured: {', '.join(changes)}"
@mcp_tool(
name="rename_vm",
description="Rename a virtual machine",
annotations=ToolAnnotations(destructiveHint=False, idempotentHint=True),
)
def rename_vm(self, name: str, new_name: str) -> str:
"""Rename a virtual machine."""
vm = self.conn.find_vm(name)
if not vm:
raise ValueError(f"VM '{name}' not found")
task = vm.Rename_Task(newName=new_name)
self.conn.wait_for_task(task)
return f"VM renamed from '{name}' to '{new_name}'"