Make all tools host-aware: per-call host selection across every mixin
Rolls the host: str | None = None selector and conn = self._conn(host) routing across all remaining tools in the 12 mixins (the inventory tools landed earlier). 97 of 98 tools now accept an optional host to target a specific managed ESXi server; list_servers is the only exception (it is the registry itself). Private helpers that touched self.conn (e.g. ovf NFC/datastore helpers, nic _get_network_backing, vcenter_ops folder/pool/cluster finders, host_management _get_host, resources datastore browse/stream) now take a conn parameter threaded from their tool call sites. Several tools had a local 'host' variable renamed to avoid shadowing the new selector. Verified: full server builds (98 tools), and get_host_info/list_hosts route correctly to two distinct live hosts (205 vs 222).
This commit is contained in:
parent
2930318125
commit
9c02d7238c
@ -25,7 +25,8 @@ class ConsoleMixin(VSphereMixin):
|
|||||||
annotations=ToolAnnotations(readOnlyHint=True),
|
annotations=ToolAnnotations(readOnlyHint=True),
|
||||||
)
|
)
|
||||||
def wait_for_vm_tools(
|
def wait_for_vm_tools(
|
||||||
self, name: str, timeout: int = 120, poll_interval: int = 5
|
self, name: str, timeout: int = 120, poll_interval: int = 5,
|
||||||
|
host: str | None = None
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Wait for VMware Tools to become available.
|
"""Wait for VMware Tools to become available.
|
||||||
|
|
||||||
@ -33,11 +34,13 @@ class ConsoleMixin(VSphereMixin):
|
|||||||
name: VM name
|
name: VM name
|
||||||
timeout: Maximum seconds to wait (default: 120)
|
timeout: Maximum seconds to wait (default: 120)
|
||||||
poll_interval: Seconds between status checks (default: 5)
|
poll_interval: Seconds between status checks (default: 5)
|
||||||
|
host: Managed ESXi host to target (default: the default host)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict with tools status, version, and guest info when ready
|
Dict with tools status, version, and guest info when ready
|
||||||
"""
|
"""
|
||||||
vm = self.conn.find_vm(name)
|
conn = self._conn(host)
|
||||||
|
vm = conn.find_vm(name)
|
||||||
if not vm:
|
if not vm:
|
||||||
raise ValueError(f"VM '{name}' not found")
|
raise ValueError(f"VM '{name}' not found")
|
||||||
|
|
||||||
@ -76,16 +79,18 @@ class ConsoleMixin(VSphereMixin):
|
|||||||
description="Get current VMware Tools status for a VM",
|
description="Get current VMware Tools status for a VM",
|
||||||
annotations=ToolAnnotations(readOnlyHint=True),
|
annotations=ToolAnnotations(readOnlyHint=True),
|
||||||
)
|
)
|
||||||
def get_vm_tools_status(self, name: str) -> dict[str, Any]:
|
def get_vm_tools_status(self, name: str, host: str | None = None) -> dict[str, Any]:
|
||||||
"""Get VMware Tools status without waiting.
|
"""Get VMware Tools status without waiting.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
name: VM name
|
name: VM name
|
||||||
|
host: Managed ESXi host to target (default: the default host)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict with current tools status and guest info
|
Dict with current tools status and guest info
|
||||||
"""
|
"""
|
||||||
vm = self.conn.find_vm(name)
|
conn = self._conn(host)
|
||||||
|
vm = conn.find_vm(name)
|
||||||
if not vm:
|
if not vm:
|
||||||
raise ValueError(f"VM '{name}' not found")
|
raise ValueError(f"VM '{name}' not found")
|
||||||
|
|
||||||
@ -115,6 +120,7 @@ class ConsoleMixin(VSphereMixin):
|
|||||||
name: str,
|
name: str,
|
||||||
width: int | None = None,
|
width: int | None = None,
|
||||||
height: int | None = None,
|
height: int | None = None,
|
||||||
|
host: str | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Capture VM console screenshot via vSphere HTTP API.
|
"""Capture VM console screenshot via vSphere HTTP API.
|
||||||
|
|
||||||
@ -122,19 +128,21 @@ class ConsoleMixin(VSphereMixin):
|
|||||||
name: VM name
|
name: VM name
|
||||||
width: Optional width to scale the image
|
width: Optional width to scale the image
|
||||||
height: Optional height to scale the image
|
height: Optional height to scale the image
|
||||||
|
host: Managed ESXi host to target (default: the default host)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict with base64-encoded image data and metadata
|
Dict with base64-encoded image data and metadata
|
||||||
"""
|
"""
|
||||||
vm = self.conn.find_vm(name)
|
conn = self._conn(host)
|
||||||
|
vm = conn.find_vm(name)
|
||||||
if not vm:
|
if not vm:
|
||||||
raise ValueError(f"VM '{name}' not found")
|
raise ValueError(f"VM '{name}' not found")
|
||||||
|
|
||||||
# Build screenshot URL
|
# Build screenshot URL
|
||||||
# Format: https://{host}/screen?id={moid}
|
# Format: https://{host}/screen?id={moid}
|
||||||
host = self.conn.settings.vcenter_host
|
vcenter_host = conn.settings.vcenter_host
|
||||||
moid = vm._moId
|
moid = vm._moId
|
||||||
screenshot_url = f"https://{host}/screen?id={moid}"
|
screenshot_url = f"https://{vcenter_host}/screen?id={moid}"
|
||||||
|
|
||||||
# Add optional scaling parameters
|
# Add optional scaling parameters
|
||||||
params = []
|
params = []
|
||||||
@ -146,8 +154,8 @@ class ConsoleMixin(VSphereMixin):
|
|||||||
screenshot_url += "&" + "&".join(params)
|
screenshot_url += "&" + "&".join(params)
|
||||||
|
|
||||||
# Build auth header
|
# Build auth header
|
||||||
username = self.conn.settings.vcenter_user
|
username = conn.settings.vcenter_user
|
||||||
password = self.conn.settings.vcenter_password.get_secret_value()
|
password = conn.settings.vcenter_password.get_secret_value()
|
||||||
auth = base64.b64encode(f"{username}:{password}".encode()).decode("ascii")
|
auth = base64.b64encode(f"{username}:{password}".encode()).decode("ascii")
|
||||||
|
|
||||||
# Make request
|
# Make request
|
||||||
@ -155,7 +163,7 @@ class ConsoleMixin(VSphereMixin):
|
|||||||
response = requests.get(
|
response = requests.get(
|
||||||
screenshot_url,
|
screenshot_url,
|
||||||
headers={"Authorization": f"Basic {auth}"},
|
headers={"Authorization": f"Basic {auth}"},
|
||||||
verify=not self.conn.settings.vcenter_insecure,
|
verify=not conn.settings.vcenter_insecure,
|
||||||
timeout=30,
|
timeout=30,
|
||||||
)
|
)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
|
|||||||
@ -94,6 +94,7 @@ class DiskManagementMixin(VSphereMixin):
|
|||||||
iso_path: str | None = None,
|
iso_path: str | None = None,
|
||||||
iso_datastore: str | None = None,
|
iso_datastore: str | None = None,
|
||||||
boot_from_iso: bool = False,
|
boot_from_iso: bool = False,
|
||||||
|
host: str | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Add a CD/DVD drive to an existing VM.
|
"""Add a CD/DVD drive to an existing VM.
|
||||||
|
|
||||||
@ -105,11 +106,13 @@ class DiskManagementMixin(VSphereMixin):
|
|||||||
iso_path: ISO path on a datastore (e.g. 'iso/installer.iso') to mount
|
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)
|
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)
|
boot_from_iso: Put the CD/DVD first in the boot order (for installers)
|
||||||
|
host: Managed ESXi host to target (default: the default host)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict with the CD/DVD drive details
|
Dict with the CD/DVD drive details
|
||||||
"""
|
"""
|
||||||
vm = self.conn.find_vm(vm_name)
|
conn = self._conn(host)
|
||||||
|
vm = conn.find_vm(vm_name)
|
||||||
if not vm:
|
if not vm:
|
||||||
raise ValueError(f"VM '{vm_name}' not found")
|
raise ValueError(f"VM '{vm_name}' not found")
|
||||||
|
|
||||||
@ -150,7 +153,7 @@ class DiskManagementMixin(VSphereMixin):
|
|||||||
)
|
)
|
||||||
|
|
||||||
task = vm.ReconfigVM_Task(spec=config_spec)
|
task = vm.ReconfigVM_Task(spec=config_spec)
|
||||||
self.conn.wait_for_task(task)
|
conn.wait_for_task(task)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"vm": vm_name,
|
"vm": vm_name,
|
||||||
@ -170,6 +173,7 @@ class DiskManagementMixin(VSphereMixin):
|
|||||||
size_gb: int,
|
size_gb: int,
|
||||||
thin_provisioned: bool = True,
|
thin_provisioned: bool = True,
|
||||||
datastore: str | None = None,
|
datastore: str | None = None,
|
||||||
|
host: str | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Add a new virtual disk to a VM.
|
"""Add a new virtual disk to a VM.
|
||||||
|
|
||||||
@ -178,11 +182,13 @@ class DiskManagementMixin(VSphereMixin):
|
|||||||
size_gb: Size of the new disk in GB
|
size_gb: Size of the new disk in GB
|
||||||
thin_provisioned: Use thin provisioning (default True)
|
thin_provisioned: Use thin provisioning (default True)
|
||||||
datastore: Datastore for the disk (default: same as VM)
|
datastore: Datastore for the disk (default: same as VM)
|
||||||
|
host: Managed ESXi host to target (default: the default host)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict with new disk details
|
Dict with new disk details
|
||||||
"""
|
"""
|
||||||
vm = self.conn.find_vm(vm_name)
|
conn = self._conn(host)
|
||||||
|
vm = conn.find_vm(vm_name)
|
||||||
if not vm:
|
if not vm:
|
||||||
raise ValueError(f"VM '{vm_name}' not found")
|
raise ValueError(f"VM '{vm_name}' not found")
|
||||||
|
|
||||||
@ -191,7 +197,7 @@ class DiskManagementMixin(VSphereMixin):
|
|||||||
|
|
||||||
# Determine datastore
|
# Determine datastore
|
||||||
if datastore:
|
if datastore:
|
||||||
ds = self.conn.find_datastore(datastore)
|
ds = conn.find_datastore(datastore)
|
||||||
if not ds:
|
if not ds:
|
||||||
raise ValueError(f"Datastore '{datastore}' not found")
|
raise ValueError(f"Datastore '{datastore}' not found")
|
||||||
ds_name = datastore
|
ds_name = datastore
|
||||||
@ -206,7 +212,7 @@ class DiskManagementMixin(VSphereMixin):
|
|||||||
backing = vim.vm.device.VirtualDisk.FlatVer2BackingInfo()
|
backing = vim.vm.device.VirtualDisk.FlatVer2BackingInfo()
|
||||||
backing.diskMode = "persistent"
|
backing.diskMode = "persistent"
|
||||||
backing.thinProvisioned = thin_provisioned
|
backing.thinProvisioned = thin_provisioned
|
||||||
backing.datastore = self.conn.find_datastore(ds_name)
|
backing.datastore = conn.find_datastore(ds_name)
|
||||||
|
|
||||||
# Create the virtual disk
|
# Create the virtual disk
|
||||||
disk = vim.vm.device.VirtualDisk()
|
disk = vim.vm.device.VirtualDisk()
|
||||||
@ -227,7 +233,7 @@ class DiskManagementMixin(VSphereMixin):
|
|||||||
|
|
||||||
# Reconfigure VM
|
# Reconfigure VM
|
||||||
task = vm.ReconfigVM_Task(spec=config_spec)
|
task = vm.ReconfigVM_Task(spec=config_spec)
|
||||||
self.conn.wait_for_task(task)
|
conn.wait_for_task(task)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"vm": vm_name,
|
"vm": vm_name,
|
||||||
@ -249,6 +255,7 @@ class DiskManagementMixin(VSphereMixin):
|
|||||||
vm_name: str,
|
vm_name: str,
|
||||||
disk_label: str,
|
disk_label: str,
|
||||||
delete_file: bool = False,
|
delete_file: bool = False,
|
||||||
|
host: str | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Remove a virtual disk from a VM.
|
"""Remove a virtual disk from a VM.
|
||||||
|
|
||||||
@ -256,11 +263,13 @@ class DiskManagementMixin(VSphereMixin):
|
|||||||
vm_name: Name of the virtual machine
|
vm_name: Name of the virtual machine
|
||||||
disk_label: Label of disk to remove (e.g., 'Hard disk 2')
|
disk_label: Label of disk to remove (e.g., 'Hard disk 2')
|
||||||
delete_file: Also delete the VMDK file (default False - keep file)
|
delete_file: Also delete the VMDK file (default False - keep file)
|
||||||
|
host: Managed ESXi host to target (default: the default host)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict with removal details
|
Dict with removal details
|
||||||
"""
|
"""
|
||||||
vm = self.conn.find_vm(vm_name)
|
conn = self._conn(host)
|
||||||
|
vm = conn.find_vm(vm_name)
|
||||||
if not vm:
|
if not vm:
|
||||||
raise ValueError(f"VM '{vm_name}' not found")
|
raise ValueError(f"VM '{vm_name}' not found")
|
||||||
|
|
||||||
@ -291,7 +300,7 @@ class DiskManagementMixin(VSphereMixin):
|
|||||||
|
|
||||||
# Reconfigure VM
|
# Reconfigure VM
|
||||||
task = vm.ReconfigVM_Task(spec=config_spec)
|
task = vm.ReconfigVM_Task(spec=config_spec)
|
||||||
self.conn.wait_for_task(task)
|
conn.wait_for_task(task)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"vm": vm_name,
|
"vm": vm_name,
|
||||||
@ -312,6 +321,7 @@ class DiskManagementMixin(VSphereMixin):
|
|||||||
vm_name: str,
|
vm_name: str,
|
||||||
disk_label: str,
|
disk_label: str,
|
||||||
new_size_gb: int,
|
new_size_gb: int,
|
||||||
|
host: str | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Extend a virtual disk to a larger size.
|
"""Extend a virtual disk to a larger size.
|
||||||
|
|
||||||
@ -319,11 +329,13 @@ class DiskManagementMixin(VSphereMixin):
|
|||||||
vm_name: Name of the virtual machine
|
vm_name: Name of the virtual machine
|
||||||
disk_label: Label of disk to extend (e.g., 'Hard disk 1')
|
disk_label: Label of disk to extend (e.g., 'Hard disk 1')
|
||||||
new_size_gb: New total size in GB (must be larger than current)
|
new_size_gb: New total size in GB (must be larger than current)
|
||||||
|
host: Managed ESXi host to target (default: the default host)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict with extension details
|
Dict with extension details
|
||||||
"""
|
"""
|
||||||
vm = self.conn.find_vm(vm_name)
|
conn = self._conn(host)
|
||||||
|
vm = conn.find_vm(vm_name)
|
||||||
if not vm:
|
if not vm:
|
||||||
raise ValueError(f"VM '{vm_name}' not found")
|
raise ValueError(f"VM '{vm_name}' not found")
|
||||||
|
|
||||||
@ -357,7 +369,7 @@ class DiskManagementMixin(VSphereMixin):
|
|||||||
|
|
||||||
# Reconfigure VM
|
# Reconfigure VM
|
||||||
task = vm.ReconfigVM_Task(spec=config_spec)
|
task = vm.ReconfigVM_Task(spec=config_spec)
|
||||||
self.conn.wait_for_task(task)
|
conn.wait_for_task(task)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"vm": vm_name,
|
"vm": vm_name,
|
||||||
@ -372,16 +384,18 @@ class DiskManagementMixin(VSphereMixin):
|
|||||||
description="List all virtual disks attached to a VM",
|
description="List all virtual disks attached to a VM",
|
||||||
annotations=ToolAnnotations(readOnlyHint=True),
|
annotations=ToolAnnotations(readOnlyHint=True),
|
||||||
)
|
)
|
||||||
def list_disks(self, vm_name: str) -> list[dict[str, Any]]:
|
def list_disks(self, vm_name: str, host: str | None = None) -> list[dict[str, Any]]:
|
||||||
"""List all virtual disks attached to a VM.
|
"""List all virtual disks attached to a VM.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
vm_name: Name of the virtual machine
|
vm_name: Name of the virtual machine
|
||||||
|
host: Managed ESXi host to target (default: the default host)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of disk details
|
List of disk details
|
||||||
"""
|
"""
|
||||||
vm = self.conn.find_vm(vm_name)
|
conn = self._conn(host)
|
||||||
|
vm = conn.find_vm(vm_name)
|
||||||
if not vm:
|
if not vm:
|
||||||
raise ValueError(f"VM '{vm_name}' not found")
|
raise ValueError(f"VM '{vm_name}' not found")
|
||||||
|
|
||||||
@ -416,6 +430,7 @@ class DiskManagementMixin(VSphereMixin):
|
|||||||
vm_name: str,
|
vm_name: str,
|
||||||
iso_path: str,
|
iso_path: str,
|
||||||
datastore: str | None = None,
|
datastore: str | None = None,
|
||||||
|
host: str | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Attach an ISO file to a VM's CD/DVD drive.
|
"""Attach an ISO file to a VM's CD/DVD drive.
|
||||||
|
|
||||||
@ -423,11 +438,13 @@ class DiskManagementMixin(VSphereMixin):
|
|||||||
vm_name: Name of the virtual machine
|
vm_name: Name of the virtual machine
|
||||||
iso_path: Path to ISO file on datastore (e.g., 'iso/ubuntu.iso')
|
iso_path: Path to ISO file on datastore (e.g., 'iso/ubuntu.iso')
|
||||||
datastore: Datastore containing the ISO (default: first VM datastore)
|
datastore: Datastore containing the ISO (default: first VM datastore)
|
||||||
|
host: Managed ESXi host to target (default: the default host)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict with attachment details
|
Dict with attachment details
|
||||||
"""
|
"""
|
||||||
vm = self.conn.find_vm(vm_name)
|
conn = self._conn(host)
|
||||||
|
vm = conn.find_vm(vm_name)
|
||||||
if not vm:
|
if not vm:
|
||||||
raise ValueError(f"VM '{vm_name}' not found")
|
raise ValueError(f"VM '{vm_name}' not found")
|
||||||
|
|
||||||
@ -464,7 +481,7 @@ class DiskManagementMixin(VSphereMixin):
|
|||||||
|
|
||||||
# Reconfigure VM
|
# Reconfigure VM
|
||||||
task = vm.ReconfigVM_Task(spec=config_spec)
|
task = vm.ReconfigVM_Task(spec=config_spec)
|
||||||
self.conn.wait_for_task(task)
|
conn.wait_for_task(task)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"vm": vm_name,
|
"vm": vm_name,
|
||||||
@ -479,16 +496,18 @@ class DiskManagementMixin(VSphereMixin):
|
|||||||
description="Detach/eject ISO from a VM's CD/DVD drive",
|
description="Detach/eject ISO from a VM's CD/DVD drive",
|
||||||
annotations=ToolAnnotations(destructiveHint=True),
|
annotations=ToolAnnotations(destructiveHint=True),
|
||||||
)
|
)
|
||||||
def detach_iso(self, vm_name: str) -> dict[str, Any]:
|
def detach_iso(self, vm_name: str, host: str | None = None) -> dict[str, Any]:
|
||||||
"""Detach/eject ISO from a VM's CD/DVD drive.
|
"""Detach/eject ISO from a VM's CD/DVD drive.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
vm_name: Name of the virtual machine
|
vm_name: Name of the virtual machine
|
||||||
|
host: Managed ESXi host to target (default: the default host)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict with detachment details
|
Dict with detachment details
|
||||||
"""
|
"""
|
||||||
vm = self.conn.find_vm(vm_name)
|
conn = self._conn(host)
|
||||||
|
vm = conn.find_vm(vm_name)
|
||||||
if not vm:
|
if not vm:
|
||||||
raise ValueError(f"VM '{vm_name}' not found")
|
raise ValueError(f"VM '{vm_name}' not found")
|
||||||
|
|
||||||
@ -523,7 +542,7 @@ class DiskManagementMixin(VSphereMixin):
|
|||||||
|
|
||||||
# Reconfigure VM
|
# Reconfigure VM
|
||||||
task = vm.ReconfigVM_Task(spec=config_spec)
|
task = vm.ReconfigVM_Task(spec=config_spec)
|
||||||
self.conn.wait_for_task(task)
|
conn.wait_for_task(task)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"vm": vm_name,
|
"vm": vm_name,
|
||||||
|
|||||||
@ -53,6 +53,7 @@ class GuestOpsMixin(VSphereMixin):
|
|||||||
working_directory: str = "",
|
working_directory: str = "",
|
||||||
wait_for_completion: bool = True,
|
wait_for_completion: bool = True,
|
||||||
timeout_seconds: int = 300,
|
timeout_seconds: int = 300,
|
||||||
|
host: str | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Run a command in the guest OS.
|
"""Run a command in the guest OS.
|
||||||
|
|
||||||
@ -65,14 +66,16 @@ class GuestOpsMixin(VSphereMixin):
|
|||||||
working_directory: Working directory for the command
|
working_directory: Working directory for the command
|
||||||
wait_for_completion: Wait for command to complete
|
wait_for_completion: Wait for command to complete
|
||||||
timeout_seconds: Timeout in seconds (only if waiting)
|
timeout_seconds: Timeout in seconds (only if waiting)
|
||||||
|
host: Managed ESXi host to target (default: the default host)
|
||||||
"""
|
"""
|
||||||
vm = self.conn.find_vm(name)
|
conn = self._conn(host)
|
||||||
|
vm = conn.find_vm(name)
|
||||||
if not vm:
|
if not vm:
|
||||||
raise ValueError(f"VM '{name}' not found")
|
raise ValueError(f"VM '{name}' not found")
|
||||||
|
|
||||||
self._check_tools_running(vm)
|
self._check_tools_running(vm)
|
||||||
|
|
||||||
guest_ops = self.conn.content.guestOperationsManager
|
guest_ops = conn.content.guestOperationsManager
|
||||||
process_manager = guest_ops.processManager
|
process_manager = guest_ops.processManager
|
||||||
auth = self._get_guest_auth(username, password)
|
auth = self._get_guest_auth(username, password)
|
||||||
|
|
||||||
@ -118,16 +121,17 @@ class GuestOpsMixin(VSphereMixin):
|
|||||||
annotations=ToolAnnotations(readOnlyHint=True),
|
annotations=ToolAnnotations(readOnlyHint=True),
|
||||||
)
|
)
|
||||||
def list_guest_processes(
|
def list_guest_processes(
|
||||||
self, name: str, username: str, password: str
|
self, name: str, username: str, password: str, host: str | None = None
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
"""List processes running in the guest OS."""
|
"""List processes running in the guest OS."""
|
||||||
vm = self.conn.find_vm(name)
|
conn = self._conn(host)
|
||||||
|
vm = conn.find_vm(name)
|
||||||
if not vm:
|
if not vm:
|
||||||
raise ValueError(f"VM '{name}' not found")
|
raise ValueError(f"VM '{name}' not found")
|
||||||
|
|
||||||
self._check_tools_running(vm)
|
self._check_tools_running(vm)
|
||||||
|
|
||||||
guest_ops = self.conn.content.guestOperationsManager
|
guest_ops = conn.content.guestOperationsManager
|
||||||
process_manager = guest_ops.processManager
|
process_manager = guest_ops.processManager
|
||||||
auth = self._get_guest_auth(username, password)
|
auth = self._get_guest_auth(username, password)
|
||||||
|
|
||||||
@ -150,7 +154,8 @@ class GuestOpsMixin(VSphereMixin):
|
|||||||
annotations=ToolAnnotations(readOnlyHint=True),
|
annotations=ToolAnnotations(readOnlyHint=True),
|
||||||
)
|
)
|
||||||
def read_guest_file(
|
def read_guest_file(
|
||||||
self, name: str, username: str, password: str, guest_path: str
|
self, name: str, username: str, password: str, guest_path: str,
|
||||||
|
host: str | None = None
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Read a file from the guest OS.
|
"""Read a file from the guest OS.
|
||||||
|
|
||||||
@ -159,14 +164,16 @@ class GuestOpsMixin(VSphereMixin):
|
|||||||
username: Guest OS username
|
username: Guest OS username
|
||||||
password: Guest OS password
|
password: Guest OS password
|
||||||
guest_path: Path to file in guest (e.g., /etc/hosts, C:\\Windows\\System32\\hosts)
|
guest_path: Path to file in guest (e.g., /etc/hosts, C:\\Windows\\System32\\hosts)
|
||||||
|
host: Managed ESXi host to target (default: the default host)
|
||||||
"""
|
"""
|
||||||
vm = self.conn.find_vm(name)
|
conn = self._conn(host)
|
||||||
|
vm = conn.find_vm(name)
|
||||||
if not vm:
|
if not vm:
|
||||||
raise ValueError(f"VM '{name}' not found")
|
raise ValueError(f"VM '{name}' not found")
|
||||||
|
|
||||||
self._check_tools_running(vm)
|
self._check_tools_running(vm)
|
||||||
|
|
||||||
guest_ops = self.conn.content.guestOperationsManager
|
guest_ops = conn.content.guestOperationsManager
|
||||||
file_manager = guest_ops.fileManager
|
file_manager = guest_ops.fileManager
|
||||||
auth = self._get_guest_auth(username, password)
|
auth = self._get_guest_auth(username, password)
|
||||||
|
|
||||||
@ -227,6 +234,7 @@ class GuestOpsMixin(VSphereMixin):
|
|||||||
guest_path: str,
|
guest_path: str,
|
||||||
content: str,
|
content: str,
|
||||||
overwrite: bool = True,
|
overwrite: bool = True,
|
||||||
|
host: str | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Write a file to the guest OS.
|
"""Write a file to the guest OS.
|
||||||
|
|
||||||
@ -237,14 +245,16 @@ class GuestOpsMixin(VSphereMixin):
|
|||||||
guest_path: Destination path in guest
|
guest_path: Destination path in guest
|
||||||
content: File content (text)
|
content: File content (text)
|
||||||
overwrite: Overwrite if exists
|
overwrite: Overwrite if exists
|
||||||
|
host: Managed ESXi host to target (default: the default host)
|
||||||
"""
|
"""
|
||||||
vm = self.conn.find_vm(name)
|
conn = self._conn(host)
|
||||||
|
vm = conn.find_vm(name)
|
||||||
if not vm:
|
if not vm:
|
||||||
raise ValueError(f"VM '{name}' not found")
|
raise ValueError(f"VM '{name}' not found")
|
||||||
|
|
||||||
self._check_tools_running(vm)
|
self._check_tools_running(vm)
|
||||||
|
|
||||||
guest_ops = self.conn.content.guestOperationsManager
|
guest_ops = conn.content.guestOperationsManager
|
||||||
file_manager = guest_ops.fileManager
|
file_manager = guest_ops.fileManager
|
||||||
auth = self._get_guest_auth(username, password)
|
auth = self._get_guest_auth(username, password)
|
||||||
|
|
||||||
@ -287,16 +297,18 @@ class GuestOpsMixin(VSphereMixin):
|
|||||||
annotations=ToolAnnotations(readOnlyHint=True),
|
annotations=ToolAnnotations(readOnlyHint=True),
|
||||||
)
|
)
|
||||||
def list_guest_directory(
|
def list_guest_directory(
|
||||||
self, name: str, username: str, password: str, guest_path: str
|
self, name: str, username: str, password: str, guest_path: str,
|
||||||
|
host: str | None = None
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
"""List files in a guest directory."""
|
"""List files in a guest directory."""
|
||||||
vm = self.conn.find_vm(name)
|
conn = self._conn(host)
|
||||||
|
vm = conn.find_vm(name)
|
||||||
if not vm:
|
if not vm:
|
||||||
raise ValueError(f"VM '{name}' not found")
|
raise ValueError(f"VM '{name}' not found")
|
||||||
|
|
||||||
self._check_tools_running(vm)
|
self._check_tools_running(vm)
|
||||||
|
|
||||||
guest_ops = self.conn.content.guestOperationsManager
|
guest_ops = conn.content.guestOperationsManager
|
||||||
file_manager = guest_ops.fileManager
|
file_manager = guest_ops.fileManager
|
||||||
auth = self._get_guest_auth(username, password)
|
auth = self._get_guest_auth(username, password)
|
||||||
|
|
||||||
@ -331,15 +343,17 @@ class GuestOpsMixin(VSphereMixin):
|
|||||||
password: str,
|
password: str,
|
||||||
guest_path: str,
|
guest_path: str,
|
||||||
create_parents: bool = True,
|
create_parents: bool = True,
|
||||||
|
host: str | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Create a directory in the guest OS."""
|
"""Create a directory in the guest OS."""
|
||||||
vm = self.conn.find_vm(name)
|
conn = self._conn(host)
|
||||||
|
vm = conn.find_vm(name)
|
||||||
if not vm:
|
if not vm:
|
||||||
raise ValueError(f"VM '{name}' not found")
|
raise ValueError(f"VM '{name}' not found")
|
||||||
|
|
||||||
self._check_tools_running(vm)
|
self._check_tools_running(vm)
|
||||||
|
|
||||||
guest_ops = self.conn.content.guestOperationsManager
|
guest_ops = conn.content.guestOperationsManager
|
||||||
file_manager = guest_ops.fileManager
|
file_manager = guest_ops.fileManager
|
||||||
auth = self._get_guest_auth(username, password)
|
auth = self._get_guest_auth(username, password)
|
||||||
|
|
||||||
@ -355,16 +369,18 @@ class GuestOpsMixin(VSphereMixin):
|
|||||||
annotations=ToolAnnotations(destructiveHint=True, idempotentHint=True),
|
annotations=ToolAnnotations(destructiveHint=True, idempotentHint=True),
|
||||||
)
|
)
|
||||||
def delete_guest_file(
|
def delete_guest_file(
|
||||||
self, name: str, username: str, password: str, guest_path: str
|
self, name: str, username: str, password: str, guest_path: str,
|
||||||
|
host: str | None = None
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Delete a file or directory from the guest OS."""
|
"""Delete a file or directory from the guest OS."""
|
||||||
vm = self.conn.find_vm(name)
|
conn = self._conn(host)
|
||||||
|
vm = conn.find_vm(name)
|
||||||
if not vm:
|
if not vm:
|
||||||
raise ValueError(f"VM '{name}' not found")
|
raise ValueError(f"VM '{name}' not found")
|
||||||
|
|
||||||
self._check_tools_running(vm)
|
self._check_tools_running(vm)
|
||||||
|
|
||||||
guest_ops = self.conn.content.guestOperationsManager
|
guest_ops = conn.content.guestOperationsManager
|
||||||
file_manager = guest_ops.fileManager
|
file_manager = guest_ops.fileManager
|
||||||
auth = self._get_guest_auth(username, password)
|
auth = self._get_guest_auth(username, password)
|
||||||
|
|
||||||
|
|||||||
@ -15,9 +15,9 @@ if TYPE_CHECKING:
|
|||||||
class HostManagementMixin(VSphereMixin):
|
class HostManagementMixin(VSphereMixin):
|
||||||
"""ESXi host management tools."""
|
"""ESXi host management tools."""
|
||||||
|
|
||||||
def _get_host(self) -> vim.HostSystem:
|
def _get_host(self, conn) -> vim.HostSystem:
|
||||||
"""Get the ESXi host system."""
|
"""Get the ESXi host system."""
|
||||||
for entity in self.conn.datacenter.hostFolder.childEntity:
|
for entity in conn.datacenter.hostFolder.childEntity:
|
||||||
if isinstance(entity, vim.ComputeResource):
|
if isinstance(entity, vim.ComputeResource):
|
||||||
if entity.host:
|
if entity.host:
|
||||||
return entity.host[0]
|
return entity.host[0]
|
||||||
@ -30,13 +30,17 @@ class HostManagementMixin(VSphereMixin):
|
|||||||
description="Get detailed information about the ESXi host",
|
description="Get detailed information about the ESXi host",
|
||||||
annotations=ToolAnnotations(readOnlyHint=True),
|
annotations=ToolAnnotations(readOnlyHint=True),
|
||||||
)
|
)
|
||||||
def get_host_info(self) -> dict[str, Any]:
|
def get_host_info(self, host: str | None = None) -> dict[str, Any]:
|
||||||
"""Get detailed ESXi host information.
|
"""Get detailed ESXi host information.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
host: Managed ESXi host to target (default: the default host)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict with host details including hardware, software, and status
|
Dict with host details including hardware, software, and status
|
||||||
"""
|
"""
|
||||||
host = self._get_host()
|
conn = self._conn(host)
|
||||||
|
host = self._get_host(conn)
|
||||||
summary = host.summary
|
summary = host.summary
|
||||||
hardware = summary.hardware
|
hardware = summary.hardware
|
||||||
config = summary.config
|
config = summary.config
|
||||||
@ -80,17 +84,20 @@ class HostManagementMixin(VSphereMixin):
|
|||||||
self,
|
self,
|
||||||
evacuate_vms: bool = True,
|
evacuate_vms: bool = True,
|
||||||
timeout_seconds: int = 300,
|
timeout_seconds: int = 300,
|
||||||
|
host: str | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Put ESXi host into maintenance mode.
|
"""Put ESXi host into maintenance mode.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
evacuate_vms: Evacuate/suspend VMs before entering (default True)
|
evacuate_vms: Evacuate/suspend VMs before entering (default True)
|
||||||
timeout_seconds: Timeout for the operation (default 300)
|
timeout_seconds: Timeout for the operation (default 300)
|
||||||
|
host: Managed ESXi host to target (default: the default host)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict with operation result
|
Dict with operation result
|
||||||
"""
|
"""
|
||||||
host = self._get_host()
|
conn = self._conn(host)
|
||||||
|
host = self._get_host(conn)
|
||||||
|
|
||||||
if host.runtime.inMaintenanceMode:
|
if host.runtime.inMaintenanceMode:
|
||||||
return {
|
return {
|
||||||
@ -104,7 +111,7 @@ class HostManagementMixin(VSphereMixin):
|
|||||||
timeout=timeout_seconds,
|
timeout=timeout_seconds,
|
||||||
evacuatePoweredOffVms=evacuate_vms,
|
evacuatePoweredOffVms=evacuate_vms,
|
||||||
)
|
)
|
||||||
self.conn.wait_for_task(task)
|
conn.wait_for_task(task)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"host": host.name,
|
"host": host.name,
|
||||||
@ -121,16 +128,19 @@ class HostManagementMixin(VSphereMixin):
|
|||||||
def exit_maintenance_mode(
|
def exit_maintenance_mode(
|
||||||
self,
|
self,
|
||||||
timeout_seconds: int = 300,
|
timeout_seconds: int = 300,
|
||||||
|
host: str | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Exit ESXi host from maintenance mode.
|
"""Exit ESXi host from maintenance mode.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
timeout_seconds: Timeout for the operation (default 300)
|
timeout_seconds: Timeout for the operation (default 300)
|
||||||
|
host: Managed ESXi host to target (default: the default host)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict with operation result
|
Dict with operation result
|
||||||
"""
|
"""
|
||||||
host = self._get_host()
|
conn = self._conn(host)
|
||||||
|
host = self._get_host(conn)
|
||||||
|
|
||||||
if not host.runtime.inMaintenanceMode:
|
if not host.runtime.inMaintenanceMode:
|
||||||
return {
|
return {
|
||||||
@ -140,7 +150,7 @@ class HostManagementMixin(VSphereMixin):
|
|||||||
}
|
}
|
||||||
|
|
||||||
task = host.ExitMaintenanceMode_Task(timeout=timeout_seconds)
|
task = host.ExitMaintenanceMode_Task(timeout=timeout_seconds)
|
||||||
self.conn.wait_for_task(task)
|
conn.wait_for_task(task)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"host": host.name,
|
"host": host.name,
|
||||||
@ -153,13 +163,17 @@ class HostManagementMixin(VSphereMixin):
|
|||||||
description="List all services on the ESXi host",
|
description="List all services on the ESXi host",
|
||||||
annotations=ToolAnnotations(readOnlyHint=True),
|
annotations=ToolAnnotations(readOnlyHint=True),
|
||||||
)
|
)
|
||||||
def list_services(self) -> list[dict[str, Any]]:
|
def list_services(self, host: str | None = None) -> list[dict[str, Any]]:
|
||||||
"""List all services on the ESXi host.
|
"""List all services on the ESXi host.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
host: Managed ESXi host to target (default: the default host)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of service details
|
List of service details
|
||||||
"""
|
"""
|
||||||
host = self._get_host()
|
conn = self._conn(host)
|
||||||
|
host = self._get_host(conn)
|
||||||
service_system = host.configManager.serviceSystem
|
service_system = host.configManager.serviceSystem
|
||||||
|
|
||||||
services = []
|
services = []
|
||||||
@ -180,16 +194,18 @@ class HostManagementMixin(VSphereMixin):
|
|||||||
description="Start a service on the ESXi host",
|
description="Start a service on the ESXi host",
|
||||||
annotations=ToolAnnotations(destructiveHint=True),
|
annotations=ToolAnnotations(destructiveHint=True),
|
||||||
)
|
)
|
||||||
def start_service(self, service_key: str) -> dict[str, Any]:
|
def start_service(self, service_key: str, host: str | None = None) -> dict[str, Any]:
|
||||||
"""Start a service on the ESXi host.
|
"""Start a service on the ESXi host.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
service_key: Service key (e.g., 'TSM-SSH', 'ntpd', 'sfcbd')
|
service_key: Service key (e.g., 'TSM-SSH', 'ntpd', 'sfcbd')
|
||||||
|
host: Managed ESXi host to target (default: the default host)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict with operation result
|
Dict with operation result
|
||||||
"""
|
"""
|
||||||
host = self._get_host()
|
conn = self._conn(host)
|
||||||
|
host = self._get_host(conn)
|
||||||
service_system = host.configManager.serviceSystem
|
service_system = host.configManager.serviceSystem
|
||||||
|
|
||||||
# Verify service exists
|
# Verify service exists
|
||||||
@ -225,16 +241,18 @@ class HostManagementMixin(VSphereMixin):
|
|||||||
description="Stop a service on the ESXi host",
|
description="Stop a service on the ESXi host",
|
||||||
annotations=ToolAnnotations(destructiveHint=True),
|
annotations=ToolAnnotations(destructiveHint=True),
|
||||||
)
|
)
|
||||||
def stop_service(self, service_key: str) -> dict[str, Any]:
|
def stop_service(self, service_key: str, host: str | None = None) -> dict[str, Any]:
|
||||||
"""Stop a service on the ESXi host.
|
"""Stop a service on the ESXi host.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
service_key: Service key (e.g., 'TSM-SSH', 'ntpd')
|
service_key: Service key (e.g., 'TSM-SSH', 'ntpd')
|
||||||
|
host: Managed ESXi host to target (default: the default host)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict with operation result
|
Dict with operation result
|
||||||
"""
|
"""
|
||||||
host = self._get_host()
|
conn = self._conn(host)
|
||||||
|
host = self._get_host(conn)
|
||||||
service_system = host.configManager.serviceSystem
|
service_system = host.configManager.serviceSystem
|
||||||
|
|
||||||
# Verify service exists
|
# Verify service exists
|
||||||
@ -274,17 +292,20 @@ class HostManagementMixin(VSphereMixin):
|
|||||||
self,
|
self,
|
||||||
service_key: str,
|
service_key: str,
|
||||||
policy: str,
|
policy: str,
|
||||||
|
host: str | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Set the startup policy for a service.
|
"""Set the startup policy for a service.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
service_key: Service key (e.g., 'TSM-SSH', 'ntpd')
|
service_key: Service key (e.g., 'TSM-SSH', 'ntpd')
|
||||||
policy: Startup policy - 'on' (auto), 'off' (manual), 'automatic'
|
policy: Startup policy - 'on' (auto), 'off' (manual), 'automatic'
|
||||||
|
host: Managed ESXi host to target (default: the default host)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict with operation result
|
Dict with operation result
|
||||||
"""
|
"""
|
||||||
host = self._get_host()
|
conn = self._conn(host)
|
||||||
|
host = self._get_host(conn)
|
||||||
service_system = host.configManager.serviceSystem
|
service_system = host.configManager.serviceSystem
|
||||||
|
|
||||||
valid_policies = ["on", "off", "automatic"]
|
valid_policies = ["on", "off", "automatic"]
|
||||||
@ -318,13 +339,17 @@ class HostManagementMixin(VSphereMixin):
|
|||||||
description="Get NTP configuration for the ESXi host",
|
description="Get NTP configuration for the ESXi host",
|
||||||
annotations=ToolAnnotations(readOnlyHint=True),
|
annotations=ToolAnnotations(readOnlyHint=True),
|
||||||
)
|
)
|
||||||
def get_ntp_config(self) -> dict[str, Any]:
|
def get_ntp_config(self, host: str | None = None) -> dict[str, Any]:
|
||||||
"""Get NTP configuration for the ESXi host.
|
"""Get NTP configuration for the ESXi host.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
host: Managed ESXi host to target (default: the default host)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict with NTP configuration
|
Dict with NTP configuration
|
||||||
"""
|
"""
|
||||||
host = self._get_host()
|
conn = self._conn(host)
|
||||||
|
host = self._get_host(conn)
|
||||||
datetime_system = host.configManager.dateTimeSystem
|
datetime_system = host.configManager.dateTimeSystem
|
||||||
|
|
||||||
ntp_config = datetime_system.dateTimeInfo.ntpConfig
|
ntp_config = datetime_system.dateTimeInfo.ntpConfig
|
||||||
@ -357,17 +382,20 @@ class HostManagementMixin(VSphereMixin):
|
|||||||
self,
|
self,
|
||||||
ntp_servers: list[str],
|
ntp_servers: list[str],
|
||||||
start_service: bool = True,
|
start_service: bool = True,
|
||||||
|
host: str | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Configure NTP servers for the ESXi host.
|
"""Configure NTP servers for the ESXi host.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
ntp_servers: List of NTP server addresses
|
ntp_servers: List of NTP server addresses
|
||||||
start_service: Start ntpd service after configuring (default True)
|
start_service: Start ntpd service after configuring (default True)
|
||||||
|
host: Managed ESXi host to target (default: the default host)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict with configuration result
|
Dict with configuration result
|
||||||
"""
|
"""
|
||||||
host = self._get_host()
|
conn = self._conn(host)
|
||||||
|
host = self._get_host(conn)
|
||||||
datetime_system = host.configManager.dateTimeSystem
|
datetime_system = host.configManager.dateTimeSystem
|
||||||
|
|
||||||
# Create NTP config
|
# Create NTP config
|
||||||
@ -406,16 +434,18 @@ class HostManagementMixin(VSphereMixin):
|
|||||||
description="Reboot the ESXi host (requires maintenance mode)",
|
description="Reboot the ESXi host (requires maintenance mode)",
|
||||||
annotations=ToolAnnotations(destructiveHint=True),
|
annotations=ToolAnnotations(destructiveHint=True),
|
||||||
)
|
)
|
||||||
def reboot_host(self, force: bool = False) -> dict[str, Any]:
|
def reboot_host(self, force: bool = False, host: str | None = None) -> dict[str, Any]:
|
||||||
"""Reboot the ESXi host.
|
"""Reboot the ESXi host.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
force: Force reboot even if VMs are running (dangerous!)
|
force: Force reboot even if VMs are running (dangerous!)
|
||||||
|
host: Managed ESXi host to target (default: the default host)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict with operation result
|
Dict with operation result
|
||||||
"""
|
"""
|
||||||
host = self._get_host()
|
conn = self._conn(host)
|
||||||
|
host = self._get_host(conn)
|
||||||
|
|
||||||
if not host.runtime.inMaintenanceMode and not force:
|
if not host.runtime.inMaintenanceMode and not force:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
@ -438,16 +468,18 @@ class HostManagementMixin(VSphereMixin):
|
|||||||
description="Shutdown the ESXi host (requires maintenance mode)",
|
description="Shutdown the ESXi host (requires maintenance mode)",
|
||||||
annotations=ToolAnnotations(destructiveHint=True),
|
annotations=ToolAnnotations(destructiveHint=True),
|
||||||
)
|
)
|
||||||
def shutdown_host(self, force: bool = False) -> dict[str, Any]:
|
def shutdown_host(self, force: bool = False, host: str | None = None) -> dict[str, Any]:
|
||||||
"""Shutdown the ESXi host.
|
"""Shutdown the ESXi host.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
force: Force shutdown even if VMs are running (dangerous!)
|
force: Force shutdown even if VMs are running (dangerous!)
|
||||||
|
host: Managed ESXi host to target (default: the default host)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict with operation result
|
Dict with operation result
|
||||||
"""
|
"""
|
||||||
host = self._get_host()
|
conn = self._conn(host)
|
||||||
|
host = self._get_host(conn)
|
||||||
|
|
||||||
if not host.runtime.inMaintenanceMode and not force:
|
if not host.runtime.inMaintenanceMode and not force:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
@ -470,13 +502,17 @@ class HostManagementMixin(VSphereMixin):
|
|||||||
description="Get detailed hardware information for the ESXi host",
|
description="Get detailed hardware information for the ESXi host",
|
||||||
annotations=ToolAnnotations(readOnlyHint=True),
|
annotations=ToolAnnotations(readOnlyHint=True),
|
||||||
)
|
)
|
||||||
def get_host_hardware(self) -> dict[str, Any]:
|
def get_host_hardware(self, host: str | None = None) -> dict[str, Any]:
|
||||||
"""Get detailed hardware information.
|
"""Get detailed hardware information.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
host: Managed ESXi host to target (default: the default host)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict with hardware details
|
Dict with hardware details
|
||||||
"""
|
"""
|
||||||
host = self._get_host()
|
conn = self._conn(host)
|
||||||
|
host = self._get_host(conn)
|
||||||
hardware = host.hardware
|
hardware = host.hardware
|
||||||
|
|
||||||
# CPU info
|
# CPU info
|
||||||
@ -532,13 +568,17 @@ class HostManagementMixin(VSphereMixin):
|
|||||||
description="Get network configuration for the ESXi host",
|
description="Get network configuration for the ESXi host",
|
||||||
annotations=ToolAnnotations(readOnlyHint=True),
|
annotations=ToolAnnotations(readOnlyHint=True),
|
||||||
)
|
)
|
||||||
def get_host_networking(self) -> dict[str, Any]:
|
def get_host_networking(self, host: str | None = None) -> dict[str, Any]:
|
||||||
"""Get network configuration for the ESXi host.
|
"""Get network configuration for the ESXi host.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
host: Managed ESXi host to target (default: the default host)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict with networking details
|
Dict with networking details
|
||||||
"""
|
"""
|
||||||
host = self._get_host()
|
conn = self._conn(host)
|
||||||
|
host = self._get_host(conn)
|
||||||
network_config = host.config.network
|
network_config = host.config.network
|
||||||
|
|
||||||
# Virtual switches
|
# Virtual switches
|
||||||
|
|||||||
@ -21,9 +21,14 @@ class MonitoringMixin(VSphereMixin):
|
|||||||
description="Get current performance statistics for a virtual machine",
|
description="Get current performance statistics for a virtual machine",
|
||||||
annotations=ToolAnnotations(readOnlyHint=True),
|
annotations=ToolAnnotations(readOnlyHint=True),
|
||||||
)
|
)
|
||||||
def get_vm_stats(self, name: str) -> dict[str, Any]:
|
def get_vm_stats(self, name: str, host: str | None = None) -> dict[str, Any]:
|
||||||
"""Get VM performance statistics."""
|
"""Get VM performance statistics.
|
||||||
vm = self.conn.find_vm(name)
|
|
||||||
|
Args:
|
||||||
|
host: Managed ESXi host to target (default: the default host)
|
||||||
|
"""
|
||||||
|
conn = self._conn(host)
|
||||||
|
vm = conn.find_vm(name)
|
||||||
if not vm:
|
if not vm:
|
||||||
raise ValueError(f"VM '{name}' not found")
|
raise ValueError(f"VM '{name}' not found")
|
||||||
|
|
||||||
@ -58,19 +63,25 @@ class MonitoringMixin(VSphereMixin):
|
|||||||
description="Get performance statistics for an ESXi host",
|
description="Get performance statistics for an ESXi host",
|
||||||
annotations=ToolAnnotations(readOnlyHint=True),
|
annotations=ToolAnnotations(readOnlyHint=True),
|
||||||
)
|
)
|
||||||
def get_host_stats(self, host_name: str | None = None) -> dict[str, Any]:
|
def get_host_stats(
|
||||||
|
self, host_name: str | None = None, host: str | None = None
|
||||||
|
) -> dict[str, Any]:
|
||||||
"""Get ESXi host performance statistics.
|
"""Get ESXi host performance statistics.
|
||||||
|
|
||||||
If host_name is not provided, returns stats for the first host.
|
If host_name is not provided, returns stats for the first host.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
host: Managed ESXi host to target (default: the default host)
|
||||||
"""
|
"""
|
||||||
|
conn = self._conn(host)
|
||||||
if host_name:
|
if host_name:
|
||||||
host = self.conn.find_host(host_name)
|
host = conn.find_host(host_name)
|
||||||
if not host:
|
if not host:
|
||||||
raise ValueError(f"Host '{host_name}' not found")
|
raise ValueError(f"Host '{host_name}' not found")
|
||||||
else:
|
else:
|
||||||
# Get first host
|
# Get first host
|
||||||
container = self.conn.content.viewManager.CreateContainerView(
|
container = conn.content.viewManager.CreateContainerView(
|
||||||
self.conn.content.rootFolder, [vim.HostSystem], True
|
conn.content.rootFolder, [vim.HostSystem], True
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
hosts = list(container.view)
|
hosts = list(container.view)
|
||||||
@ -117,10 +128,15 @@ class MonitoringMixin(VSphereMixin):
|
|||||||
description="List all ESXi hosts in the datacenter",
|
description="List all ESXi hosts in the datacenter",
|
||||||
annotations=ToolAnnotations(readOnlyHint=True),
|
annotations=ToolAnnotations(readOnlyHint=True),
|
||||||
)
|
)
|
||||||
def list_hosts(self) -> list[dict[str, Any]]:
|
def list_hosts(self, host: str | None = None) -> list[dict[str, Any]]:
|
||||||
"""List all ESXi hosts with basic info."""
|
"""List all ESXi hosts with basic info.
|
||||||
container = self.conn.content.viewManager.CreateContainerView(
|
|
||||||
self.conn.content.rootFolder, [vim.HostSystem], True
|
Args:
|
||||||
|
host: Managed ESXi host to target (default: the default host)
|
||||||
|
"""
|
||||||
|
conn = self._conn(host)
|
||||||
|
container = conn.content.viewManager.CreateContainerView(
|
||||||
|
conn.content.rootFolder, [vim.HostSystem], True
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
hosts = []
|
hosts = []
|
||||||
@ -147,9 +163,16 @@ class MonitoringMixin(VSphereMixin):
|
|||||||
description="Get recent vSphere tasks (VM operations, etc.)",
|
description="Get recent vSphere tasks (VM operations, etc.)",
|
||||||
annotations=ToolAnnotations(readOnlyHint=True),
|
annotations=ToolAnnotations(readOnlyHint=True),
|
||||||
)
|
)
|
||||||
def get_recent_tasks(self, count: int = 20) -> list[dict[str, Any]]:
|
def get_recent_tasks(
|
||||||
"""Get recent vSphere tasks."""
|
self, count: int = 20, host: str | None = None
|
||||||
task_manager = self.conn.content.taskManager
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Get recent vSphere tasks.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
host: Managed ESXi host to target (default: the default host)
|
||||||
|
"""
|
||||||
|
conn = self._conn(host)
|
||||||
|
task_manager = conn.content.taskManager
|
||||||
recent_tasks = task_manager.recentTask[:count] if task_manager.recentTask else []
|
recent_tasks = task_manager.recentTask[:count] if task_manager.recentTask else []
|
||||||
|
|
||||||
tasks = []
|
tasks = []
|
||||||
@ -187,10 +210,15 @@ class MonitoringMixin(VSphereMixin):
|
|||||||
annotations=ToolAnnotations(readOnlyHint=True),
|
annotations=ToolAnnotations(readOnlyHint=True),
|
||||||
)
|
)
|
||||||
def get_recent_events(
|
def get_recent_events(
|
||||||
self, count: int = 50, hours: int = 24
|
self, count: int = 50, hours: int = 24, host: str | None = None
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
"""Get recent vSphere events."""
|
"""Get recent vSphere events.
|
||||||
event_manager = self.conn.content.eventManager
|
|
||||||
|
Args:
|
||||||
|
host: Managed ESXi host to target (default: the default host)
|
||||||
|
"""
|
||||||
|
conn = self._conn(host)
|
||||||
|
event_manager = conn.content.eventManager
|
||||||
|
|
||||||
# Create time filter
|
# Create time filter
|
||||||
time_filter = vim.event.EventFilterSpec.ByTime()
|
time_filter = vim.event.EventFilterSpec.ByTime()
|
||||||
@ -234,17 +262,22 @@ class MonitoringMixin(VSphereMixin):
|
|||||||
description="Get triggered alarms in the datacenter",
|
description="Get triggered alarms in the datacenter",
|
||||||
annotations=ToolAnnotations(readOnlyHint=True),
|
annotations=ToolAnnotations(readOnlyHint=True),
|
||||||
)
|
)
|
||||||
def get_alarms(self) -> list[dict[str, Any]]:
|
def get_alarms(self, host: str | None = None) -> list[dict[str, Any]]:
|
||||||
"""Get all triggered alarms."""
|
"""Get all triggered alarms.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
host: Managed ESXi host to target (default: the default host)
|
||||||
|
"""
|
||||||
|
conn = self._conn(host)
|
||||||
alarms = []
|
alarms = []
|
||||||
|
|
||||||
# Check datacenter alarms
|
# Check datacenter alarms
|
||||||
if self.conn.datacenter.triggeredAlarmState:
|
if conn.datacenter.triggeredAlarmState:
|
||||||
for alarm_state in self.conn.datacenter.triggeredAlarmState:
|
for alarm_state in conn.datacenter.triggeredAlarmState:
|
||||||
alarms.append(self._format_alarm(alarm_state))
|
alarms.append(self._format_alarm(alarm_state))
|
||||||
|
|
||||||
# Check VM alarms
|
# Check VM alarms
|
||||||
for vm in self.conn.get_all_vms():
|
for vm in conn.get_all_vms():
|
||||||
if vm.triggeredAlarmState:
|
if vm.triggeredAlarmState:
|
||||||
for alarm_state in vm.triggeredAlarmState:
|
for alarm_state in vm.triggeredAlarmState:
|
||||||
alarms.append(self._format_alarm(alarm_state, vm.name))
|
alarms.append(self._format_alarm(alarm_state, vm.name))
|
||||||
|
|||||||
@ -28,10 +28,10 @@ class NICManagementMixin(VSphereMixin):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
def _get_network_backing(
|
def _get_network_backing(
|
||||||
self, network_name: str
|
self, conn, network_name: str
|
||||||
) -> vim.vm.device.VirtualEthernetCard.NetworkBackingInfo:
|
) -> vim.vm.device.VirtualEthernetCard.NetworkBackingInfo:
|
||||||
"""Get the appropriate backing info for a network."""
|
"""Get the appropriate backing info for a network."""
|
||||||
network = self.conn.find_network(network_name)
|
network = conn.find_network(network_name)
|
||||||
if not network:
|
if not network:
|
||||||
raise ValueError(f"Network '{network_name}' not found")
|
raise ValueError(f"Network '{network_name}' not found")
|
||||||
|
|
||||||
@ -54,16 +54,18 @@ class NICManagementMixin(VSphereMixin):
|
|||||||
description="List all network adapters attached to a VM",
|
description="List all network adapters attached to a VM",
|
||||||
annotations=ToolAnnotations(readOnlyHint=True),
|
annotations=ToolAnnotations(readOnlyHint=True),
|
||||||
)
|
)
|
||||||
def list_nics(self, vm_name: str) -> list[dict[str, Any]]:
|
def list_nics(self, vm_name: str, host: str | None = None) -> list[dict[str, Any]]:
|
||||||
"""List all virtual network adapters on a VM.
|
"""List all virtual network adapters on a VM.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
vm_name: Name of the virtual machine
|
vm_name: Name of the virtual machine
|
||||||
|
host: Managed ESXi host to target (default: the default host)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of NIC details
|
List of NIC details
|
||||||
"""
|
"""
|
||||||
vm = self.conn.find_vm(vm_name)
|
conn = self._conn(host)
|
||||||
|
vm = conn.find_vm(vm_name)
|
||||||
if not vm:
|
if not vm:
|
||||||
raise ValueError(f"VM '{vm_name}' not found")
|
raise ValueError(f"VM '{vm_name}' not found")
|
||||||
|
|
||||||
@ -87,7 +89,7 @@ class NICManagementMixin(VSphereMixin):
|
|||||||
# For distributed switch, look up the portgroup name
|
# For distributed switch, look up the portgroup name
|
||||||
nic_info["network"] = f"DVS:{backing.port.portgroupKey}"
|
nic_info["network"] = f"DVS:{backing.port.portgroupKey}"
|
||||||
# Try to get actual name
|
# Try to get actual name
|
||||||
for net in self.conn.datacenter.networkFolder.childEntity:
|
for net in conn.datacenter.networkFolder.childEntity:
|
||||||
if hasattr(net, "key") and net.key == backing.port.portgroupKey:
|
if hasattr(net, "key") and net.key == backing.port.portgroupKey:
|
||||||
nic_info["network"] = net.name
|
nic_info["network"] = net.name
|
||||||
break
|
break
|
||||||
@ -107,6 +109,7 @@ class NICManagementMixin(VSphereMixin):
|
|||||||
network: str,
|
network: str,
|
||||||
nic_type: str = "vmxnet3",
|
nic_type: str = "vmxnet3",
|
||||||
start_connected: bool = True,
|
start_connected: bool = True,
|
||||||
|
host: str | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Add a new network adapter to a VM.
|
"""Add a new network adapter to a VM.
|
||||||
|
|
||||||
@ -115,11 +118,13 @@ class NICManagementMixin(VSphereMixin):
|
|||||||
network: Network/portgroup name to connect to
|
network: Network/portgroup name to connect to
|
||||||
nic_type: Adapter type - vmxnet3 (default), e1000, e1000e
|
nic_type: Adapter type - vmxnet3 (default), e1000, e1000e
|
||||||
start_connected: Connect adapter when VM powers on (default True)
|
start_connected: Connect adapter when VM powers on (default True)
|
||||||
|
host: Managed ESXi host to target (default: the default host)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict with new NIC details
|
Dict with new NIC details
|
||||||
"""
|
"""
|
||||||
vm = self.conn.find_vm(vm_name)
|
conn = self._conn(host)
|
||||||
|
vm = conn.find_vm(vm_name)
|
||||||
if not vm:
|
if not vm:
|
||||||
raise ValueError(f"VM '{vm_name}' not found")
|
raise ValueError(f"VM '{vm_name}' not found")
|
||||||
|
|
||||||
@ -137,7 +142,7 @@ class NICManagementMixin(VSphereMixin):
|
|||||||
|
|
||||||
# Create the NIC
|
# Create the NIC
|
||||||
nic = nic_class()
|
nic = nic_class()
|
||||||
nic.backing = self._get_network_backing(network)
|
nic.backing = self._get_network_backing(conn, network)
|
||||||
nic.connectable = vim.vm.device.VirtualDevice.ConnectInfo()
|
nic.connectable = vim.vm.device.VirtualDevice.ConnectInfo()
|
||||||
nic.connectable.startConnected = start_connected
|
nic.connectable.startConnected = start_connected
|
||||||
nic.connectable.connected = False # Can't connect until powered on
|
nic.connectable.connected = False # Can't connect until powered on
|
||||||
@ -155,7 +160,7 @@ class NICManagementMixin(VSphereMixin):
|
|||||||
|
|
||||||
# Reconfigure VM
|
# Reconfigure VM
|
||||||
task = vm.ReconfigVM_Task(spec=config_spec)
|
task = vm.ReconfigVM_Task(spec=config_spec)
|
||||||
self.conn.wait_for_task(task)
|
conn.wait_for_task(task)
|
||||||
|
|
||||||
# Get the MAC address that was assigned
|
# Get the MAC address that was assigned
|
||||||
vm.Reload()
|
vm.Reload()
|
||||||
@ -190,17 +195,20 @@ class NICManagementMixin(VSphereMixin):
|
|||||||
self,
|
self,
|
||||||
vm_name: str,
|
vm_name: str,
|
||||||
nic_label: str,
|
nic_label: str,
|
||||||
|
host: str | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Remove a network adapter from a VM.
|
"""Remove a network adapter from a VM.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
vm_name: Name of the virtual machine
|
vm_name: Name of the virtual machine
|
||||||
nic_label: Label of NIC to remove (e.g., 'Network adapter 1')
|
nic_label: Label of NIC to remove (e.g., 'Network adapter 1')
|
||||||
|
host: Managed ESXi host to target (default: the default host)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict with removal details
|
Dict with removal details
|
||||||
"""
|
"""
|
||||||
vm = self.conn.find_vm(vm_name)
|
conn = self._conn(host)
|
||||||
|
vm = conn.find_vm(vm_name)
|
||||||
if not vm:
|
if not vm:
|
||||||
raise ValueError(f"VM '{vm_name}' not found")
|
raise ValueError(f"VM '{vm_name}' not found")
|
||||||
|
|
||||||
@ -230,7 +238,7 @@ class NICManagementMixin(VSphereMixin):
|
|||||||
|
|
||||||
# Reconfigure VM
|
# Reconfigure VM
|
||||||
task = vm.ReconfigVM_Task(spec=config_spec)
|
task = vm.ReconfigVM_Task(spec=config_spec)
|
||||||
self.conn.wait_for_task(task)
|
conn.wait_for_task(task)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"vm": vm_name,
|
"vm": vm_name,
|
||||||
@ -250,6 +258,7 @@ class NICManagementMixin(VSphereMixin):
|
|||||||
vm_name: str,
|
vm_name: str,
|
||||||
nic_label: str,
|
nic_label: str,
|
||||||
new_network: str,
|
new_network: str,
|
||||||
|
host: str | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Change which network a NIC is connected to.
|
"""Change which network a NIC is connected to.
|
||||||
|
|
||||||
@ -257,11 +266,13 @@ class NICManagementMixin(VSphereMixin):
|
|||||||
vm_name: Name of the virtual machine
|
vm_name: Name of the virtual machine
|
||||||
nic_label: Label of NIC to modify (e.g., 'Network adapter 1')
|
nic_label: Label of NIC to modify (e.g., 'Network adapter 1')
|
||||||
new_network: New network/portgroup name
|
new_network: New network/portgroup name
|
||||||
|
host: Managed ESXi host to target (default: the default host)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict with change details
|
Dict with change details
|
||||||
"""
|
"""
|
||||||
vm = self.conn.find_vm(vm_name)
|
conn = self._conn(host)
|
||||||
|
vm = conn.find_vm(vm_name)
|
||||||
if not vm:
|
if not vm:
|
||||||
raise ValueError(f"VM '{vm_name}' not found")
|
raise ValueError(f"VM '{vm_name}' not found")
|
||||||
|
|
||||||
@ -280,7 +291,7 @@ class NICManagementMixin(VSphereMixin):
|
|||||||
old_network = nic.backing.deviceName
|
old_network = nic.backing.deviceName
|
||||||
|
|
||||||
# Update backing to new network
|
# Update backing to new network
|
||||||
nic.backing = self._get_network_backing(new_network)
|
nic.backing = self._get_network_backing(conn, new_network)
|
||||||
|
|
||||||
# Create device edit spec
|
# Create device edit spec
|
||||||
nic_spec = vim.vm.device.VirtualDeviceSpec()
|
nic_spec = vim.vm.device.VirtualDeviceSpec()
|
||||||
@ -293,7 +304,7 @@ class NICManagementMixin(VSphereMixin):
|
|||||||
|
|
||||||
# Reconfigure VM
|
# Reconfigure VM
|
||||||
task = vm.ReconfigVM_Task(spec=config_spec)
|
task = vm.ReconfigVM_Task(spec=config_spec)
|
||||||
self.conn.wait_for_task(task)
|
conn.wait_for_task(task)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"vm": vm_name,
|
"vm": vm_name,
|
||||||
@ -314,6 +325,7 @@ class NICManagementMixin(VSphereMixin):
|
|||||||
vm_name: str,
|
vm_name: str,
|
||||||
nic_label: str,
|
nic_label: str,
|
||||||
connected: bool = True,
|
connected: bool = True,
|
||||||
|
host: str | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Connect or disconnect a NIC on a running VM.
|
"""Connect or disconnect a NIC on a running VM.
|
||||||
|
|
||||||
@ -321,11 +333,13 @@ class NICManagementMixin(VSphereMixin):
|
|||||||
vm_name: Name of the virtual machine
|
vm_name: Name of the virtual machine
|
||||||
nic_label: Label of NIC (e.g., 'Network adapter 1')
|
nic_label: Label of NIC (e.g., 'Network adapter 1')
|
||||||
connected: True to connect, False to disconnect
|
connected: True to connect, False to disconnect
|
||||||
|
host: Managed ESXi host to target (default: the default host)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict with connection status
|
Dict with connection status
|
||||||
"""
|
"""
|
||||||
vm = self.conn.find_vm(vm_name)
|
conn = self._conn(host)
|
||||||
|
vm = conn.find_vm(vm_name)
|
||||||
if not vm:
|
if not vm:
|
||||||
raise ValueError(f"VM '{vm_name}' not found")
|
raise ValueError(f"VM '{vm_name}' not found")
|
||||||
|
|
||||||
@ -355,7 +369,7 @@ class NICManagementMixin(VSphereMixin):
|
|||||||
|
|
||||||
# Reconfigure VM
|
# Reconfigure VM
|
||||||
task = vm.ReconfigVM_Task(spec=config_spec)
|
task = vm.ReconfigVM_Task(spec=config_spec)
|
||||||
self.conn.wait_for_task(task)
|
conn.wait_for_task(task)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"vm": vm_name,
|
"vm": vm_name,
|
||||||
@ -374,6 +388,7 @@ class NICManagementMixin(VSphereMixin):
|
|||||||
vm_name: str,
|
vm_name: str,
|
||||||
nic_label: str,
|
nic_label: str,
|
||||||
mac_address: str,
|
mac_address: str,
|
||||||
|
host: str | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Set a custom MAC address for a NIC.
|
"""Set a custom MAC address for a NIC.
|
||||||
|
|
||||||
@ -381,11 +396,13 @@ class NICManagementMixin(VSphereMixin):
|
|||||||
vm_name: Name of the virtual machine
|
vm_name: Name of the virtual machine
|
||||||
nic_label: Label of NIC (e.g., 'Network adapter 1')
|
nic_label: Label of NIC (e.g., 'Network adapter 1')
|
||||||
mac_address: MAC address in format XX:XX:XX:XX:XX:XX
|
mac_address: MAC address in format XX:XX:XX:XX:XX:XX
|
||||||
|
host: Managed ESXi host to target (default: the default host)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict with MAC address change details
|
Dict with MAC address change details
|
||||||
"""
|
"""
|
||||||
vm = self.conn.find_vm(vm_name)
|
conn = self._conn(host)
|
||||||
|
vm = conn.find_vm(vm_name)
|
||||||
if not vm:
|
if not vm:
|
||||||
raise ValueError(f"VM '{vm_name}' not found")
|
raise ValueError(f"VM '{vm_name}' not found")
|
||||||
|
|
||||||
@ -420,7 +437,7 @@ class NICManagementMixin(VSphereMixin):
|
|||||||
|
|
||||||
# Reconfigure VM
|
# Reconfigure VM
|
||||||
task = vm.ReconfigVM_Task(spec=config_spec)
|
task = vm.ReconfigVM_Task(spec=config_spec)
|
||||||
self.conn.wait_for_task(task)
|
conn.wait_for_task(task)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"vm": vm_name,
|
"vm": vm_name,
|
||||||
|
|||||||
@ -56,11 +56,12 @@ class OVFManagementMixin(VSphereMixin):
|
|||||||
_lease: vim.HttpNfcLease,
|
_lease: vim.HttpNfcLease,
|
||||||
disk_path: str,
|
disk_path: str,
|
||||||
device_url: str,
|
device_url: str,
|
||||||
|
conn,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Upload a disk file via NFC lease."""
|
"""Upload a disk file via NFC lease."""
|
||||||
# Create SSL context
|
# Create SSL context
|
||||||
context = ssl.create_default_context()
|
context = ssl.create_default_context()
|
||||||
if self.conn.settings.vcenter_insecure:
|
if conn.settings.vcenter_insecure:
|
||||||
context.check_hostname = False
|
context.check_hostname = False
|
||||||
context.verify_mode = ssl.CERT_NONE
|
context.verify_mode = ssl.CERT_NONE
|
||||||
|
|
||||||
@ -74,8 +75,8 @@ class OVFManagementMixin(VSphereMixin):
|
|||||||
request.add_header("Connection", "Keep-Alive")
|
request.add_header("Connection", "Keep-Alive")
|
||||||
|
|
||||||
# Add session cookie
|
# Add session cookie
|
||||||
if hasattr(self.conn.service_instance, "_stub"):
|
if hasattr(conn.service_instance, "_stub"):
|
||||||
cookie = self.conn.service_instance._stub.cookie
|
cookie = conn.service_instance._stub.cookie
|
||||||
if cookie:
|
if cookie:
|
||||||
request.add_header("Cookie", cookie)
|
request.add_header("Cookie", cookie)
|
||||||
|
|
||||||
@ -96,6 +97,7 @@ class OVFManagementMixin(VSphereMixin):
|
|||||||
datastore: str,
|
datastore: str,
|
||||||
network: str | None = None,
|
network: str | None = None,
|
||||||
power_on: bool = False,
|
power_on: bool = False,
|
||||||
|
host: str | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Deploy a virtual machine from an OVF or OVA file.
|
"""Deploy a virtual machine from an OVF or OVA file.
|
||||||
|
|
||||||
@ -107,21 +109,23 @@ class OVFManagementMixin(VSphereMixin):
|
|||||||
datastore: Target datastore for VM files
|
datastore: Target datastore for VM files
|
||||||
network: Network to connect VM to (optional)
|
network: Network to connect VM to (optional)
|
||||||
power_on: Power on VM after deployment (default False)
|
power_on: Power on VM after deployment (default False)
|
||||||
|
host: Managed ESXi host to target (default: the default host)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict with deployment details
|
Dict with deployment details
|
||||||
"""
|
"""
|
||||||
|
conn = self._conn(host)
|
||||||
# Get OVF Manager
|
# Get OVF Manager
|
||||||
ovf_manager = self.conn.content.ovfManager
|
ovf_manager = conn.content.ovfManager
|
||||||
|
|
||||||
# Find target datastore
|
# Find target datastore
|
||||||
ds = self.conn.find_datastore(datastore)
|
ds = conn.find_datastore(datastore)
|
||||||
if not ds:
|
if not ds:
|
||||||
raise ValueError(f"Datastore '{datastore}' not found")
|
raise ValueError(f"Datastore '{datastore}' not found")
|
||||||
|
|
||||||
# Get resource pool and folder
|
# Get resource pool and folder
|
||||||
host = None
|
host = None
|
||||||
for h in self.conn.datacenter.hostFolder.childEntity:
|
for h in conn.datacenter.hostFolder.childEntity:
|
||||||
if hasattr(h, "host"):
|
if hasattr(h, "host"):
|
||||||
host = h.host[0] if h.host else None
|
host = h.host[0] if h.host else None
|
||||||
break
|
break
|
||||||
@ -139,7 +143,7 @@ class OVFManagementMixin(VSphereMixin):
|
|||||||
resource_pool = host.parent.resourcePool
|
resource_pool = host.parent.resourcePool
|
||||||
|
|
||||||
# Get VM folder
|
# Get VM folder
|
||||||
vm_folder = self.conn.datacenter.vmFolder
|
vm_folder = conn.datacenter.vmFolder
|
||||||
|
|
||||||
# Read OVF descriptor from datastore
|
# Read OVF descriptor from datastore
|
||||||
# For OVA, we need to extract first
|
# For OVA, we need to extract first
|
||||||
@ -154,7 +158,7 @@ class OVFManagementMixin(VSphereMixin):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Read OVF descriptor via datastore browser
|
# Read OVF descriptor via datastore browser
|
||||||
ovf_descriptor = self._read_datastore_file(datastore, ovf_path)
|
ovf_descriptor = self._read_datastore_file(datastore, ovf_path, conn)
|
||||||
|
|
||||||
# Create import spec params
|
# Create import spec params
|
||||||
import_spec_params = vim.OvfManager.CreateImportSpecParams(
|
import_spec_params = vim.OvfManager.CreateImportSpecParams(
|
||||||
@ -164,7 +168,7 @@ class OVFManagementMixin(VSphereMixin):
|
|||||||
|
|
||||||
# If network specified, add network mapping
|
# If network specified, add network mapping
|
||||||
if network:
|
if network:
|
||||||
net = self.conn.find_network(network)
|
net = conn.find_network(network)
|
||||||
if net:
|
if net:
|
||||||
network_mapping = vim.OvfManager.NetworkMapping(
|
network_mapping = vim.OvfManager.NetworkMapping(
|
||||||
name="VM Network", # Common default in OVF
|
name="VM Network", # Common default in OVF
|
||||||
@ -213,7 +217,7 @@ class OVFManagementMixin(VSphereMixin):
|
|||||||
lease.Complete()
|
lease.Complete()
|
||||||
|
|
||||||
# Find the newly created VM
|
# Find the newly created VM
|
||||||
vm = self.conn.find_vm(vm_name)
|
vm = conn.find_vm(vm_name)
|
||||||
|
|
||||||
result = {
|
result = {
|
||||||
"vm": vm_name,
|
"vm": vm_name,
|
||||||
@ -226,35 +230,35 @@ class OVFManagementMixin(VSphereMixin):
|
|||||||
result["uuid"] = vm.config.uuid
|
result["uuid"] = vm.config.uuid
|
||||||
if power_on:
|
if power_on:
|
||||||
task = vm.PowerOnVM_Task()
|
task = vm.PowerOnVM_Task()
|
||||||
self.conn.wait_for_task(task)
|
conn.wait_for_task(task)
|
||||||
result["power_state"] = "poweredOn"
|
result["power_state"] = "poweredOn"
|
||||||
else:
|
else:
|
||||||
result["power_state"] = "poweredOff"
|
result["power_state"] = "poweredOff"
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def _read_datastore_file(self, datastore: str, path: str) -> str:
|
def _read_datastore_file(self, datastore: str, path: str, conn) -> str:
|
||||||
"""Read a text file from datastore."""
|
"""Read a text file from datastore."""
|
||||||
ds = self.conn.find_datastore(datastore)
|
ds = conn.find_datastore(datastore)
|
||||||
if not ds:
|
if not ds:
|
||||||
raise ValueError(f"Datastore '{datastore}' not found")
|
raise ValueError(f"Datastore '{datastore}' not found")
|
||||||
|
|
||||||
# Build HTTP URL
|
# Build HTTP URL
|
||||||
dc_name = self.conn.datacenter.name
|
dc_name = conn.datacenter.name
|
||||||
url = (
|
url = (
|
||||||
f"https://{self.conn.settings.vcenter_host}/folder/{path}"
|
f"https://{conn.settings.vcenter_host}/folder/{path}"
|
||||||
f"?dcPath={dc_name}&dsName={datastore}"
|
f"?dcPath={dc_name}&dsName={datastore}"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Setup request
|
# Setup request
|
||||||
context = ssl.create_default_context()
|
context = ssl.create_default_context()
|
||||||
if self.conn.settings.vcenter_insecure:
|
if conn.settings.vcenter_insecure:
|
||||||
context.check_hostname = False
|
context.check_hostname = False
|
||||||
context.verify_mode = ssl.CERT_NONE
|
context.verify_mode = ssl.CERT_NONE
|
||||||
|
|
||||||
request = urllib.request.Request(url)
|
request = urllib.request.Request(url)
|
||||||
if hasattr(self.conn.service_instance, "_stub"):
|
if hasattr(conn.service_instance, "_stub"):
|
||||||
cookie = self.conn.service_instance._stub.cookie
|
cookie = conn.service_instance._stub.cookie
|
||||||
if cookie:
|
if cookie:
|
||||||
request.add_header("Cookie", cookie)
|
request.add_header("Cookie", cookie)
|
||||||
|
|
||||||
@ -269,11 +273,11 @@ class OVFManagementMixin(VSphereMixin):
|
|||||||
# For now, document this limitation
|
# For now, document this limitation
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def _resolve_ova_file(self, ova_path: str) -> tuple[str, bool]:
|
def _resolve_ova_file(self, ova_path: str, conn) -> tuple[str, bool]:
|
||||||
"""Return (local_path, is_temp); downloads the OVA first if a URL is given."""
|
"""Return (local_path, is_temp); downloads the OVA first if a URL is given."""
|
||||||
if ova_path.startswith(("http://", "https://")):
|
if ova_path.startswith(("http://", "https://")):
|
||||||
context = ssl.create_default_context()
|
context = ssl.create_default_context()
|
||||||
if self.conn.settings.vcenter_insecure:
|
if conn.settings.vcenter_insecure:
|
||||||
context.check_hostname = False
|
context.check_hostname = False
|
||||||
context.verify_mode = ssl.CERT_NONE
|
context.verify_mode = ssl.CERT_NONE
|
||||||
fd, tmp = tempfile.mkstemp(suffix=".ova", prefix="ova_dl_")
|
fd, tmp = tempfile.mkstemp(suffix=".ova", prefix="ova_dl_")
|
||||||
@ -301,7 +305,9 @@ class OVFManagementMixin(VSphereMixin):
|
|||||||
names.append(val)
|
names.append(val)
|
||||||
return names
|
return names
|
||||||
|
|
||||||
def _put_stream_to_lease(self, device_url: str, fileobj: Any, size: int) -> None:
|
def _put_stream_to_lease(
|
||||||
|
self, device_url: str, fileobj: Any, size: int, conn
|
||||||
|
) -> None:
|
||||||
"""Stream a file object to an NFC lease device URL via chunked HTTP PUT."""
|
"""Stream a file object to an NFC lease device URL via chunked HTTP PUT."""
|
||||||
parsed = urlparse(device_url)
|
parsed = urlparse(device_url)
|
||||||
host = parsed.hostname
|
host = parsed.hostname
|
||||||
@ -311,43 +317,47 @@ class OVFManagementMixin(VSphereMixin):
|
|||||||
if (
|
if (
|
||||||
not host
|
not host
|
||||||
or host == "*"
|
or host == "*"
|
||||||
or self.conn.content.about.apiType == "HostAgent"
|
or conn.content.about.apiType == "HostAgent"
|
||||||
):
|
):
|
||||||
host = self.conn.settings.vcenter_host
|
host = conn.settings.vcenter_host
|
||||||
port = parsed.port or 443
|
port = parsed.port or 443
|
||||||
path = parsed.path + (f"?{parsed.query}" if parsed.query else "")
|
path = parsed.path + (f"?{parsed.query}" if parsed.query else "")
|
||||||
|
|
||||||
context = ssl.create_default_context()
|
context = ssl.create_default_context()
|
||||||
if self.conn.settings.vcenter_insecure:
|
if conn.settings.vcenter_insecure:
|
||||||
context.check_hostname = False
|
context.check_hostname = False
|
||||||
context.verify_mode = ssl.CERT_NONE
|
context.verify_mode = ssl.CERT_NONE
|
||||||
|
|
||||||
conn = http.client.HTTPSConnection(host, port, context=context, timeout=900)
|
http_conn = http.client.HTTPSConnection(
|
||||||
|
host, port, context=context, timeout=900
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
conn.putrequest("PUT", path, skip_host=True, skip_accept_encoding=True)
|
http_conn.putrequest(
|
||||||
conn.putheader("Host", host)
|
"PUT", path, skip_host=True, skip_accept_encoding=True
|
||||||
conn.putheader("Content-Length", str(size))
|
)
|
||||||
conn.putheader("Content-Type", "application/x-vnd.vmware-streamVmdk")
|
http_conn.putheader("Host", host)
|
||||||
|
http_conn.putheader("Content-Length", str(size))
|
||||||
|
http_conn.putheader("Content-Type", "application/x-vnd.vmware-streamVmdk")
|
||||||
# ImportVApp pre-creates the disk file, so the NFC stream must
|
# ImportVApp pre-creates the disk file, so the NFC stream must
|
||||||
# explicitly overwrite it (else ESXi returns 403 "File exists").
|
# explicitly overwrite it (else ESXi returns 403 "File exists").
|
||||||
conn.putheader("Overwrite", "t")
|
http_conn.putheader("Overwrite", "t")
|
||||||
cookie = getattr(self.conn.si._stub, "cookie", None)
|
cookie = getattr(conn.si._stub, "cookie", None)
|
||||||
if cookie:
|
if cookie:
|
||||||
conn.putheader("Cookie", cookie)
|
http_conn.putheader("Cookie", cookie)
|
||||||
conn.endheaders()
|
http_conn.endheaders()
|
||||||
while True:
|
while True:
|
||||||
chunk = fileobj.read(1024 * 1024)
|
chunk = fileobj.read(1024 * 1024)
|
||||||
if not chunk:
|
if not chunk:
|
||||||
break
|
break
|
||||||
conn.send(chunk)
|
http_conn.send(chunk)
|
||||||
resp = conn.getresponse()
|
resp = http_conn.getresponse()
|
||||||
resp.read()
|
resp.read()
|
||||||
if resp.status not in (200, 201):
|
if resp.status not in (200, 201):
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"Disk upload failed: HTTP {resp.status} {resp.reason}"
|
f"Disk upload failed: HTTP {resp.status} {resp.reason}"
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
http_conn.close()
|
||||||
|
|
||||||
@mcp_tool(
|
@mcp_tool(
|
||||||
name="deploy_ova",
|
name="deploy_ova",
|
||||||
@ -366,6 +376,7 @@ class OVFManagementMixin(VSphereMixin):
|
|||||||
iso_path: str | None = None,
|
iso_path: str | None = None,
|
||||||
iso_datastore: str | None = None,
|
iso_datastore: str | None = None,
|
||||||
boot_from_iso: bool = True,
|
boot_from_iso: bool = True,
|
||||||
|
host: str | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Deploy a virtual machine from an OVA file.
|
"""Deploy a virtual machine from an OVA file.
|
||||||
|
|
||||||
@ -389,25 +400,27 @@ class OVFManagementMixin(VSphereMixin):
|
|||||||
a bootable ISO). A CD/DVD drive is added automatically.
|
a bootable ISO). A CD/DVD drive is added automatically.
|
||||||
iso_datastore: Datastore holding the ISO (default: the VM's datastore)
|
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
|
boot_from_iso: When an ISO is given, put the CD/DVD first in boot order
|
||||||
|
host: Managed ESXi host to target (default: the default host)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict with deployment details
|
Dict with deployment details
|
||||||
"""
|
"""
|
||||||
ds = self.conn.find_datastore(datastore) if datastore else self.conn.datastore
|
conn = self._conn(host)
|
||||||
|
ds = conn.find_datastore(datastore) if datastore else conn.datastore
|
||||||
if not ds:
|
if not ds:
|
||||||
raise ValueError(f"Datastore '{datastore}' not found")
|
raise ValueError(f"Datastore '{datastore}' not found")
|
||||||
resource_pool = self.conn.resource_pool
|
resource_pool = conn.resource_pool
|
||||||
vm_folder = self.conn.datacenter.vmFolder
|
vm_folder = conn.datacenter.vmFolder
|
||||||
|
|
||||||
# An ESXi host is required as the import target
|
# An ESXi host is required as the import target
|
||||||
view = self.conn.content.viewManager.CreateContainerView(
|
view = conn.content.viewManager.CreateContainerView(
|
||||||
self.conn.content.rootFolder, [vim.HostSystem], True
|
conn.content.rootFolder, [vim.HostSystem], True
|
||||||
)
|
)
|
||||||
hosts = list(view.view)
|
hosts = list(view.view)
|
||||||
view.Destroy()
|
view.Destroy()
|
||||||
host_system = hosts[0] if hosts else None
|
host_system = hosts[0] if hosts else None
|
||||||
|
|
||||||
local_ova, is_temp = self._resolve_ova_file(ova_path)
|
local_ova, is_temp = self._resolve_ova_file(ova_path, conn)
|
||||||
import_spec = None
|
import_spec = None
|
||||||
try:
|
try:
|
||||||
with tarfile.open(local_ova) as tar:
|
with tarfile.open(local_ova) as tar:
|
||||||
@ -427,7 +440,7 @@ class OVFManagementMixin(VSphereMixin):
|
|||||||
if deployment_option:
|
if deployment_option:
|
||||||
spec_params.deploymentOption = deployment_option
|
spec_params.deploymentOption = deployment_option
|
||||||
if network:
|
if network:
|
||||||
net = self.conn.find_network(network)
|
net = conn.find_network(network)
|
||||||
if not net:
|
if not net:
|
||||||
raise ValueError(f"Network '{network}' not found")
|
raise ValueError(f"Network '{network}' not found")
|
||||||
ovf_nets = self._parse_ovf_network_names(ovf_xml) or ["VM Network"]
|
ovf_nets = self._parse_ovf_network_names(ovf_xml) or ["VM Network"]
|
||||||
@ -436,7 +449,7 @@ class OVFManagementMixin(VSphereMixin):
|
|||||||
for n in ovf_nets
|
for n in ovf_nets
|
||||||
]
|
]
|
||||||
|
|
||||||
ovf_manager = self.conn.content.ovfManager
|
ovf_manager = conn.content.ovfManager
|
||||||
import_spec = ovf_manager.CreateImportSpec(
|
import_spec = ovf_manager.CreateImportSpec(
|
||||||
ovfDescriptor=ovf_xml,
|
ovfDescriptor=ovf_xml,
|
||||||
resourcePool=resource_pool,
|
resourcePool=resource_pool,
|
||||||
@ -485,7 +498,9 @@ class OVFManagementMixin(VSphereMixin):
|
|||||||
if not member:
|
if not member:
|
||||||
raise ValueError(f"Disk '{fi.path}' missing from OVA")
|
raise ValueError(f"Disk '{fi.path}' missing from OVA")
|
||||||
src = tar.extractfile(member)
|
src = tar.extractfile(member)
|
||||||
self._put_stream_to_lease(device_url, src, member.size)
|
self._put_stream_to_lease(
|
||||||
|
device_url, src, member.size, conn
|
||||||
|
)
|
||||||
uploaded[0] += member.size
|
uploaded[0] += member.size
|
||||||
lease.HttpNfcLeaseProgress(
|
lease.HttpNfcLeaseProgress(
|
||||||
min(99, int(uploaded[0] * 100 / total))
|
min(99, int(uploaded[0] * 100 / total))
|
||||||
@ -502,7 +517,7 @@ class OVFManagementMixin(VSphereMixin):
|
|||||||
if is_temp:
|
if is_temp:
|
||||||
Path(local_ova).unlink(missing_ok=True)
|
Path(local_ova).unlink(missing_ok=True)
|
||||||
|
|
||||||
vm = self.conn.find_vm(vm_name)
|
vm = conn.find_vm(vm_name)
|
||||||
result: dict[str, Any] = {
|
result: dict[str, Any] = {
|
||||||
"vm": vm_name,
|
"vm": vm_name,
|
||||||
"action": "ova_deployed",
|
"action": "ova_deployed",
|
||||||
@ -514,11 +529,11 @@ class OVFManagementMixin(VSphereMixin):
|
|||||||
result["uuid"] = vm.config.uuid
|
result["uuid"] = vm.config.uuid
|
||||||
if iso_path:
|
if iso_path:
|
||||||
result["iso"] = self._add_cdrom_with_iso(
|
result["iso"] = self._add_cdrom_with_iso(
|
||||||
vm, iso_path, iso_datastore, boot_from_iso
|
vm, iso_path, iso_datastore, boot_from_iso, conn
|
||||||
)
|
)
|
||||||
if power_on:
|
if power_on:
|
||||||
task = vm.PowerOnVM_Task()
|
task = vm.PowerOnVM_Task()
|
||||||
self.conn.wait_for_task(task)
|
conn.wait_for_task(task)
|
||||||
result["power_state"] = "poweredOn"
|
result["power_state"] = "poweredOn"
|
||||||
else:
|
else:
|
||||||
result["power_state"] = "poweredOff"
|
result["power_state"] = "poweredOff"
|
||||||
@ -530,6 +545,7 @@ class OVFManagementMixin(VSphereMixin):
|
|||||||
iso_path: str,
|
iso_path: str,
|
||||||
iso_datastore: str | None,
|
iso_datastore: str | None,
|
||||||
boot_from_iso: bool,
|
boot_from_iso: bool,
|
||||||
|
conn,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Mount an ISO in the VM's CD/DVD drive (reusing the first existing
|
"""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.
|
drive, or adding one if none exist) and optionally boot from it.
|
||||||
@ -601,7 +617,7 @@ class OVFManagementMixin(VSphereMixin):
|
|||||||
bootOrder=[vim.vm.BootOptions.BootableCdromDevice()]
|
bootOrder=[vim.vm.BootOptions.BootableCdromDevice()]
|
||||||
)
|
)
|
||||||
task = vm.ReconfigVM_Task(spec=config_spec)
|
task = vm.ReconfigVM_Task(spec=config_spec)
|
||||||
self.conn.wait_for_task(task)
|
conn.wait_for_task(task)
|
||||||
return mounted
|
return mounted
|
||||||
|
|
||||||
@mcp_tool(
|
@mcp_tool(
|
||||||
@ -609,7 +625,7 @@ class OVFManagementMixin(VSphereMixin):
|
|||||||
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",
|
||||||
annotations=ToolAnnotations(readOnlyHint=True),
|
annotations=ToolAnnotations(readOnlyHint=True),
|
||||||
)
|
)
|
||||||
def inspect_ova(self, ova_path: str) -> dict[str, Any]:
|
def inspect_ova(self, ova_path: str, host: str | None = None) -> dict[str, Any]:
|
||||||
"""Inspect an OVA without deploying it.
|
"""Inspect an OVA without deploying it.
|
||||||
|
|
||||||
Reads the OVF descriptor and reports the product, guest OS, hardware
|
Reads the OVF descriptor and reports the product, guest OS, hardware
|
||||||
@ -619,11 +635,13 @@ class OVFManagementMixin(VSphereMixin):
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
ova_path: Local path to the .ova file, or an http(s) URL
|
ova_path: Local path to the .ova file, or an http(s) URL
|
||||||
|
host: Managed ESXi host to target (default: the default host)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict describing the OVA
|
Dict describing the OVA
|
||||||
"""
|
"""
|
||||||
local_ova, is_temp = self._resolve_ova_file(ova_path)
|
conn = self._conn(host)
|
||||||
|
local_ova, is_temp = self._resolve_ova_file(ova_path, conn)
|
||||||
try:
|
try:
|
||||||
with tarfile.open(local_ova) as tar:
|
with tarfile.open(local_ova) as tar:
|
||||||
ovf_member = next(
|
ovf_member = next(
|
||||||
@ -711,6 +729,7 @@ class OVFManagementMixin(VSphereMixin):
|
|||||||
vm_name: str,
|
vm_name: str,
|
||||||
target_path: str,
|
target_path: str,
|
||||||
datastore: str | None = None,
|
datastore: str | None = None,
|
||||||
|
host: str | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Export a virtual machine to OVF format.
|
"""Export a virtual machine to OVF format.
|
||||||
|
|
||||||
@ -718,11 +737,13 @@ class OVFManagementMixin(VSphereMixin):
|
|||||||
vm_name: Name of the VM to export
|
vm_name: Name of the VM to export
|
||||||
target_path: Target directory path on datastore
|
target_path: Target directory path on datastore
|
||||||
datastore: Target datastore (default: VM's datastore)
|
datastore: Target datastore (default: VM's datastore)
|
||||||
|
host: Managed ESXi host to target (default: the default host)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict with export details
|
Dict with export details
|
||||||
"""
|
"""
|
||||||
vm = self.conn.find_vm(vm_name)
|
conn = self._conn(host)
|
||||||
|
vm = conn.find_vm(vm_name)
|
||||||
if not vm:
|
if not vm:
|
||||||
raise ValueError(f"VM '{vm_name}' not found")
|
raise ValueError(f"VM '{vm_name}' not found")
|
||||||
|
|
||||||
@ -732,7 +753,7 @@ class OVFManagementMixin(VSphereMixin):
|
|||||||
|
|
||||||
# Determine target datastore
|
# Determine target datastore
|
||||||
if datastore:
|
if datastore:
|
||||||
ds = self.conn.find_datastore(datastore)
|
ds = conn.find_datastore(datastore)
|
||||||
if not ds:
|
if not ds:
|
||||||
raise ValueError(f"Datastore '{datastore}' not found")
|
raise ValueError(f"Datastore '{datastore}' not found")
|
||||||
ds_name = datastore
|
ds_name = datastore
|
||||||
@ -750,7 +771,7 @@ class OVFManagementMixin(VSphereMixin):
|
|||||||
raise ValueError(f"Export lease error: {lease.error}")
|
raise ValueError(f"Export lease error: {lease.error}")
|
||||||
|
|
||||||
# Get OVF descriptor
|
# Get OVF descriptor
|
||||||
ovf_manager = self.conn.content.ovfManager
|
ovf_manager = conn.content.ovfManager
|
||||||
ovf_descriptor = ovf_manager.CreateDescriptor(
|
ovf_descriptor = ovf_manager.CreateDescriptor(
|
||||||
obj=vm,
|
obj=vm,
|
||||||
cdp=vim.OvfManager.CreateDescriptorParams(
|
cdp=vim.OvfManager.CreateDescriptorParams(
|
||||||
@ -784,7 +805,9 @@ class OVFManagementMixin(VSphereMixin):
|
|||||||
ovf_output_path = f"{target_path}/{ovf_filename}"
|
ovf_output_path = f"{target_path}/{ovf_filename}"
|
||||||
|
|
||||||
# Upload OVF descriptor to datastore
|
# Upload OVF descriptor to datastore
|
||||||
self._write_datastore_file(ds_name, ovf_output_path, ovf_descriptor.ovfDescriptor)
|
self._write_datastore_file(
|
||||||
|
ds_name, ovf_output_path, ovf_descriptor.ovfDescriptor, conn
|
||||||
|
)
|
||||||
|
|
||||||
exported_files.append(ovf_output_path)
|
exported_files.append(ovf_output_path)
|
||||||
|
|
||||||
@ -800,17 +823,19 @@ class OVFManagementMixin(VSphereMixin):
|
|||||||
"ovf_descriptor": ovf_filename,
|
"ovf_descriptor": ovf_filename,
|
||||||
}
|
}
|
||||||
|
|
||||||
def _write_datastore_file(self, datastore: str, path: str, content: str) -> None:
|
def _write_datastore_file(
|
||||||
|
self, datastore: str, path: str, content: str, conn
|
||||||
|
) -> None:
|
||||||
"""Write a text file to datastore."""
|
"""Write a text file to datastore."""
|
||||||
dc_name = self.conn.datacenter.name
|
dc_name = conn.datacenter.name
|
||||||
url = (
|
url = (
|
||||||
f"https://{self.conn.settings.vcenter_host}/folder/{path}"
|
f"https://{conn.settings.vcenter_host}/folder/{path}"
|
||||||
f"?dcPath={dc_name}&dsName={datastore}"
|
f"?dcPath={dc_name}&dsName={datastore}"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Setup request
|
# Setup request
|
||||||
context = ssl.create_default_context()
|
context = ssl.create_default_context()
|
||||||
if self.conn.settings.vcenter_insecure:
|
if conn.settings.vcenter_insecure:
|
||||||
context.check_hostname = False
|
context.check_hostname = False
|
||||||
context.verify_mode = ssl.CERT_NONE
|
context.verify_mode = ssl.CERT_NONE
|
||||||
|
|
||||||
@ -819,8 +844,8 @@ class OVFManagementMixin(VSphereMixin):
|
|||||||
request.add_header("Content-Type", "application/xml")
|
request.add_header("Content-Type", "application/xml")
|
||||||
request.add_header("Content-Length", str(len(data)))
|
request.add_header("Content-Length", str(len(data)))
|
||||||
|
|
||||||
if hasattr(self.conn.service_instance, "_stub"):
|
if hasattr(conn.service_instance, "_stub"):
|
||||||
cookie = self.conn.service_instance._stub.cookie
|
cookie = conn.service_instance._stub.cookie
|
||||||
if cookie:
|
if cookie:
|
||||||
request.add_header("Cookie", cookie)
|
request.add_header("Cookie", cookie)
|
||||||
|
|
||||||
@ -835,25 +860,28 @@ class OVFManagementMixin(VSphereMixin):
|
|||||||
self,
|
self,
|
||||||
ovf_path: str,
|
ovf_path: str,
|
||||||
datastore: str,
|
datastore: str,
|
||||||
|
host: str | None = None,
|
||||||
) -> list[dict[str, str]]:
|
) -> list[dict[str, str]]:
|
||||||
"""List networks defined in an OVF descriptor.
|
"""List networks defined in an OVF descriptor.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
ovf_path: Path to OVF file on datastore
|
ovf_path: Path to OVF file on datastore
|
||||||
datastore: Datastore containing the OVF
|
datastore: Datastore containing the OVF
|
||||||
|
host: Managed ESXi host to target (default: the default host)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of network definitions
|
List of network definitions
|
||||||
"""
|
"""
|
||||||
|
conn = self._conn(host)
|
||||||
# Read OVF descriptor
|
# Read OVF descriptor
|
||||||
ovf_descriptor = self._read_datastore_file(datastore, ovf_path)
|
ovf_descriptor = self._read_datastore_file(datastore, ovf_path, conn)
|
||||||
|
|
||||||
# Parse network references
|
# Parse network references
|
||||||
ovf_manager = self.conn.content.ovfManager
|
ovf_manager = conn.content.ovfManager
|
||||||
|
|
||||||
# Get resource pool for parsing
|
# Get resource pool for parsing
|
||||||
host = None
|
host = None
|
||||||
for h in self.conn.datacenter.hostFolder.childEntity:
|
for h in conn.datacenter.hostFolder.childEntity:
|
||||||
if hasattr(h, "host"):
|
if hasattr(h, "host"):
|
||||||
host = h.host[0] if h.host else None
|
host = h.host[0] if h.host else None
|
||||||
break
|
break
|
||||||
@ -862,7 +890,7 @@ class OVFManagementMixin(VSphereMixin):
|
|||||||
raise ValueError("No ESXi host found")
|
raise ValueError("No ESXi host found")
|
||||||
|
|
||||||
resource_pool = host.parent.resourcePool if hasattr(host, "parent") else None
|
resource_pool = host.parent.resourcePool if hasattr(host, "parent") else None
|
||||||
ds = self.conn.find_datastore(datastore)
|
ds = conn.find_datastore(datastore)
|
||||||
|
|
||||||
# Create parse params to extract network info
|
# Create parse params to extract network info
|
||||||
import_spec_params = vim.OvfManager.CreateImportSpecParams()
|
import_spec_params = vim.OvfManager.CreateImportSpecParams()
|
||||||
|
|||||||
@ -20,9 +20,10 @@ class PowerOpsMixin(VSphereMixin):
|
|||||||
description="Power on a virtual machine",
|
description="Power on a virtual machine",
|
||||||
annotations=ToolAnnotations(destructiveHint=False, idempotentHint=True),
|
annotations=ToolAnnotations(destructiveHint=False, idempotentHint=True),
|
||||||
)
|
)
|
||||||
def power_on(self, name: str) -> str:
|
def power_on(self, name: str, host: str | None = None) -> str:
|
||||||
"""Power on a virtual machine."""
|
"""Power on a virtual machine."""
|
||||||
vm = self.conn.find_vm(name)
|
conn = self._conn(host)
|
||||||
|
vm = conn.find_vm(name)
|
||||||
if not vm:
|
if not vm:
|
||||||
raise ValueError(f"VM '{name}' not found")
|
raise ValueError(f"VM '{name}' not found")
|
||||||
|
|
||||||
@ -30,7 +31,7 @@ class PowerOpsMixin(VSphereMixin):
|
|||||||
return f"VM '{name}' is already powered on"
|
return f"VM '{name}' is already powered on"
|
||||||
|
|
||||||
task = vm.PowerOnVM_Task()
|
task = vm.PowerOnVM_Task()
|
||||||
self.conn.wait_for_task(task)
|
conn.wait_for_task(task)
|
||||||
|
|
||||||
return f"VM '{name}' powered on"
|
return f"VM '{name}' powered on"
|
||||||
|
|
||||||
@ -39,9 +40,10 @@ class PowerOpsMixin(VSphereMixin):
|
|||||||
description="Power off a virtual machine (hard shutdown, like pulling the power cord)",
|
description="Power off a virtual machine (hard shutdown, like pulling the power cord)",
|
||||||
annotations=ToolAnnotations(destructiveHint=True, idempotentHint=True),
|
annotations=ToolAnnotations(destructiveHint=True, idempotentHint=True),
|
||||||
)
|
)
|
||||||
def power_off(self, name: str) -> str:
|
def power_off(self, name: str, host: str | None = None) -> str:
|
||||||
"""Power off a virtual machine (hard shutdown)."""
|
"""Power off a virtual machine (hard shutdown)."""
|
||||||
vm = self.conn.find_vm(name)
|
conn = self._conn(host)
|
||||||
|
vm = conn.find_vm(name)
|
||||||
if not vm:
|
if not vm:
|
||||||
raise ValueError(f"VM '{name}' not found")
|
raise ValueError(f"VM '{name}' not found")
|
||||||
|
|
||||||
@ -49,7 +51,7 @@ class PowerOpsMixin(VSphereMixin):
|
|||||||
return f"VM '{name}' is already powered off"
|
return f"VM '{name}' is already powered off"
|
||||||
|
|
||||||
task = vm.PowerOffVM_Task()
|
task = vm.PowerOffVM_Task()
|
||||||
self.conn.wait_for_task(task)
|
conn.wait_for_task(task)
|
||||||
|
|
||||||
return f"VM '{name}' powered off"
|
return f"VM '{name}' powered off"
|
||||||
|
|
||||||
@ -58,9 +60,10 @@ class PowerOpsMixin(VSphereMixin):
|
|||||||
description="Gracefully shutdown the guest OS (requires VMware Tools installed and running)",
|
description="Gracefully shutdown the guest OS (requires VMware Tools installed and running)",
|
||||||
annotations=ToolAnnotations(destructiveHint=True, idempotentHint=True),
|
annotations=ToolAnnotations(destructiveHint=True, idempotentHint=True),
|
||||||
)
|
)
|
||||||
def shutdown_guest(self, name: str) -> str:
|
def shutdown_guest(self, name: str, host: str | None = None) -> str:
|
||||||
"""Gracefully shutdown the guest OS."""
|
"""Gracefully shutdown the guest OS."""
|
||||||
vm = self.conn.find_vm(name)
|
conn = self._conn(host)
|
||||||
|
vm = conn.find_vm(name)
|
||||||
if not vm:
|
if not vm:
|
||||||
raise ValueError(f"VM '{name}' not found")
|
raise ValueError(f"VM '{name}' not found")
|
||||||
|
|
||||||
@ -81,9 +84,10 @@ class PowerOpsMixin(VSphereMixin):
|
|||||||
description="Gracefully reboot the guest OS (requires VMware Tools)",
|
description="Gracefully reboot the guest OS (requires VMware Tools)",
|
||||||
annotations=ToolAnnotations(destructiveHint=True, idempotentHint=False),
|
annotations=ToolAnnotations(destructiveHint=True, idempotentHint=False),
|
||||||
)
|
)
|
||||||
def reboot_guest(self, name: str) -> str:
|
def reboot_guest(self, name: str, host: str | None = None) -> str:
|
||||||
"""Gracefully reboot the guest OS."""
|
"""Gracefully reboot the guest OS."""
|
||||||
vm = self.conn.find_vm(name)
|
conn = self._conn(host)
|
||||||
|
vm = conn.find_vm(name)
|
||||||
if not vm:
|
if not vm:
|
||||||
raise ValueError(f"VM '{name}' not found")
|
raise ValueError(f"VM '{name}' not found")
|
||||||
|
|
||||||
@ -104,14 +108,15 @@ class PowerOpsMixin(VSphereMixin):
|
|||||||
description="Reset (hard reboot) a virtual machine - like pressing the reset button",
|
description="Reset (hard reboot) a virtual machine - like pressing the reset button",
|
||||||
annotations=ToolAnnotations(destructiveHint=True, idempotentHint=False),
|
annotations=ToolAnnotations(destructiveHint=True, idempotentHint=False),
|
||||||
)
|
)
|
||||||
def reset_vm(self, name: str) -> str:
|
def reset_vm(self, name: str, host: str | None = None) -> str:
|
||||||
"""Reset (hard reboot) a virtual machine."""
|
"""Reset (hard reboot) a virtual machine."""
|
||||||
vm = self.conn.find_vm(name)
|
conn = self._conn(host)
|
||||||
|
vm = conn.find_vm(name)
|
||||||
if not vm:
|
if not vm:
|
||||||
raise ValueError(f"VM '{name}' not found")
|
raise ValueError(f"VM '{name}' not found")
|
||||||
|
|
||||||
task = vm.ResetVM_Task()
|
task = vm.ResetVM_Task()
|
||||||
self.conn.wait_for_task(task)
|
conn.wait_for_task(task)
|
||||||
|
|
||||||
return f"VM '{name}' reset"
|
return f"VM '{name}' reset"
|
||||||
|
|
||||||
@ -120,9 +125,10 @@ class PowerOpsMixin(VSphereMixin):
|
|||||||
description="Suspend a virtual machine (save state to disk)",
|
description="Suspend a virtual machine (save state to disk)",
|
||||||
annotations=ToolAnnotations(destructiveHint=False, idempotentHint=True),
|
annotations=ToolAnnotations(destructiveHint=False, idempotentHint=True),
|
||||||
)
|
)
|
||||||
def suspend_vm(self, name: str) -> str:
|
def suspend_vm(self, name: str, host: str | None = None) -> str:
|
||||||
"""Suspend a virtual machine."""
|
"""Suspend a virtual machine."""
|
||||||
vm = self.conn.find_vm(name)
|
conn = self._conn(host)
|
||||||
|
vm = conn.find_vm(name)
|
||||||
if not vm:
|
if not vm:
|
||||||
raise ValueError(f"VM '{name}' not found")
|
raise ValueError(f"VM '{name}' not found")
|
||||||
|
|
||||||
@ -133,7 +139,7 @@ class PowerOpsMixin(VSphereMixin):
|
|||||||
return f"VM '{name}' is powered off, cannot suspend"
|
return f"VM '{name}' is powered off, cannot suspend"
|
||||||
|
|
||||||
task = vm.SuspendVM_Task()
|
task = vm.SuspendVM_Task()
|
||||||
self.conn.wait_for_task(task)
|
conn.wait_for_task(task)
|
||||||
|
|
||||||
return f"VM '{name}' suspended"
|
return f"VM '{name}' suspended"
|
||||||
|
|
||||||
@ -142,9 +148,10 @@ class PowerOpsMixin(VSphereMixin):
|
|||||||
description="Put guest OS into standby mode (requires VMware Tools)",
|
description="Put guest OS into standby mode (requires VMware Tools)",
|
||||||
annotations=ToolAnnotations(destructiveHint=False, idempotentHint=True),
|
annotations=ToolAnnotations(destructiveHint=False, idempotentHint=True),
|
||||||
)
|
)
|
||||||
def standby_guest(self, name: str) -> str:
|
def standby_guest(self, name: str, host: str | None = None) -> str:
|
||||||
"""Put guest OS into standby mode."""
|
"""Put guest OS into standby mode."""
|
||||||
vm = self.conn.find_vm(name)
|
conn = self._conn(host)
|
||||||
|
vm = conn.find_vm(name)
|
||||||
if not vm:
|
if not vm:
|
||||||
raise ValueError(f"VM '{name}' not found")
|
raise ValueError(f"VM '{name}' not found")
|
||||||
|
|
||||||
|
|||||||
@ -26,13 +26,13 @@ class ResourcesMixin(VSphereMixin):
|
|||||||
)
|
)
|
||||||
def browse_datastore_root(self, datastore_name: str) -> list[dict[str, Any]]:
|
def browse_datastore_root(self, datastore_name: str) -> list[dict[str, Any]]:
|
||||||
"""Browse files and folders at the root of a datastore."""
|
"""Browse files and folders at the root of a datastore."""
|
||||||
return self._browse_datastore_path(datastore_name, "")
|
return self._browse_datastore_path(self.conn, datastore_name, "")
|
||||||
|
|
||||||
def _browse_datastore_path(
|
def _browse_datastore_path(
|
||||||
self, datastore_name: str, path: str
|
self, conn, datastore_name: str, path: str
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
"""Browse files and folders on a datastore at a given path."""
|
"""Browse files and folders on a datastore at a given path."""
|
||||||
ds = self.conn.find_datastore(datastore_name)
|
ds = conn.find_datastore(datastore_name)
|
||||||
if not ds:
|
if not ds:
|
||||||
raise ValueError(f"Datastore '{datastore_name}' not found")
|
raise ValueError(f"Datastore '{datastore_name}' not found")
|
||||||
|
|
||||||
@ -56,7 +56,7 @@ class ResourcesMixin(VSphereMixin):
|
|||||||
|
|
||||||
# Search for files
|
# Search for files
|
||||||
task = browser.SearchDatastore_Task(ds_path, search_spec)
|
task = browser.SearchDatastore_Task(ds_path, search_spec)
|
||||||
self.conn.wait_for_task(task)
|
conn.wait_for_task(task)
|
||||||
|
|
||||||
results = []
|
results = []
|
||||||
if task.info.result and task.info.result.file:
|
if task.info.result and task.info.result.file:
|
||||||
@ -85,7 +85,7 @@ class ResourcesMixin(VSphereMixin):
|
|||||||
|
|
||||||
return sorted(results, key=lambda x: (x["type"] != "Folder", x["name"]))
|
return sorted(results, key=lambda x: (x["type"] != "Folder", x["name"]))
|
||||||
|
|
||||||
def _stream_from_esxi(self, datastore: str, path: str, chunk_size: int = 1024 * 1024):
|
def _stream_from_esxi(self, conn, datastore: str, path: str, chunk_size: int = 1024 * 1024):
|
||||||
"""Generator that streams file content from ESXi datastore.
|
"""Generator that streams file content from ESXi datastore.
|
||||||
|
|
||||||
Yields raw bytes chunks as they arrive from ESXi HTTP API.
|
Yields raw bytes chunks as they arrive from ESXi HTTP API.
|
||||||
@ -95,13 +95,13 @@ class ResourcesMixin(VSphereMixin):
|
|||||||
import urllib.request
|
import urllib.request
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
|
|
||||||
ds = self.conn.find_datastore(datastore)
|
ds = conn.find_datastore(datastore)
|
||||||
if not ds:
|
if not ds:
|
||||||
raise ValueError(f"Datastore '{datastore}' not found")
|
raise ValueError(f"Datastore '{datastore}' not found")
|
||||||
|
|
||||||
# Build download URL
|
# Build download URL
|
||||||
dc_name = self.conn.datacenter.name
|
dc_name = conn.datacenter.name
|
||||||
host = self.conn.settings.vcenter_host
|
host = conn.settings.vcenter_host
|
||||||
encoded_path = quote(path, safe="")
|
encoded_path = quote(path, safe="")
|
||||||
|
|
||||||
url = (
|
url = (
|
||||||
@ -111,12 +111,12 @@ class ResourcesMixin(VSphereMixin):
|
|||||||
|
|
||||||
# Create SSL context
|
# Create SSL context
|
||||||
context = ssl.create_default_context()
|
context = ssl.create_default_context()
|
||||||
if self.conn.settings.vcenter_insecure:
|
if conn.settings.vcenter_insecure:
|
||||||
context.check_hostname = False
|
context.check_hostname = False
|
||||||
context.verify_mode = ssl.CERT_NONE
|
context.verify_mode = ssl.CERT_NONE
|
||||||
|
|
||||||
# Get session cookie
|
# Get session cookie
|
||||||
stub = self.conn.si._stub
|
stub = conn.si._stub
|
||||||
cookie = stub.cookie
|
cookie = stub.cookie
|
||||||
|
|
||||||
request = urllib.request.Request(url, method="GET")
|
request = urllib.request.Request(url, method="GET")
|
||||||
@ -146,15 +146,17 @@ class ResourcesMixin(VSphereMixin):
|
|||||||
annotations=ToolAnnotations(readOnlyHint=True),
|
annotations=ToolAnnotations(readOnlyHint=True),
|
||||||
)
|
)
|
||||||
def browse_datastore_tool(
|
def browse_datastore_tool(
|
||||||
self, datastore: str, path: str = ""
|
self, datastore: str, path: str = "", host: str | None = None
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
"""Browse files at a specific path on a datastore.
|
"""Browse files at a specific path on a datastore.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
datastore: Datastore name (e.g., c1_ds-02)
|
datastore: Datastore name (e.g., c1_ds-02)
|
||||||
path: Path within datastore (e.g., "rpm-desktop-1/" or "" for root)
|
path: Path within datastore (e.g., "rpm-desktop-1/" or "" for root)
|
||||||
|
host: Managed ESXi host to target (default: the default host)
|
||||||
"""
|
"""
|
||||||
return self._browse_datastore_path(datastore, path)
|
conn = self._conn(host)
|
||||||
|
return self._browse_datastore_path(conn, datastore, path)
|
||||||
|
|
||||||
@mcp_tool(
|
@mcp_tool(
|
||||||
name="download_from_datastore",
|
name="download_from_datastore",
|
||||||
@ -167,6 +169,7 @@ class ResourcesMixin(VSphereMixin):
|
|||||||
path: str,
|
path: str,
|
||||||
save_to: str | None = None,
|
save_to: str | None = None,
|
||||||
max_memory_mb: int = 50,
|
max_memory_mb: int = 50,
|
||||||
|
host: str | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Download a file from a datastore using streaming.
|
"""Download a file from a datastore using streaming.
|
||||||
|
|
||||||
@ -179,14 +182,16 @@ class ResourcesMixin(VSphereMixin):
|
|||||||
path: Path to file on datastore (e.g., "iso/readme.txt")
|
path: Path to file on datastore (e.g., "iso/readme.txt")
|
||||||
save_to: Local path to save file (recommended for large files)
|
save_to: Local path to save file (recommended for large files)
|
||||||
max_memory_mb: Max file size in MB to return in response (default 50MB)
|
max_memory_mb: Max file size in MB to return in response (default 50MB)
|
||||||
|
host: Managed ESXi host to target (default: the default host)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict with file content or save confirmation
|
Dict with file content or save confirmation
|
||||||
"""
|
"""
|
||||||
import base64
|
import base64
|
||||||
|
|
||||||
|
conn = self._conn(host)
|
||||||
max_bytes = max_memory_mb * 1024 * 1024
|
max_bytes = max_memory_mb * 1024 * 1024
|
||||||
stream = self._stream_from_esxi(datastore, path)
|
stream = self._stream_from_esxi(conn, datastore, path)
|
||||||
|
|
||||||
# First yield is total size (or None)
|
# First yield is total size (or None)
|
||||||
total_size = next(stream)
|
total_size = next(stream)
|
||||||
@ -260,6 +265,7 @@ class ResourcesMixin(VSphereMixin):
|
|||||||
local_path: str | None = None,
|
local_path: str | None = None,
|
||||||
content_base64: str | None = None,
|
content_base64: str | None = None,
|
||||||
chunk_size: int = 8 * 1024 * 1024, # 8MB chunks
|
chunk_size: int = 8 * 1024 * 1024, # 8MB chunks
|
||||||
|
host: str | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Upload a file to a datastore.
|
"""Upload a file to a datastore.
|
||||||
|
|
||||||
@ -272,6 +278,7 @@ class ResourcesMixin(VSphereMixin):
|
|||||||
local_path: Local file path to upload - streams from disk (preferred for large files)
|
local_path: Local file path to upload - streams from disk (preferred for large files)
|
||||||
content_base64: Base64-encoded file content (for small files only)
|
content_base64: Base64-encoded file content (for small files only)
|
||||||
chunk_size: Chunk size for streaming uploads (default 8MB)
|
chunk_size: Chunk size for streaming uploads (default 8MB)
|
||||||
|
host: Managed ESXi host to target (default: the default host)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict with upload details including size and whether streaming was used
|
Dict with upload details including size and whether streaming was used
|
||||||
@ -281,35 +288,37 @@ class ResourcesMixin(VSphereMixin):
|
|||||||
import ssl
|
import ssl
|
||||||
import urllib.request
|
import urllib.request
|
||||||
|
|
||||||
|
conn = self._conn(host)
|
||||||
|
|
||||||
if not local_path and not content_base64:
|
if not local_path and not content_base64:
|
||||||
raise ValueError("Either local_path or content_base64 must be provided")
|
raise ValueError("Either local_path or content_base64 must be provided")
|
||||||
if local_path and content_base64:
|
if local_path and content_base64:
|
||||||
raise ValueError("Only one of local_path or content_base64 can be provided")
|
raise ValueError("Only one of local_path or content_base64 can be provided")
|
||||||
|
|
||||||
ds = self.conn.find_datastore(datastore)
|
ds = conn.find_datastore(datastore)
|
||||||
if not ds:
|
if not ds:
|
||||||
raise ValueError(f"Datastore '{datastore}' not found")
|
raise ValueError(f"Datastore '{datastore}' not found")
|
||||||
|
|
||||||
# Build upload URL
|
# Build upload URL
|
||||||
dc_name = self.conn.datacenter.name
|
dc_name = conn.datacenter.name
|
||||||
host = self.conn.settings.vcenter_host
|
vc_host = conn.settings.vcenter_host
|
||||||
|
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
encoded_path = quote(remote_path, safe="")
|
encoded_path = quote(remote_path, safe="")
|
||||||
|
|
||||||
url = (
|
url = (
|
||||||
f"https://{host}/folder/{encoded_path}"
|
f"https://{vc_host}/folder/{encoded_path}"
|
||||||
f"?dcPath={quote(dc_name)}&dsName={quote(datastore)}"
|
f"?dcPath={quote(dc_name)}&dsName={quote(datastore)}"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create SSL context
|
# Create SSL context
|
||||||
context = ssl.create_default_context()
|
context = ssl.create_default_context()
|
||||||
if self.conn.settings.vcenter_insecure:
|
if conn.settings.vcenter_insecure:
|
||||||
context.check_hostname = False
|
context.check_hostname = False
|
||||||
context.verify_mode = ssl.CERT_NONE
|
context.verify_mode = ssl.CERT_NONE
|
||||||
|
|
||||||
# Get session cookie from existing connection
|
# Get session cookie from existing connection
|
||||||
stub = self.conn.si._stub
|
stub = conn.si._stub
|
||||||
cookie = stub.cookie
|
cookie = stub.cookie
|
||||||
|
|
||||||
if local_path:
|
if local_path:
|
||||||
@ -398,17 +407,21 @@ class ResourcesMixin(VSphereMixin):
|
|||||||
description="Delete a file or folder from a datastore",
|
description="Delete a file or folder from a datastore",
|
||||||
annotations=ToolAnnotations(destructiveHint=True, idempotentHint=True),
|
annotations=ToolAnnotations(destructiveHint=True, idempotentHint=True),
|
||||||
)
|
)
|
||||||
def delete_datastore_file(self, datastore: str, path: str) -> str:
|
def delete_datastore_file(
|
||||||
|
self, datastore: str, path: str, host: str | None = None
|
||||||
|
) -> str:
|
||||||
"""Delete a file or folder from a datastore.
|
"""Delete a file or folder from a datastore.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
datastore: Datastore name
|
datastore: Datastore name
|
||||||
path: Path to file or folder to delete (e.g., "iso/old-file.iso")
|
path: Path to file or folder to delete (e.g., "iso/old-file.iso")
|
||||||
|
host: Managed ESXi host to target (default: the default host)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Success message
|
Success message
|
||||||
"""
|
"""
|
||||||
ds = self.conn.find_datastore(datastore)
|
conn = self._conn(host)
|
||||||
|
ds = conn.find_datastore(datastore)
|
||||||
if not ds:
|
if not ds:
|
||||||
raise ValueError(f"Datastore '{datastore}' not found")
|
raise ValueError(f"Datastore '{datastore}' not found")
|
||||||
|
|
||||||
@ -416,11 +429,11 @@ class ResourcesMixin(VSphereMixin):
|
|||||||
ds_path = f"[{datastore}] {path}"
|
ds_path = f"[{datastore}] {path}"
|
||||||
|
|
||||||
# Use FileManager to delete
|
# Use FileManager to delete
|
||||||
file_manager = self.conn.content.fileManager
|
file_manager = conn.content.fileManager
|
||||||
dc = self.conn.datacenter
|
dc = conn.datacenter
|
||||||
|
|
||||||
task = file_manager.DeleteDatastoreFile_Task(name=ds_path, datacenter=dc)
|
task = file_manager.DeleteDatastoreFile_Task(name=ds_path, datacenter=dc)
|
||||||
self.conn.wait_for_task(task)
|
conn.wait_for_task(task)
|
||||||
|
|
||||||
return f"Deleted [{datastore}] {path}"
|
return f"Deleted [{datastore}] {path}"
|
||||||
|
|
||||||
@ -429,17 +442,21 @@ class ResourcesMixin(VSphereMixin):
|
|||||||
description="Create a folder on a datastore",
|
description="Create a folder on a datastore",
|
||||||
annotations=ToolAnnotations(destructiveHint=False, idempotentHint=True),
|
annotations=ToolAnnotations(destructiveHint=False, idempotentHint=True),
|
||||||
)
|
)
|
||||||
def create_datastore_folder(self, datastore: str, path: str) -> str:
|
def create_datastore_folder(
|
||||||
|
self, datastore: str, path: str, host: str | None = None
|
||||||
|
) -> str:
|
||||||
"""Create a folder on a datastore.
|
"""Create a folder on a datastore.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
datastore: Datastore name
|
datastore: Datastore name
|
||||||
path: Folder path to create (e.g., "iso/new-folder")
|
path: Folder path to create (e.g., "iso/new-folder")
|
||||||
|
host: Managed ESXi host to target (default: the default host)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Success message
|
Success message
|
||||||
"""
|
"""
|
||||||
ds = self.conn.find_datastore(datastore)
|
conn = self._conn(host)
|
||||||
|
ds = conn.find_datastore(datastore)
|
||||||
if not ds:
|
if not ds:
|
||||||
raise ValueError(f"Datastore '{datastore}' not found")
|
raise ValueError(f"Datastore '{datastore}' not found")
|
||||||
|
|
||||||
@ -447,8 +464,8 @@ class ResourcesMixin(VSphereMixin):
|
|||||||
ds_path = f"[{datastore}] {path}"
|
ds_path = f"[{datastore}] {path}"
|
||||||
|
|
||||||
# Use FileManager to create directory
|
# Use FileManager to create directory
|
||||||
file_manager = self.conn.content.fileManager
|
file_manager = conn.content.fileManager
|
||||||
file_manager.MakeDirectory(name=ds_path, datacenter=self.conn.datacenter)
|
file_manager.MakeDirectory(name=ds_path, datacenter=conn.datacenter)
|
||||||
|
|
||||||
return f"Created folder [{datastore}] {path}"
|
return f"Created folder [{datastore}] {path}"
|
||||||
|
|
||||||
@ -473,6 +490,7 @@ class ResourcesMixin(VSphereMixin):
|
|||||||
source_path: str,
|
source_path: str,
|
||||||
dest_datastore: str | None = None,
|
dest_datastore: str | None = None,
|
||||||
dest_path: str | None = None,
|
dest_path: str | None = None,
|
||||||
|
host: str | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Move or rename a file or folder on a datastore.
|
"""Move or rename a file or folder on a datastore.
|
||||||
|
|
||||||
@ -481,11 +499,13 @@ class ResourcesMixin(VSphereMixin):
|
|||||||
source_path: Source path (e.g., "iso/old-name.iso")
|
source_path: Source path (e.g., "iso/old-name.iso")
|
||||||
dest_datastore: Destination datastore (default: same as source)
|
dest_datastore: Destination datastore (default: same as source)
|
||||||
dest_path: Destination path (default: same as source with new name)
|
dest_path: Destination path (default: same as source with new name)
|
||||||
|
host: Managed ESXi host to target (default: the default host)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict with move operation details
|
Dict with move operation details
|
||||||
"""
|
"""
|
||||||
ds = self.conn.find_datastore(source_datastore)
|
conn = self._conn(host)
|
||||||
|
ds = conn.find_datastore(source_datastore)
|
||||||
if not ds:
|
if not ds:
|
||||||
raise ValueError(f"Datastore '{source_datastore}' not found")
|
raise ValueError(f"Datastore '{source_datastore}' not found")
|
||||||
|
|
||||||
@ -493,7 +513,7 @@ class ResourcesMixin(VSphereMixin):
|
|||||||
if not dest_datastore:
|
if not dest_datastore:
|
||||||
dest_datastore = source_datastore
|
dest_datastore = source_datastore
|
||||||
else:
|
else:
|
||||||
dest_ds = self.conn.find_datastore(dest_datastore)
|
dest_ds = conn.find_datastore(dest_datastore)
|
||||||
if not dest_ds:
|
if not dest_ds:
|
||||||
raise ValueError(f"Destination datastore '{dest_datastore}' not found")
|
raise ValueError(f"Destination datastore '{dest_datastore}' not found")
|
||||||
|
|
||||||
@ -505,8 +525,8 @@ class ResourcesMixin(VSphereMixin):
|
|||||||
dest_ds_path = f"[{dest_datastore}] {dest_path}"
|
dest_ds_path = f"[{dest_datastore}] {dest_path}"
|
||||||
|
|
||||||
# Use FileManager to move
|
# Use FileManager to move
|
||||||
file_manager = self.conn.content.fileManager
|
file_manager = conn.content.fileManager
|
||||||
dc = self.conn.datacenter
|
dc = conn.datacenter
|
||||||
|
|
||||||
task = file_manager.MoveDatastoreFile_Task(
|
task = file_manager.MoveDatastoreFile_Task(
|
||||||
sourceName=source_ds_path,
|
sourceName=source_ds_path,
|
||||||
@ -515,7 +535,7 @@ class ResourcesMixin(VSphereMixin):
|
|||||||
destinationDatacenter=dc,
|
destinationDatacenter=dc,
|
||||||
force=False,
|
force=False,
|
||||||
)
|
)
|
||||||
self.conn.wait_for_task(task)
|
conn.wait_for_task(task)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"action": "moved",
|
"action": "moved",
|
||||||
@ -535,6 +555,7 @@ class ResourcesMixin(VSphereMixin):
|
|||||||
dest_datastore: str | None = None,
|
dest_datastore: str | None = None,
|
||||||
dest_path: str | None = None,
|
dest_path: str | None = None,
|
||||||
force: bool = False,
|
force: bool = False,
|
||||||
|
host: str | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Copy a file or folder on a datastore.
|
"""Copy a file or folder on a datastore.
|
||||||
|
|
||||||
@ -544,11 +565,13 @@ class ResourcesMixin(VSphereMixin):
|
|||||||
dest_datastore: Destination datastore (default: same as source)
|
dest_datastore: Destination datastore (default: same as source)
|
||||||
dest_path: Destination path (required)
|
dest_path: Destination path (required)
|
||||||
force: Overwrite destination if exists (default False)
|
force: Overwrite destination if exists (default False)
|
||||||
|
host: Managed ESXi host to target (default: the default host)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict with copy operation details
|
Dict with copy operation details
|
||||||
"""
|
"""
|
||||||
ds = self.conn.find_datastore(source_datastore)
|
conn = self._conn(host)
|
||||||
|
ds = conn.find_datastore(source_datastore)
|
||||||
if not ds:
|
if not ds:
|
||||||
raise ValueError(f"Datastore '{source_datastore}' not found")
|
raise ValueError(f"Datastore '{source_datastore}' not found")
|
||||||
|
|
||||||
@ -556,7 +579,7 @@ class ResourcesMixin(VSphereMixin):
|
|||||||
if not dest_datastore:
|
if not dest_datastore:
|
||||||
dest_datastore = source_datastore
|
dest_datastore = source_datastore
|
||||||
else:
|
else:
|
||||||
dest_ds = self.conn.find_datastore(dest_datastore)
|
dest_ds = conn.find_datastore(dest_datastore)
|
||||||
if not dest_ds:
|
if not dest_ds:
|
||||||
raise ValueError(f"Destination datastore '{dest_datastore}' not found")
|
raise ValueError(f"Destination datastore '{dest_datastore}' not found")
|
||||||
|
|
||||||
@ -568,8 +591,8 @@ class ResourcesMixin(VSphereMixin):
|
|||||||
dest_ds_path = f"[{dest_datastore}] {dest_path}"
|
dest_ds_path = f"[{dest_datastore}] {dest_path}"
|
||||||
|
|
||||||
# Use FileManager to copy
|
# Use FileManager to copy
|
||||||
file_manager = self.conn.content.fileManager
|
file_manager = conn.content.fileManager
|
||||||
dc = self.conn.datacenter
|
dc = conn.datacenter
|
||||||
|
|
||||||
task = file_manager.CopyDatastoreFile_Task(
|
task = file_manager.CopyDatastoreFile_Task(
|
||||||
sourceName=source_ds_path,
|
sourceName=source_ds_path,
|
||||||
@ -578,7 +601,7 @@ class ResourcesMixin(VSphereMixin):
|
|||||||
destinationDatacenter=dc,
|
destinationDatacenter=dc,
|
||||||
force=force,
|
force=force,
|
||||||
)
|
)
|
||||||
self.conn.wait_for_task(task)
|
conn.wait_for_task(task)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"action": "copied",
|
"action": "copied",
|
||||||
@ -718,9 +741,15 @@ class ResourcesMixin(VSphereMixin):
|
|||||||
description="Get detailed information about a specific datastore",
|
description="Get detailed information about a specific datastore",
|
||||||
annotations=ToolAnnotations(readOnlyHint=True),
|
annotations=ToolAnnotations(readOnlyHint=True),
|
||||||
)
|
)
|
||||||
def get_datastore_info(self, name: str) -> dict[str, Any]:
|
def get_datastore_info(self, name: str, host: str | None = None) -> dict[str, Any]:
|
||||||
"""Get detailed datastore information."""
|
"""Get detailed datastore information.
|
||||||
ds = self.conn.find_datastore(name)
|
|
||||||
|
Args:
|
||||||
|
name: Datastore name
|
||||||
|
host: Managed ESXi host to target (default: the default host)
|
||||||
|
"""
|
||||||
|
conn = self._conn(host)
|
||||||
|
ds = conn.find_datastore(name)
|
||||||
if not ds:
|
if not ds:
|
||||||
raise ValueError(f"Datastore '{name}' not found")
|
raise ValueError(f"Datastore '{name}' not found")
|
||||||
|
|
||||||
@ -752,9 +781,15 @@ class ResourcesMixin(VSphereMixin):
|
|||||||
description="Get detailed information about a specific network",
|
description="Get detailed information about a specific network",
|
||||||
annotations=ToolAnnotations(readOnlyHint=True),
|
annotations=ToolAnnotations(readOnlyHint=True),
|
||||||
)
|
)
|
||||||
def get_network_info(self, name: str) -> dict[str, Any]:
|
def get_network_info(self, name: str, host: str | None = None) -> dict[str, Any]:
|
||||||
"""Get detailed network information."""
|
"""Get detailed network information.
|
||||||
net = self.conn.find_network(name)
|
|
||||||
|
Args:
|
||||||
|
name: Network name
|
||||||
|
host: Managed ESXi host to target (default: the default host)
|
||||||
|
"""
|
||||||
|
conn = self._conn(host)
|
||||||
|
net = conn.find_network(name)
|
||||||
if not net:
|
if not net:
|
||||||
raise ValueError(f"Network '{name}' not found")
|
raise ValueError(f"Network '{name}' not found")
|
||||||
|
|
||||||
@ -783,14 +818,21 @@ class ResourcesMixin(VSphereMixin):
|
|||||||
description="Get information about resource pools",
|
description="Get information about resource pools",
|
||||||
annotations=ToolAnnotations(readOnlyHint=True),
|
annotations=ToolAnnotations(readOnlyHint=True),
|
||||||
)
|
)
|
||||||
def get_resource_pool_info(self, name: str | None = None) -> dict[str, Any]:
|
def get_resource_pool_info(
|
||||||
|
self, name: str | None = None, host: str | None = None
|
||||||
|
) -> dict[str, Any]:
|
||||||
"""Get resource pool information.
|
"""Get resource pool information.
|
||||||
|
|
||||||
If name is not provided, returns info for the default resource pool.
|
If name is not provided, returns info for the default resource pool.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Resource pool name (default: the default resource pool)
|
||||||
|
host: Managed ESXi host to target (default: the default host)
|
||||||
"""
|
"""
|
||||||
|
conn = self._conn(host)
|
||||||
if name:
|
if name:
|
||||||
container = self.conn.content.viewManager.CreateContainerView(
|
container = conn.content.viewManager.CreateContainerView(
|
||||||
self.conn.content.rootFolder, [vim.ResourcePool], True
|
conn.content.rootFolder, [vim.ResourcePool], True
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
pool = next((p for p in container.view if p.name == name), None)
|
pool = next((p for p in container.view if p.name == name), None)
|
||||||
@ -799,7 +841,7 @@ class ResourcesMixin(VSphereMixin):
|
|||||||
if not pool:
|
if not pool:
|
||||||
raise ValueError(f"Resource pool '{name}' not found")
|
raise ValueError(f"Resource pool '{name}' not found")
|
||||||
else:
|
else:
|
||||||
pool = self.conn.resource_pool
|
pool = conn.resource_pool
|
||||||
|
|
||||||
runtime = pool.summary.runtime
|
runtime = pool.summary.runtime
|
||||||
config = pool.summary.config
|
config = pool.summary.config
|
||||||
@ -822,10 +864,15 @@ class ResourcesMixin(VSphereMixin):
|
|||||||
description="List all VM templates in the inventory",
|
description="List all VM templates in the inventory",
|
||||||
annotations=ToolAnnotations(readOnlyHint=True),
|
annotations=ToolAnnotations(readOnlyHint=True),
|
||||||
)
|
)
|
||||||
def list_templates(self) -> list[dict[str, Any]]:
|
def list_templates(self, host: str | None = None) -> list[dict[str, Any]]:
|
||||||
"""List all VM templates."""
|
"""List all VM templates.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
host: Managed ESXi host to target (default: the default host)
|
||||||
|
"""
|
||||||
|
conn = self._conn(host)
|
||||||
templates = []
|
templates = []
|
||||||
for vm in self.conn.get_all_vms():
|
for vm in conn.get_all_vms():
|
||||||
if vm.config and vm.config.template:
|
if vm.config and vm.config.template:
|
||||||
templates.append(
|
templates.append(
|
||||||
{
|
{
|
||||||
@ -842,9 +889,14 @@ class ResourcesMixin(VSphereMixin):
|
|||||||
description="Get vCenter/ESXi server information",
|
description="Get vCenter/ESXi server information",
|
||||||
annotations=ToolAnnotations(readOnlyHint=True),
|
annotations=ToolAnnotations(readOnlyHint=True),
|
||||||
)
|
)
|
||||||
def get_vcenter_info(self) -> dict[str, Any]:
|
def get_vcenter_info(self, host: str | None = None) -> dict[str, Any]:
|
||||||
"""Get vCenter/ESXi server information."""
|
"""Get vCenter/ESXi server information.
|
||||||
about = self.conn.content.about
|
|
||||||
|
Args:
|
||||||
|
host: Managed ESXi host to target (default: the default host)
|
||||||
|
"""
|
||||||
|
conn = self._conn(host)
|
||||||
|
about = conn.content.about
|
||||||
return {
|
return {
|
||||||
"name": about.name,
|
"name": about.name,
|
||||||
"full_name": about.fullName,
|
"full_name": about.fullName,
|
||||||
|
|||||||
@ -66,16 +66,18 @@ class SerialPortMixin(VSphereMixin):
|
|||||||
description="Get current serial port configuration for a VM",
|
description="Get current serial port configuration for a VM",
|
||||||
annotations=ToolAnnotations(readOnlyHint=True),
|
annotations=ToolAnnotations(readOnlyHint=True),
|
||||||
)
|
)
|
||||||
def get_serial_port(self, name: str) -> dict[str, Any]:
|
def get_serial_port(self, name: str, host: str | None = None) -> dict[str, Any]:
|
||||||
"""Get serial port configuration.
|
"""Get serial port configuration.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
name: VM name
|
name: VM name
|
||||||
|
host: Managed ESXi host to target (default: the default host)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict with serial port details or message if not configured
|
Dict with serial port details or message if not configured
|
||||||
"""
|
"""
|
||||||
vm = self.conn.find_vm(name)
|
conn = self._conn(host)
|
||||||
|
vm = conn.find_vm(name)
|
||||||
if not vm:
|
if not vm:
|
||||||
raise ValueError(f"VM '{name}' not found")
|
raise ValueError(f"VM '{name}' not found")
|
||||||
|
|
||||||
@ -109,6 +111,7 @@ class SerialPortMixin(VSphereMixin):
|
|||||||
port: int | None = None,
|
port: int | None = None,
|
||||||
direction: str = "server",
|
direction: str = "server",
|
||||||
yield_on_poll: bool = True,
|
yield_on_poll: bool = True,
|
||||||
|
host: str | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Setup or update network serial port.
|
"""Setup or update network serial port.
|
||||||
|
|
||||||
@ -118,11 +121,13 @@ class SerialPortMixin(VSphereMixin):
|
|||||||
port: TCP port number. If not specified, auto-assigns unused port.
|
port: TCP port number. If not specified, auto-assigns unused port.
|
||||||
direction: 'server' (VM listens) or 'client' (VM connects). Default: server
|
direction: 'server' (VM listens) or 'client' (VM connects). Default: server
|
||||||
yield_on_poll: Enable CPU yield behavior. Default: True
|
yield_on_poll: Enable CPU yield behavior. Default: True
|
||||||
|
host: Managed ESXi host to target (default: the default host)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict with configured serial port URI and details
|
Dict with configured serial port URI and details
|
||||||
"""
|
"""
|
||||||
vm = self.conn.find_vm(name)
|
conn = self._conn(host)
|
||||||
|
vm = conn.find_vm(name)
|
||||||
if not vm:
|
if not vm:
|
||||||
raise ValueError(f"VM '{name}' not found")
|
raise ValueError(f"VM '{name}' not found")
|
||||||
|
|
||||||
@ -141,8 +146,8 @@ class SerialPortMixin(VSphereMixin):
|
|||||||
|
|
||||||
# Find or assign port
|
# Find or assign port
|
||||||
if port is None:
|
if port is None:
|
||||||
host = vm.runtime.host
|
vm_host = vm.runtime.host
|
||||||
host_ip = host.name if host else self.conn.settings.vcenter_host
|
host_ip = vm_host.name if vm_host else conn.settings.vcenter_host
|
||||||
port = self._find_unused_port(host_ip)
|
port = self._find_unused_port(host_ip)
|
||||||
|
|
||||||
# Build service URI
|
# Build service URI
|
||||||
@ -177,11 +182,11 @@ class SerialPortMixin(VSphereMixin):
|
|||||||
spec = vim.vm.ConfigSpec()
|
spec = vim.vm.ConfigSpec()
|
||||||
spec.deviceChange = [serial_spec]
|
spec.deviceChange = [serial_spec]
|
||||||
task = vm.ReconfigVM_Task(spec=spec)
|
task = vm.ReconfigVM_Task(spec=spec)
|
||||||
self.conn.wait_for_task(task)
|
conn.wait_for_task(task)
|
||||||
|
|
||||||
# Get ESXi host info for connection string
|
# Get ESXi host info for connection string
|
||||||
host = vm.runtime.host
|
vm_host = vm.runtime.host
|
||||||
host_ip = host.name if host else self.conn.settings.vcenter_host
|
host_ip = vm_host.name if vm_host else conn.settings.vcenter_host
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"vm_name": name,
|
"vm_name": name,
|
||||||
@ -199,17 +204,19 @@ class SerialPortMixin(VSphereMixin):
|
|||||||
description="Connect or disconnect an existing serial port on a VM",
|
description="Connect or disconnect an existing serial port on a VM",
|
||||||
annotations=ToolAnnotations(destructiveHint=False, idempotentHint=True),
|
annotations=ToolAnnotations(destructiveHint=False, idempotentHint=True),
|
||||||
)
|
)
|
||||||
def connect_serial_port(self, name: str, connected: bool = True) -> dict[str, Any]:
|
def connect_serial_port(self, name: str, connected: bool = True, host: str | None = None) -> dict[str, Any]:
|
||||||
"""Connect or disconnect serial port.
|
"""Connect or disconnect serial port.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
name: VM name
|
name: VM name
|
||||||
connected: True to connect, False to disconnect. Default: True
|
connected: True to connect, False to disconnect. Default: True
|
||||||
|
host: Managed ESXi host to target (default: the default host)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict with result
|
Dict with result
|
||||||
"""
|
"""
|
||||||
vm = self.conn.find_vm(name)
|
conn = self._conn(host)
|
||||||
|
vm = conn.find_vm(name)
|
||||||
if not vm:
|
if not vm:
|
||||||
raise ValueError(f"VM '{name}' not found")
|
raise ValueError(f"VM '{name}' not found")
|
||||||
|
|
||||||
@ -226,7 +233,7 @@ class SerialPortMixin(VSphereMixin):
|
|||||||
spec = vim.vm.ConfigSpec()
|
spec = vim.vm.ConfigSpec()
|
||||||
spec.deviceChange = [serial_spec]
|
spec.deviceChange = [serial_spec]
|
||||||
task = vm.ReconfigVM_Task(spec=spec)
|
task = vm.ReconfigVM_Task(spec=spec)
|
||||||
self.conn.wait_for_task(task)
|
conn.wait_for_task(task)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"vm_name": name,
|
"vm_name": name,
|
||||||
@ -239,18 +246,20 @@ class SerialPortMixin(VSphereMixin):
|
|||||||
description="Reset serial port by disconnecting and reconnecting (clears stuck connections)",
|
description="Reset serial port by disconnecting and reconnecting (clears stuck connections)",
|
||||||
annotations=ToolAnnotations(destructiveHint=False, idempotentHint=True),
|
annotations=ToolAnnotations(destructiveHint=False, idempotentHint=True),
|
||||||
)
|
)
|
||||||
def clear_serial_port(self, name: str) -> dict[str, Any]:
|
def clear_serial_port(self, name: str, host: str | None = None) -> dict[str, Any]:
|
||||||
"""Clear serial port by cycling connection state.
|
"""Clear serial port by cycling connection state.
|
||||||
|
|
||||||
Useful for clearing stuck or stale connections.
|
Useful for clearing stuck or stale connections.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
name: VM name
|
name: VM name
|
||||||
|
host: Managed ESXi host to target (default: the default host)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict with result
|
Dict with result
|
||||||
"""
|
"""
|
||||||
vm = self.conn.find_vm(name)
|
conn = self._conn(host)
|
||||||
|
vm = conn.find_vm(name)
|
||||||
if not vm:
|
if not vm:
|
||||||
raise ValueError(f"VM '{name}' not found")
|
raise ValueError(f"VM '{name}' not found")
|
||||||
|
|
||||||
@ -259,11 +268,11 @@ class SerialPortMixin(VSphereMixin):
|
|||||||
raise ValueError(f"No network serial port configured on VM '{name}'")
|
raise ValueError(f"No network serial port configured on VM '{name}'")
|
||||||
|
|
||||||
# Disconnect
|
# Disconnect
|
||||||
self.connect_serial_port(name, connected=False)
|
self.connect_serial_port(name, connected=False, host=host)
|
||||||
time.sleep(1)
|
time.sleep(1)
|
||||||
|
|
||||||
# Reconnect
|
# Reconnect
|
||||||
self.connect_serial_port(name, connected=True)
|
self.connect_serial_port(name, connected=True, host=host)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"vm_name": name,
|
"vm_name": name,
|
||||||
@ -277,16 +286,18 @@ class SerialPortMixin(VSphereMixin):
|
|||||||
description="Remove the network serial port from a VM. VM must be powered off.",
|
description="Remove the network serial port from a VM. VM must be powered off.",
|
||||||
annotations=ToolAnnotations(destructiveHint=True, idempotentHint=True),
|
annotations=ToolAnnotations(destructiveHint=True, idempotentHint=True),
|
||||||
)
|
)
|
||||||
def remove_serial_port(self, name: str) -> str:
|
def remove_serial_port(self, name: str, host: str | None = None) -> str:
|
||||||
"""Remove serial port from VM.
|
"""Remove serial port from VM.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
name: VM name
|
name: VM name
|
||||||
|
host: Managed ESXi host to target (default: the default host)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Success message
|
Success message
|
||||||
"""
|
"""
|
||||||
vm = self.conn.find_vm(name)
|
conn = self._conn(host)
|
||||||
|
vm = conn.find_vm(name)
|
||||||
if not vm:
|
if not vm:
|
||||||
raise ValueError(f"VM '{name}' not found")
|
raise ValueError(f"VM '{name}' not found")
|
||||||
|
|
||||||
@ -306,6 +317,6 @@ class SerialPortMixin(VSphereMixin):
|
|||||||
spec = vim.vm.ConfigSpec()
|
spec = vim.vm.ConfigSpec()
|
||||||
spec.deviceChange = [serial_spec]
|
spec.deviceChange = [serial_spec]
|
||||||
task = vm.ReconfigVM_Task(spec=spec)
|
task = vm.ReconfigVM_Task(spec=spec)
|
||||||
self.conn.wait_for_task(task)
|
conn.wait_for_task(task)
|
||||||
|
|
||||||
return f"Serial port removed from VM '{name}'"
|
return f"Serial port removed from VM '{name}'"
|
||||||
|
|||||||
@ -55,9 +55,15 @@ class SnapshotsMixin(VSphereMixin):
|
|||||||
description="List all snapshots for a virtual machine",
|
description="List all snapshots for a virtual machine",
|
||||||
annotations=ToolAnnotations(readOnlyHint=True),
|
annotations=ToolAnnotations(readOnlyHint=True),
|
||||||
)
|
)
|
||||||
def list_snapshots(self, name: str) -> list[dict[str, Any]]:
|
def list_snapshots(self, name: str, host: str | None = None) -> list[dict[str, Any]]:
|
||||||
"""List all snapshots for a VM."""
|
"""List all snapshots for a VM.
|
||||||
vm = self.conn.find_vm(name)
|
|
||||||
|
Args:
|
||||||
|
name: VM name
|
||||||
|
host: Managed ESXi host to target (default: the default host)
|
||||||
|
"""
|
||||||
|
conn = self._conn(host)
|
||||||
|
vm = conn.find_vm(name)
|
||||||
if not vm:
|
if not vm:
|
||||||
raise ValueError(f"VM '{name}' not found")
|
raise ValueError(f"VM '{name}' not found")
|
||||||
|
|
||||||
@ -95,6 +101,7 @@ class SnapshotsMixin(VSphereMixin):
|
|||||||
description: str = "",
|
description: str = "",
|
||||||
memory: bool = True,
|
memory: bool = True,
|
||||||
quiesce: bool = False,
|
quiesce: bool = False,
|
||||||
|
host: str | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Create a VM snapshot.
|
"""Create a VM snapshot.
|
||||||
|
|
||||||
@ -104,8 +111,10 @@ class SnapshotsMixin(VSphereMixin):
|
|||||||
description: Optional description
|
description: Optional description
|
||||||
memory: Include memory state (allows instant restore to running state)
|
memory: Include memory state (allows instant restore to running state)
|
||||||
quiesce: Quiesce guest filesystem (requires VMware Tools, ensures consistent state)
|
quiesce: Quiesce guest filesystem (requires VMware Tools, ensures consistent state)
|
||||||
|
host: Managed ESXi host to target (default: the default host)
|
||||||
"""
|
"""
|
||||||
vm = self.conn.find_vm(name)
|
conn = self._conn(host)
|
||||||
|
vm = conn.find_vm(name)
|
||||||
if not vm:
|
if not vm:
|
||||||
raise ValueError(f"VM '{name}' not found")
|
raise ValueError(f"VM '{name}' not found")
|
||||||
|
|
||||||
@ -126,7 +135,7 @@ class SnapshotsMixin(VSphereMixin):
|
|||||||
memory=memory,
|
memory=memory,
|
||||||
quiesce=quiesce,
|
quiesce=quiesce,
|
||||||
)
|
)
|
||||||
self.conn.wait_for_task(task)
|
conn.wait_for_task(task)
|
||||||
|
|
||||||
return f"Snapshot '{snapshot_name}' created for VM '{name}'"
|
return f"Snapshot '{snapshot_name}' created for VM '{name}'"
|
||||||
|
|
||||||
@ -135,9 +144,18 @@ class SnapshotsMixin(VSphereMixin):
|
|||||||
description="Revert a VM to a specific snapshot",
|
description="Revert a VM to a specific snapshot",
|
||||||
annotations=ToolAnnotations(destructiveHint=True, idempotentHint=False),
|
annotations=ToolAnnotations(destructiveHint=True, idempotentHint=False),
|
||||||
)
|
)
|
||||||
def revert_to_snapshot(self, name: str, snapshot_name: str) -> str:
|
def revert_to_snapshot(
|
||||||
"""Revert VM to a specific snapshot."""
|
self, name: str, snapshot_name: str, host: str | None = None
|
||||||
vm = self.conn.find_vm(name)
|
) -> str:
|
||||||
|
"""Revert VM to a specific snapshot.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: VM name
|
||||||
|
snapshot_name: Name of snapshot to revert to
|
||||||
|
host: Managed ESXi host to target (default: the default host)
|
||||||
|
"""
|
||||||
|
conn = self._conn(host)
|
||||||
|
vm = conn.find_vm(name)
|
||||||
if not vm:
|
if not vm:
|
||||||
raise ValueError(f"VM '{name}' not found")
|
raise ValueError(f"VM '{name}' not found")
|
||||||
|
|
||||||
@ -151,7 +169,7 @@ class SnapshotsMixin(VSphereMixin):
|
|||||||
raise ValueError(f"Snapshot '{snapshot_name}' not found on VM '{name}'")
|
raise ValueError(f"Snapshot '{snapshot_name}' not found on VM '{name}'")
|
||||||
|
|
||||||
task = snapshot.RevertToSnapshot_Task()
|
task = snapshot.RevertToSnapshot_Task()
|
||||||
self.conn.wait_for_task(task)
|
conn.wait_for_task(task)
|
||||||
|
|
||||||
return f"VM '{name}' reverted to snapshot '{snapshot_name}'"
|
return f"VM '{name}' reverted to snapshot '{snapshot_name}'"
|
||||||
|
|
||||||
@ -160,9 +178,15 @@ class SnapshotsMixin(VSphereMixin):
|
|||||||
description="Revert a VM to its current (most recent) snapshot",
|
description="Revert a VM to its current (most recent) snapshot",
|
||||||
annotations=ToolAnnotations(destructiveHint=True, idempotentHint=False),
|
annotations=ToolAnnotations(destructiveHint=True, idempotentHint=False),
|
||||||
)
|
)
|
||||||
def revert_to_current_snapshot(self, name: str) -> str:
|
def revert_to_current_snapshot(self, name: str, host: str | None = None) -> str:
|
||||||
"""Revert VM to its current snapshot."""
|
"""Revert VM to its current snapshot.
|
||||||
vm = self.conn.find_vm(name)
|
|
||||||
|
Args:
|
||||||
|
name: VM name
|
||||||
|
host: Managed ESXi host to target (default: the default host)
|
||||||
|
"""
|
||||||
|
conn = self._conn(host)
|
||||||
|
vm = conn.find_vm(name)
|
||||||
if not vm:
|
if not vm:
|
||||||
raise ValueError(f"VM '{name}' not found")
|
raise ValueError(f"VM '{name}' not found")
|
||||||
|
|
||||||
@ -170,7 +194,7 @@ class SnapshotsMixin(VSphereMixin):
|
|||||||
raise ValueError(f"VM '{name}' has no current snapshot")
|
raise ValueError(f"VM '{name}' has no current snapshot")
|
||||||
|
|
||||||
task = vm.RevertToCurrentSnapshot_Task()
|
task = vm.RevertToCurrentSnapshot_Task()
|
||||||
self.conn.wait_for_task(task)
|
conn.wait_for_task(task)
|
||||||
|
|
||||||
return f"VM '{name}' reverted to current snapshot"
|
return f"VM '{name}' reverted to current snapshot"
|
||||||
|
|
||||||
@ -180,7 +204,11 @@ class SnapshotsMixin(VSphereMixin):
|
|||||||
annotations=ToolAnnotations(destructiveHint=True, idempotentHint=True),
|
annotations=ToolAnnotations(destructiveHint=True, idempotentHint=True),
|
||||||
)
|
)
|
||||||
def delete_snapshot(
|
def delete_snapshot(
|
||||||
self, name: str, snapshot_name: str, remove_children: bool = False
|
self,
|
||||||
|
name: str,
|
||||||
|
snapshot_name: str,
|
||||||
|
remove_children: bool = False,
|
||||||
|
host: str | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Delete a VM snapshot.
|
"""Delete a VM snapshot.
|
||||||
|
|
||||||
@ -188,8 +216,10 @@ class SnapshotsMixin(VSphereMixin):
|
|||||||
name: VM name
|
name: VM name
|
||||||
snapshot_name: Name of snapshot to delete
|
snapshot_name: Name of snapshot to delete
|
||||||
remove_children: If True, also delete child snapshots
|
remove_children: If True, also delete child snapshots
|
||||||
|
host: Managed ESXi host to target (default: the default host)
|
||||||
"""
|
"""
|
||||||
vm = self.conn.find_vm(name)
|
conn = self._conn(host)
|
||||||
|
vm = conn.find_vm(name)
|
||||||
if not vm:
|
if not vm:
|
||||||
raise ValueError(f"VM '{name}' not found")
|
raise ValueError(f"VM '{name}' not found")
|
||||||
|
|
||||||
@ -203,7 +233,7 @@ class SnapshotsMixin(VSphereMixin):
|
|||||||
raise ValueError(f"Snapshot '{snapshot_name}' not found on VM '{name}'")
|
raise ValueError(f"Snapshot '{snapshot_name}' not found on VM '{name}'")
|
||||||
|
|
||||||
task = snapshot.RemoveSnapshot_Task(removeChildren=remove_children)
|
task = snapshot.RemoveSnapshot_Task(removeChildren=remove_children)
|
||||||
self.conn.wait_for_task(task)
|
conn.wait_for_task(task)
|
||||||
|
|
||||||
msg = f"Snapshot '{snapshot_name}' deleted from VM '{name}'"
|
msg = f"Snapshot '{snapshot_name}' deleted from VM '{name}'"
|
||||||
if remove_children:
|
if remove_children:
|
||||||
@ -215,9 +245,15 @@ class SnapshotsMixin(VSphereMixin):
|
|||||||
description="Delete ALL snapshots from a VM (consolidates disk)",
|
description="Delete ALL snapshots from a VM (consolidates disk)",
|
||||||
annotations=ToolAnnotations(destructiveHint=True, idempotentHint=True),
|
annotations=ToolAnnotations(destructiveHint=True, idempotentHint=True),
|
||||||
)
|
)
|
||||||
def delete_all_snapshots(self, name: str) -> str:
|
def delete_all_snapshots(self, name: str, host: str | None = None) -> str:
|
||||||
"""Delete all snapshots from a VM."""
|
"""Delete all snapshots from a VM.
|
||||||
vm = self.conn.find_vm(name)
|
|
||||||
|
Args:
|
||||||
|
name: VM name
|
||||||
|
host: Managed ESXi host to target (default: the default host)
|
||||||
|
"""
|
||||||
|
conn = self._conn(host)
|
||||||
|
vm = conn.find_vm(name)
|
||||||
if not vm:
|
if not vm:
|
||||||
raise ValueError(f"VM '{name}' not found")
|
raise ValueError(f"VM '{name}' not found")
|
||||||
|
|
||||||
@ -225,7 +261,7 @@ class SnapshotsMixin(VSphereMixin):
|
|||||||
return f"VM '{name}' has no snapshots to delete"
|
return f"VM '{name}' has no snapshots to delete"
|
||||||
|
|
||||||
task = vm.RemoveAllSnapshots_Task()
|
task = vm.RemoveAllSnapshots_Task()
|
||||||
self.conn.wait_for_task(task)
|
conn.wait_for_task(task)
|
||||||
|
|
||||||
return f"All snapshots deleted from VM '{name}'"
|
return f"All snapshots deleted from VM '{name}'"
|
||||||
|
|
||||||
@ -240,9 +276,19 @@ class SnapshotsMixin(VSphereMixin):
|
|||||||
snapshot_name: str,
|
snapshot_name: str,
|
||||||
new_name: str | None = None,
|
new_name: str | None = None,
|
||||||
new_description: str | None = None,
|
new_description: str | None = None,
|
||||||
|
host: str | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Rename a snapshot or update its description."""
|
"""Rename a snapshot or update its description.
|
||||||
vm = self.conn.find_vm(name)
|
|
||||||
|
Args:
|
||||||
|
name: VM name
|
||||||
|
snapshot_name: Name of snapshot to rename
|
||||||
|
new_name: New name for the snapshot
|
||||||
|
new_description: New description for the snapshot
|
||||||
|
host: Managed ESXi host to target (default: the default host)
|
||||||
|
"""
|
||||||
|
conn = self._conn(host)
|
||||||
|
vm = conn.find_vm(name)
|
||||||
if not vm:
|
if not vm:
|
||||||
raise ValueError(f"VM '{name}' not found")
|
raise ValueError(f"VM '{name}' not found")
|
||||||
|
|
||||||
|
|||||||
@ -43,6 +43,7 @@ class VCenterOpsMixin(VSphereMixin):
|
|||||||
vm_name: str,
|
vm_name: str,
|
||||||
target_datastore: str,
|
target_datastore: str,
|
||||||
thin_provision: bool | None = None,
|
thin_provision: bool | None = None,
|
||||||
|
host: str | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Move a VM's storage to a different datastore.
|
"""Move a VM's storage to a different datastore.
|
||||||
|
|
||||||
@ -53,15 +54,17 @@ class VCenterOpsMixin(VSphereMixin):
|
|||||||
vm_name: Name of the VM to migrate
|
vm_name: Name of the VM to migrate
|
||||||
target_datastore: Target datastore name
|
target_datastore: Target datastore name
|
||||||
thin_provision: Convert to thin provisioning (None = keep current)
|
thin_provision: Convert to thin provisioning (None = keep current)
|
||||||
|
host: Managed ESXi host to target (default: the default host)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict with migration details
|
Dict with migration details
|
||||||
"""
|
"""
|
||||||
vm = self.conn.find_vm(vm_name)
|
conn = self._conn(host)
|
||||||
|
vm = conn.find_vm(vm_name)
|
||||||
if not vm:
|
if not vm:
|
||||||
raise ValueError(f"VM '{vm_name}' not found")
|
raise ValueError(f"VM '{vm_name}' not found")
|
||||||
|
|
||||||
ds = self.conn.find_datastore(target_datastore)
|
ds = conn.find_datastore(target_datastore)
|
||||||
if not ds:
|
if not ds:
|
||||||
raise ValueError(f"Datastore '{target_datastore}' not found")
|
raise ValueError(f"Datastore '{target_datastore}' not found")
|
||||||
|
|
||||||
@ -88,7 +91,7 @@ class VCenterOpsMixin(VSphereMixin):
|
|||||||
|
|
||||||
# Perform the relocation
|
# Perform the relocation
|
||||||
task = vm.RelocateVM_Task(spec=relocate_spec)
|
task = vm.RelocateVM_Task(spec=relocate_spec)
|
||||||
self.conn.wait_for_task(task)
|
conn.wait_for_task(task)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"vm": vm_name,
|
"vm": vm_name,
|
||||||
@ -108,6 +111,7 @@ class VCenterOpsMixin(VSphereMixin):
|
|||||||
vm_name: str,
|
vm_name: str,
|
||||||
disk_label: str,
|
disk_label: str,
|
||||||
target_datastore: str,
|
target_datastore: str,
|
||||||
|
host: str | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Move a specific VM disk to a different datastore.
|
"""Move a specific VM disk to a different datastore.
|
||||||
|
|
||||||
@ -115,15 +119,17 @@ class VCenterOpsMixin(VSphereMixin):
|
|||||||
vm_name: Name of the VM
|
vm_name: Name of the VM
|
||||||
disk_label: Label of the disk (e.g., 'Hard disk 1')
|
disk_label: Label of the disk (e.g., 'Hard disk 1')
|
||||||
target_datastore: Target datastore name
|
target_datastore: Target datastore name
|
||||||
|
host: Managed ESXi host to target (default: the default host)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict with migration details
|
Dict with migration details
|
||||||
"""
|
"""
|
||||||
vm = self.conn.find_vm(vm_name)
|
conn = self._conn(host)
|
||||||
|
vm = conn.find_vm(vm_name)
|
||||||
if not vm:
|
if not vm:
|
||||||
raise ValueError(f"VM '{vm_name}' not found")
|
raise ValueError(f"VM '{vm_name}' not found")
|
||||||
|
|
||||||
ds = self.conn.find_datastore(target_datastore)
|
ds = conn.find_datastore(target_datastore)
|
||||||
if not ds:
|
if not ds:
|
||||||
raise ValueError(f"Datastore '{target_datastore}' not found")
|
raise ValueError(f"Datastore '{target_datastore}' not found")
|
||||||
|
|
||||||
@ -157,7 +163,7 @@ class VCenterOpsMixin(VSphereMixin):
|
|||||||
|
|
||||||
# Perform the relocation
|
# Perform the relocation
|
||||||
task = vm.RelocateVM_Task(spec=relocate_spec)
|
task = vm.RelocateVM_Task(spec=relocate_spec)
|
||||||
self.conn.wait_for_task(task)
|
conn.wait_for_task(task)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"vm": vm_name,
|
"vm": vm_name,
|
||||||
@ -176,7 +182,7 @@ class VCenterOpsMixin(VSphereMixin):
|
|||||||
description="Convert a VM to a template (idempotent - safe to call on existing template)",
|
description="Convert a VM to a template (idempotent - safe to call on existing template)",
|
||||||
annotations=ToolAnnotations(destructiveHint=True, idempotentHint=True),
|
annotations=ToolAnnotations(destructiveHint=True, idempotentHint=True),
|
||||||
)
|
)
|
||||||
def convert_to_template(self, vm_name: str) -> dict[str, Any]:
|
def convert_to_template(self, vm_name: str, host: str | None = None) -> dict[str, Any]:
|
||||||
"""Convert a VM to a template.
|
"""Convert a VM to a template.
|
||||||
|
|
||||||
The VM must be powered off. Once converted, it cannot be powered on
|
The VM must be powered off. Once converted, it cannot be powered on
|
||||||
@ -184,11 +190,13 @@ class VCenterOpsMixin(VSphereMixin):
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
vm_name: Name of the VM to convert
|
vm_name: Name of the VM to convert
|
||||||
|
host: Managed ESXi host to target (default: the default host)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict with conversion details
|
Dict with conversion details
|
||||||
"""
|
"""
|
||||||
vm = self.conn.find_vm(vm_name)
|
conn = self._conn(host)
|
||||||
|
vm = conn.find_vm(vm_name)
|
||||||
if not vm:
|
if not vm:
|
||||||
raise ValueError(f"VM '{vm_name}' not found")
|
raise ValueError(f"VM '{vm_name}' not found")
|
||||||
|
|
||||||
@ -219,17 +227,20 @@ class VCenterOpsMixin(VSphereMixin):
|
|||||||
self,
|
self,
|
||||||
template_name: str,
|
template_name: str,
|
||||||
resource_pool: str | None = None,
|
resource_pool: str | None = None,
|
||||||
|
host: str | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Convert a template back to a regular VM.
|
"""Convert a template back to a regular VM.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
template_name: Name of the template
|
template_name: Name of the template
|
||||||
resource_pool: Resource pool for the VM (optional)
|
resource_pool: Resource pool for the VM (optional)
|
||||||
|
host: Managed ESXi host to target (default: the default host)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict with conversion details
|
Dict with conversion details
|
||||||
"""
|
"""
|
||||||
vm = self.conn.find_vm(template_name)
|
conn = self._conn(host)
|
||||||
|
vm = conn.find_vm(template_name)
|
||||||
if not vm:
|
if not vm:
|
||||||
raise ValueError(f"Template '{template_name}' not found")
|
raise ValueError(f"Template '{template_name}' not found")
|
||||||
|
|
||||||
@ -242,20 +253,20 @@ class VCenterOpsMixin(VSphereMixin):
|
|||||||
|
|
||||||
# Get resource pool
|
# Get resource pool
|
||||||
if resource_pool:
|
if resource_pool:
|
||||||
pool = self._find_resource_pool(resource_pool)
|
pool = self._find_resource_pool(resource_pool, conn)
|
||||||
if not pool:
|
if not pool:
|
||||||
raise ValueError(f"Resource pool '{resource_pool}' not found")
|
raise ValueError(f"Resource pool '{resource_pool}' not found")
|
||||||
else:
|
else:
|
||||||
pool = self.conn.resource_pool
|
pool = conn.resource_pool
|
||||||
|
|
||||||
# Get a host from the resource pool
|
# Get a host from the resource pool
|
||||||
host = None
|
target_host = None
|
||||||
if hasattr(pool, "owner") and hasattr(pool.owner, "host"):
|
if hasattr(pool, "owner") and hasattr(pool.owner, "host"):
|
||||||
hosts = pool.owner.host
|
hosts = pool.owner.host
|
||||||
if hosts:
|
if hosts:
|
||||||
host = hosts[0]
|
target_host = hosts[0]
|
||||||
|
|
||||||
vm.MarkAsVirtualMachine(pool=pool, host=host)
|
vm.MarkAsVirtualMachine(pool=pool, host=target_host)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"vm": template_name,
|
"vm": template_name,
|
||||||
@ -263,10 +274,10 @@ class VCenterOpsMixin(VSphereMixin):
|
|||||||
"is_template": False,
|
"is_template": False,
|
||||||
}
|
}
|
||||||
|
|
||||||
def _find_resource_pool(self, name: str) -> vim.ResourcePool | None:
|
def _find_resource_pool(self, name: str, conn) -> vim.ResourcePool | None:
|
||||||
"""Find a resource pool by name."""
|
"""Find a resource pool by name."""
|
||||||
container = self.conn.content.viewManager.CreateContainerView(
|
container = conn.content.viewManager.CreateContainerView(
|
||||||
self.conn.content.rootFolder, [vim.ResourcePool], True
|
conn.content.rootFolder, [vim.ResourcePool], True
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
for pool in container.view:
|
for pool in container.view:
|
||||||
@ -287,6 +298,7 @@ class VCenterOpsMixin(VSphereMixin):
|
|||||||
new_vm_name: str,
|
new_vm_name: str,
|
||||||
datastore: str | None = None,
|
datastore: str | None = None,
|
||||||
power_on: bool = False,
|
power_on: bool = False,
|
||||||
|
host: str | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Deploy a new VM from a template.
|
"""Deploy a new VM from a template.
|
||||||
|
|
||||||
@ -295,11 +307,13 @@ class VCenterOpsMixin(VSphereMixin):
|
|||||||
new_vm_name: Name for the new VM
|
new_vm_name: Name for the new VM
|
||||||
datastore: Target datastore (default: same as template)
|
datastore: Target datastore (default: same as template)
|
||||||
power_on: Power on after deployment (default False)
|
power_on: Power on after deployment (default False)
|
||||||
|
host: Managed ESXi host to target (default: the default host)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict with deployment details
|
Dict with deployment details
|
||||||
"""
|
"""
|
||||||
template = self.conn.find_vm(template_name)
|
conn = self._conn(host)
|
||||||
|
template = conn.find_vm(template_name)
|
||||||
if not template:
|
if not template:
|
||||||
raise ValueError(f"Template '{template_name}' not found")
|
raise ValueError(f"Template '{template_name}' not found")
|
||||||
|
|
||||||
@ -307,15 +321,15 @@ class VCenterOpsMixin(VSphereMixin):
|
|||||||
raise ValueError(f"'{template_name}' is not a template")
|
raise ValueError(f"'{template_name}' is not a template")
|
||||||
|
|
||||||
# Check if target VM already exists
|
# Check if target VM already exists
|
||||||
if self.conn.find_vm(new_vm_name):
|
if conn.find_vm(new_vm_name):
|
||||||
raise ValueError(f"VM '{new_vm_name}' already exists")
|
raise ValueError(f"VM '{new_vm_name}' already exists")
|
||||||
|
|
||||||
# Build clone spec
|
# Build clone spec
|
||||||
relocate_spec = vim.vm.RelocateSpec()
|
relocate_spec = vim.vm.RelocateSpec()
|
||||||
relocate_spec.pool = self.conn.resource_pool
|
relocate_spec.pool = conn.resource_pool
|
||||||
|
|
||||||
if datastore:
|
if datastore:
|
||||||
ds = self.conn.find_datastore(datastore)
|
ds = conn.find_datastore(datastore)
|
||||||
if not ds:
|
if not ds:
|
||||||
raise ValueError(f"Datastore '{datastore}' not found")
|
raise ValueError(f"Datastore '{datastore}' not found")
|
||||||
relocate_spec.datastore = ds
|
relocate_spec.datastore = ds
|
||||||
@ -326,14 +340,14 @@ class VCenterOpsMixin(VSphereMixin):
|
|||||||
clone_spec.template = False # Create VM, not another template
|
clone_spec.template = False # Create VM, not another template
|
||||||
|
|
||||||
# Get target folder
|
# Get target folder
|
||||||
folder = self.conn.datacenter.vmFolder
|
folder = conn.datacenter.vmFolder
|
||||||
|
|
||||||
# Clone the template
|
# Clone the template
|
||||||
task = template.Clone(folder=folder, name=new_vm_name, spec=clone_spec)
|
task = template.Clone(folder=folder, name=new_vm_name, spec=clone_spec)
|
||||||
self.conn.wait_for_task(task)
|
conn.wait_for_task(task)
|
||||||
|
|
||||||
# Get the new VM info
|
# Get the new VM info
|
||||||
new_vm = self.conn.find_vm(new_vm_name)
|
new_vm = conn.find_vm(new_vm_name)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"vm": new_vm_name,
|
"vm": new_vm_name,
|
||||||
@ -352,12 +366,16 @@ class VCenterOpsMixin(VSphereMixin):
|
|||||||
description="List VM folders in the datacenter",
|
description="List VM folders in the datacenter",
|
||||||
annotations=ToolAnnotations(readOnlyHint=True),
|
annotations=ToolAnnotations(readOnlyHint=True),
|
||||||
)
|
)
|
||||||
def list_folders(self) -> list[dict[str, Any]]:
|
def list_folders(self, host: str | None = None) -> list[dict[str, Any]]:
|
||||||
"""List all VM folders in the datacenter.
|
"""List all VM folders in the datacenter.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
host: Managed ESXi host to target (default: the default host)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of folder details
|
List of folder details
|
||||||
"""
|
"""
|
||||||
|
conn = self._conn(host)
|
||||||
folders = []
|
folders = []
|
||||||
|
|
||||||
def _collect_folders(folder: vim.Folder, path: str = ""):
|
def _collect_folders(folder: vim.Folder, path: str = ""):
|
||||||
@ -375,7 +393,7 @@ class VCenterOpsMixin(VSphereMixin):
|
|||||||
_collect_folders(child, current_path)
|
_collect_folders(child, current_path)
|
||||||
|
|
||||||
# Start from VM folder
|
# Start from VM folder
|
||||||
vm_folder = self.conn.datacenter.vmFolder
|
vm_folder = conn.datacenter.vmFolder
|
||||||
_collect_folders(vm_folder)
|
_collect_folders(vm_folder)
|
||||||
|
|
||||||
return folders
|
return folders
|
||||||
@ -389,22 +407,25 @@ class VCenterOpsMixin(VSphereMixin):
|
|||||||
self,
|
self,
|
||||||
folder_name: str,
|
folder_name: str,
|
||||||
parent_path: str | None = None,
|
parent_path: str | None = None,
|
||||||
|
host: str | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Create a new VM folder.
|
"""Create a new VM folder.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
folder_name: Name for the new folder
|
folder_name: Name for the new folder
|
||||||
parent_path: Path to parent folder (None = root vm folder)
|
parent_path: Path to parent folder (None = root vm folder)
|
||||||
|
host: Managed ESXi host to target (default: the default host)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict with folder details
|
Dict with folder details
|
||||||
"""
|
"""
|
||||||
|
conn = self._conn(host)
|
||||||
if parent_path:
|
if parent_path:
|
||||||
parent = self._find_folder_by_path(parent_path)
|
parent = self._find_folder_by_path(parent_path, conn)
|
||||||
if not parent:
|
if not parent:
|
||||||
raise ValueError(f"Parent folder '{parent_path}' not found")
|
raise ValueError(f"Parent folder '{parent_path}' not found")
|
||||||
else:
|
else:
|
||||||
parent = self.conn.datacenter.vmFolder
|
parent = conn.datacenter.vmFolder
|
||||||
|
|
||||||
parent.CreateFolder(name=folder_name)
|
parent.CreateFolder(name=folder_name)
|
||||||
|
|
||||||
@ -415,11 +436,11 @@ class VCenterOpsMixin(VSphereMixin):
|
|||||||
"path": f"{parent_path}/{folder_name}" if parent_path else f"vm/{folder_name}",
|
"path": f"{parent_path}/{folder_name}" if parent_path else f"vm/{folder_name}",
|
||||||
}
|
}
|
||||||
|
|
||||||
def _find_folder_by_path(self, path: str) -> vim.Folder | None:
|
def _find_folder_by_path(self, path: str, conn) -> vim.Folder | None:
|
||||||
"""Find a folder by its path (e.g., 'vm/Production/WebServers')."""
|
"""Find a folder by its path (e.g., 'vm/Production/WebServers')."""
|
||||||
parts = [p for p in path.split("/") if p and p != "vm"]
|
parts = [p for p in path.split("/") if p and p != "vm"]
|
||||||
|
|
||||||
current = self.conn.datacenter.vmFolder
|
current = conn.datacenter.vmFolder
|
||||||
for part in parts:
|
for part in parts:
|
||||||
found = None
|
found = None
|
||||||
if hasattr(current, "childEntity"):
|
if hasattr(current, "childEntity"):
|
||||||
@ -442,21 +463,24 @@ class VCenterOpsMixin(VSphereMixin):
|
|||||||
self,
|
self,
|
||||||
vm_name: str,
|
vm_name: str,
|
||||||
folder_path: str,
|
folder_path: str,
|
||||||
|
host: str | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Move a VM to a different folder.
|
"""Move a VM to a different folder.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
vm_name: Name of the VM to move
|
vm_name: Name of the VM to move
|
||||||
folder_path: Path to target folder
|
folder_path: Path to target folder
|
||||||
|
host: Managed ESXi host to target (default: the default host)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict with move details
|
Dict with move details
|
||||||
"""
|
"""
|
||||||
vm = self.conn.find_vm(vm_name)
|
conn = self._conn(host)
|
||||||
|
vm = conn.find_vm(vm_name)
|
||||||
if not vm:
|
if not vm:
|
||||||
raise ValueError(f"VM '{vm_name}' not found")
|
raise ValueError(f"VM '{vm_name}' not found")
|
||||||
|
|
||||||
folder = self._find_folder_by_path(folder_path)
|
folder = self._find_folder_by_path(folder_path, conn)
|
||||||
if not folder:
|
if not folder:
|
||||||
raise ValueError(f"Folder '{folder_path}' not found")
|
raise ValueError(f"Folder '{folder_path}' not found")
|
||||||
|
|
||||||
@ -465,7 +489,7 @@ class VCenterOpsMixin(VSphereMixin):
|
|||||||
|
|
||||||
# Move the VM
|
# Move the VM
|
||||||
task = folder.MoveIntoFolder_Task([vm])
|
task = folder.MoveIntoFolder_Task([vm])
|
||||||
self.conn.wait_for_task(task)
|
conn.wait_for_task(task)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"vm": vm_name,
|
"vm": vm_name,
|
||||||
@ -487,17 +511,20 @@ class VCenterOpsMixin(VSphereMixin):
|
|||||||
self,
|
self,
|
||||||
max_count: int = 20,
|
max_count: int = 20,
|
||||||
entity_name: str | None = None,
|
entity_name: str | None = None,
|
||||||
|
host: str | None = None,
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
"""List recent tasks from vCenter.
|
"""List recent tasks from vCenter.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
max_count: Maximum number of tasks to return (default 20)
|
max_count: Maximum number of tasks to return (default 20)
|
||||||
entity_name: Filter by entity name (optional)
|
entity_name: Filter by entity name (optional)
|
||||||
|
host: Managed ESXi host to target (default: the default host)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of task details
|
List of task details
|
||||||
"""
|
"""
|
||||||
task_manager = self.conn.content.taskManager
|
conn = self._conn(host)
|
||||||
|
task_manager = conn.content.taskManager
|
||||||
recent_tasks = task_manager.recentTask
|
recent_tasks = task_manager.recentTask
|
||||||
|
|
||||||
tasks = []
|
tasks = []
|
||||||
@ -543,6 +570,7 @@ class VCenterOpsMixin(VSphereMixin):
|
|||||||
max_count: int = 50,
|
max_count: int = 50,
|
||||||
event_types: list[str] | None = None,
|
event_types: list[str] | None = None,
|
||||||
hours_back: int = 24,
|
hours_back: int = 24,
|
||||||
|
host: str | None = None,
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
"""List recent events from vCenter.
|
"""List recent events from vCenter.
|
||||||
|
|
||||||
@ -550,11 +578,13 @@ class VCenterOpsMixin(VSphereMixin):
|
|||||||
max_count: Maximum number of events (default 50)
|
max_count: Maximum number of events (default 50)
|
||||||
event_types: Filter by event type names (optional)
|
event_types: Filter by event type names (optional)
|
||||||
hours_back: How many hours back to look (default 24)
|
hours_back: How many hours back to look (default 24)
|
||||||
|
host: Managed ESXi host to target (default: the default host)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of event details
|
List of event details
|
||||||
"""
|
"""
|
||||||
event_manager = self.conn.content.eventManager
|
conn = self._conn(host)
|
||||||
|
event_manager = conn.content.eventManager
|
||||||
|
|
||||||
# Create filter spec
|
# Create filter spec
|
||||||
filter_spec = vim.event.EventFilterSpec()
|
filter_spec = vim.event.EventFilterSpec()
|
||||||
@ -605,15 +635,19 @@ class VCenterOpsMixin(VSphereMixin):
|
|||||||
description="List all clusters in the datacenter",
|
description="List all clusters in the datacenter",
|
||||||
annotations=ToolAnnotations(readOnlyHint=True),
|
annotations=ToolAnnotations(readOnlyHint=True),
|
||||||
)
|
)
|
||||||
def list_clusters(self) -> list[dict[str, Any]]:
|
def list_clusters(self, host: str | None = None) -> list[dict[str, Any]]:
|
||||||
"""List all clusters in the datacenter.
|
"""List all clusters in the datacenter.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
host: Managed ESXi host to target (default: the default host)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of cluster details with DRS/HA status
|
List of cluster details with DRS/HA status
|
||||||
"""
|
"""
|
||||||
|
conn = self._conn(host)
|
||||||
clusters = []
|
clusters = []
|
||||||
|
|
||||||
for entity in self.conn.datacenter.hostFolder.childEntity:
|
for entity in conn.datacenter.hostFolder.childEntity:
|
||||||
if isinstance(entity, vim.ClusterComputeResource):
|
if isinstance(entity, vim.ClusterComputeResource):
|
||||||
drs_config = entity.configuration.drsConfig
|
drs_config = entity.configuration.drsConfig
|
||||||
ha_config = entity.configuration.dasConfig
|
ha_config = entity.configuration.dasConfig
|
||||||
@ -652,16 +686,19 @@ class VCenterOpsMixin(VSphereMixin):
|
|||||||
def get_drs_recommendations(
|
def get_drs_recommendations(
|
||||||
self,
|
self,
|
||||||
cluster_name: str,
|
cluster_name: str,
|
||||||
|
host: str | None = None,
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
"""Get DRS recommendations for a cluster.
|
"""Get DRS recommendations for a cluster.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
cluster_name: Name of the cluster
|
cluster_name: Name of the cluster
|
||||||
|
host: Managed ESXi host to target (default: the default host)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of DRS recommendations
|
List of DRS recommendations
|
||||||
"""
|
"""
|
||||||
cluster = self._find_cluster(cluster_name)
|
conn = self._conn(host)
|
||||||
|
cluster = self._find_cluster(cluster_name, conn)
|
||||||
if not cluster:
|
if not cluster:
|
||||||
raise ValueError(f"Cluster '{cluster_name}' not found")
|
raise ValueError(f"Cluster '{cluster_name}' not found")
|
||||||
|
|
||||||
@ -701,9 +738,9 @@ class VCenterOpsMixin(VSphereMixin):
|
|||||||
|
|
||||||
return recommendations
|
return recommendations
|
||||||
|
|
||||||
def _find_cluster(self, name: str) -> vim.ClusterComputeResource | None:
|
def _find_cluster(self, name: str, conn) -> vim.ClusterComputeResource | None:
|
||||||
"""Find a cluster by name."""
|
"""Find a cluster by name."""
|
||||||
for entity in self.conn.datacenter.hostFolder.childEntity:
|
for entity in conn.datacenter.hostFolder.childEntity:
|
||||||
if isinstance(entity, vim.ClusterComputeResource) and entity.name == name:
|
if isinstance(entity, vim.ClusterComputeResource) and entity.name == name:
|
||||||
return entity
|
return entity
|
||||||
return None
|
return None
|
||||||
|
|||||||
@ -125,6 +125,7 @@ class VMLifecycleMixin(VSphereMixin):
|
|||||||
iso_path: str | None = None,
|
iso_path: str | None = None,
|
||||||
iso_datastore: str | None = None,
|
iso_datastore: str | None = None,
|
||||||
boot_from_iso: bool = True,
|
boot_from_iso: bool = True,
|
||||||
|
host: str | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Create a new virtual machine with specified configuration.
|
"""Create a new virtual machine with specified configuration.
|
||||||
|
|
||||||
@ -143,18 +144,20 @@ class VMLifecycleMixin(VSphereMixin):
|
|||||||
iso_path: ISO path on a datastore (e.g. 'iso/ubuntu.iso') to mount in the CD/DVD drive
|
iso_path: ISO path on a datastore (e.g. 'iso/ubuntu.iso') to mount in the CD/DVD drive
|
||||||
iso_datastore: Datastore holding the ISO (default: the VM's datastore)
|
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 the boot order
|
boot_from_iso: When an ISO is given, put the CD/DVD first in the boot order
|
||||||
|
host: Managed ESXi host to target (default: the default host)
|
||||||
"""
|
"""
|
||||||
|
conn = self._conn(host)
|
||||||
# Resolve datastore
|
# Resolve datastore
|
||||||
datastore_obj = self.conn.datastore
|
datastore_obj = conn.datastore
|
||||||
if datastore:
|
if datastore:
|
||||||
datastore_obj = self.conn.find_datastore(datastore)
|
datastore_obj = conn.find_datastore(datastore)
|
||||||
if not datastore_obj:
|
if not datastore_obj:
|
||||||
raise ValueError(f"Datastore '{datastore}' not found")
|
raise ValueError(f"Datastore '{datastore}' not found")
|
||||||
|
|
||||||
# Resolve network
|
# Resolve network
|
||||||
network_obj = self.conn.network
|
network_obj = conn.network
|
||||||
if network:
|
if network:
|
||||||
network_obj = self.conn.find_network(network)
|
network_obj = conn.find_network(network)
|
||||||
if not network_obj:
|
if not network_obj:
|
||||||
raise ValueError(f"Network '{network}' not found")
|
raise ValueError(f"Network '{network}' not found")
|
||||||
|
|
||||||
@ -263,10 +266,10 @@ class VMLifecycleMixin(VSphereMixin):
|
|||||||
vm_spec.deviceChange = device_specs
|
vm_spec.deviceChange = device_specs
|
||||||
|
|
||||||
# Create VM
|
# Create VM
|
||||||
task = self.conn.datacenter.vmFolder.CreateVM_Task(
|
task = conn.datacenter.vmFolder.CreateVM_Task(
|
||||||
config=vm_spec, pool=self.conn.resource_pool
|
config=vm_spec, pool=conn.resource_pool
|
||||||
)
|
)
|
||||||
self.conn.wait_for_task(task)
|
conn.wait_for_task(task)
|
||||||
|
|
||||||
return f"VM '{name}' created successfully"
|
return f"VM '{name}' created successfully"
|
||||||
|
|
||||||
@ -281,31 +284,33 @@ class VMLifecycleMixin(VSphereMixin):
|
|||||||
new_name: str,
|
new_name: str,
|
||||||
power_on: bool = False,
|
power_on: bool = False,
|
||||||
datastore: str | None = None,
|
datastore: str | None = None,
|
||||||
|
host: str | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Clone a VM from a template or existing VM."""
|
"""Clone a VM from a template or existing VM."""
|
||||||
template_vm = self.conn.find_vm(template_name)
|
conn = self._conn(host)
|
||||||
|
template_vm = conn.find_vm(template_name)
|
||||||
if not template_vm:
|
if not template_vm:
|
||||||
raise ValueError(f"Template VM '{template_name}' not found")
|
raise ValueError(f"Template VM '{template_name}' not found")
|
||||||
|
|
||||||
vm_folder = template_vm.parent
|
vm_folder = template_vm.parent
|
||||||
if not isinstance(vm_folder, vim.Folder):
|
if not isinstance(vm_folder, vim.Folder):
|
||||||
vm_folder = self.conn.datacenter.vmFolder
|
vm_folder = conn.datacenter.vmFolder
|
||||||
|
|
||||||
# Resolve datastore
|
# Resolve datastore
|
||||||
datastore_obj = self.conn.datastore
|
datastore_obj = conn.datastore
|
||||||
if datastore:
|
if datastore:
|
||||||
datastore_obj = self.conn.find_datastore(datastore)
|
datastore_obj = conn.find_datastore(datastore)
|
||||||
if not datastore_obj:
|
if not datastore_obj:
|
||||||
raise ValueError(f"Datastore '{datastore}' not found")
|
raise ValueError(f"Datastore '{datastore}' not found")
|
||||||
|
|
||||||
resource_pool = template_vm.resourcePool or self.conn.resource_pool
|
resource_pool = template_vm.resourcePool or conn.resource_pool
|
||||||
relocate_spec = vim.vm.RelocateSpec(pool=resource_pool, datastore=datastore_obj)
|
relocate_spec = vim.vm.RelocateSpec(pool=resource_pool, datastore=datastore_obj)
|
||||||
clone_spec = vim.vm.CloneSpec(
|
clone_spec = vim.vm.CloneSpec(
|
||||||
powerOn=power_on, template=False, location=relocate_spec
|
powerOn=power_on, template=False, location=relocate_spec
|
||||||
)
|
)
|
||||||
|
|
||||||
task = template_vm.Clone(folder=vm_folder, name=new_name, spec=clone_spec)
|
task = template_vm.Clone(folder=vm_folder, name=new_name, spec=clone_spec)
|
||||||
self.conn.wait_for_task(task)
|
conn.wait_for_task(task)
|
||||||
|
|
||||||
return f"VM '{new_name}' cloned from '{template_name}'"
|
return f"VM '{new_name}' cloned from '{template_name}'"
|
||||||
|
|
||||||
@ -314,19 +319,20 @@ class VMLifecycleMixin(VSphereMixin):
|
|||||||
description="Delete a virtual machine permanently (powers off if running)",
|
description="Delete a virtual machine permanently (powers off if running)",
|
||||||
annotations=ToolAnnotations(destructiveHint=True, idempotentHint=True),
|
annotations=ToolAnnotations(destructiveHint=True, idempotentHint=True),
|
||||||
)
|
)
|
||||||
def delete_vm(self, name: str) -> str:
|
def delete_vm(self, name: str, host: str | None = None) -> str:
|
||||||
"""Delete a virtual machine permanently."""
|
"""Delete a virtual machine permanently."""
|
||||||
vm = self.conn.find_vm(name)
|
conn = self._conn(host)
|
||||||
|
vm = conn.find_vm(name)
|
||||||
if not vm:
|
if not vm:
|
||||||
raise ValueError(f"VM '{name}' not found")
|
raise ValueError(f"VM '{name}' not found")
|
||||||
|
|
||||||
# Power off if running
|
# Power off if running
|
||||||
if vm.runtime.powerState == vim.VirtualMachine.PowerState.poweredOn:
|
if vm.runtime.powerState == vim.VirtualMachine.PowerState.poweredOn:
|
||||||
task = vm.PowerOffVM_Task()
|
task = vm.PowerOffVM_Task()
|
||||||
self.conn.wait_for_task(task)
|
conn.wait_for_task(task)
|
||||||
|
|
||||||
task = vm.Destroy_Task()
|
task = vm.Destroy_Task()
|
||||||
self.conn.wait_for_task(task)
|
conn.wait_for_task(task)
|
||||||
|
|
||||||
return f"VM '{name}' deleted"
|
return f"VM '{name}' deleted"
|
||||||
|
|
||||||
@ -341,9 +347,11 @@ class VMLifecycleMixin(VSphereMixin):
|
|||||||
cpu: int | None = None,
|
cpu: int | None = None,
|
||||||
memory_mb: int | None = None,
|
memory_mb: int | None = None,
|
||||||
annotation: str | None = None,
|
annotation: str | None = None,
|
||||||
|
host: str | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Reconfigure VM hardware settings."""
|
"""Reconfigure VM hardware settings."""
|
||||||
vm = self.conn.find_vm(name)
|
conn = self._conn(host)
|
||||||
|
vm = conn.find_vm(name)
|
||||||
if not vm:
|
if not vm:
|
||||||
raise ValueError(f"VM '{name}' not found")
|
raise ValueError(f"VM '{name}' not found")
|
||||||
|
|
||||||
@ -366,7 +374,7 @@ class VMLifecycleMixin(VSphereMixin):
|
|||||||
return f"No changes specified for VM '{name}'"
|
return f"No changes specified for VM '{name}'"
|
||||||
|
|
||||||
task = vm.ReconfigVM_Task(spec=config_spec)
|
task = vm.ReconfigVM_Task(spec=config_spec)
|
||||||
self.conn.wait_for_task(task)
|
conn.wait_for_task(task)
|
||||||
|
|
||||||
return f"VM '{name}' reconfigured: {', '.join(changes)}"
|
return f"VM '{name}' reconfigured: {', '.join(changes)}"
|
||||||
|
|
||||||
@ -375,13 +383,14 @@ class VMLifecycleMixin(VSphereMixin):
|
|||||||
description="Rename a virtual machine",
|
description="Rename a virtual machine",
|
||||||
annotations=ToolAnnotations(destructiveHint=False, idempotentHint=True),
|
annotations=ToolAnnotations(destructiveHint=False, idempotentHint=True),
|
||||||
)
|
)
|
||||||
def rename_vm(self, name: str, new_name: str) -> str:
|
def rename_vm(self, name: str, new_name: str, host: str | None = None) -> str:
|
||||||
"""Rename a virtual machine."""
|
"""Rename a virtual machine."""
|
||||||
vm = self.conn.find_vm(name)
|
conn = self._conn(host)
|
||||||
|
vm = conn.find_vm(name)
|
||||||
if not vm:
|
if not vm:
|
||||||
raise ValueError(f"VM '{name}' not found")
|
raise ValueError(f"VM '{name}' not found")
|
||||||
|
|
||||||
task = vm.Rename_Task(newName=new_name)
|
task = vm.Rename_Task(newName=new_name)
|
||||||
self.conn.wait_for_task(task)
|
conn.wait_for_task(task)
|
||||||
|
|
||||||
return f"VM renamed from '{name}' to '{new_name}'"
|
return f"VM renamed from '{name}' to '{new_name}'"
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user