Multi-host support: manage a list of ESXi servers
Adds a pluggable server inventory and per-host connection routing: - servers.py: load_servers() returns the managed host list. Today it reads ESXI_HOST[_N] env vars (a suffixed host inherits the unsuffixed USER/PASS/INSECURE/NETWORK); this one function is the seam an API-backed source replaces later. Servers are identified by host. - connection_manager.py: ConnectionManager holds the inventory and lazily connects per host (one dead host doesn't block startup or the others), keyed by host, with a default. - mixins/_base.py: VSphereMixin base gives every mixin self.conn (default host) and self._conn(host) for per-call routing. All 13 mixins now take the manager instead of a single connection. - server.py builds the manager from load_servers() (falling back to the single VCENTER_* server) and eager-connects the default. - New list_servers tool; list_vms/get_vm_info take an optional host arg as the per-call routing pattern. Verified across two live hosts: default + host='10.22.22.222' route to the right box; unknown host gives a clear error.
This commit is contained in:
parent
b743da7666
commit
2930318125
73
src/mcvsphere/connection_manager.py
Normal file
73
src/mcvsphere/connection_manager.py
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
"""Manages VMware connections to multiple ESXi hosts, keyed by host.
|
||||||
|
|
||||||
|
Connections are established lazily on first use so one unreachable host does
|
||||||
|
not block startup or the other hosts. Tools select a host per call via the
|
||||||
|
``host`` argument; omitting it uses the default (first) server.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
from mcvsphere.connection import VMwareConnection
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from mcvsphere.config import Settings
|
||||||
|
from mcvsphere.servers import ServerConfig
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class ConnectionManager:
|
||||||
|
"""Holds the managed server inventory and their lazy connections."""
|
||||||
|
|
||||||
|
def __init__(self, servers: list["ServerConfig"], settings: "Settings"):
|
||||||
|
if not servers:
|
||||||
|
raise ValueError("No ESXi servers configured (set ESXI_HOST[_N] vars)")
|
||||||
|
self._servers: dict[str, ServerConfig] = {s.host: s for s in servers}
|
||||||
|
self._default_host: str = servers[0].host
|
||||||
|
self._settings = settings
|
||||||
|
self._connections: dict[str, VMwareConnection] = {}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def hosts(self) -> list[str]:
|
||||||
|
"""All managed host identifiers."""
|
||||||
|
return list(self._servers)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def default_host(self) -> str:
|
||||||
|
return self._default_host
|
||||||
|
|
||||||
|
def get(self, host: str | None = None) -> VMwareConnection:
|
||||||
|
"""Return the connection for ``host`` (default if None), connecting lazily."""
|
||||||
|
target = host or self._default_host
|
||||||
|
if target not in self._servers:
|
||||||
|
known = ", ".join(self._servers) or "(none)"
|
||||||
|
raise ValueError(f"Unknown server '{target}'. Managed hosts: {known}")
|
||||||
|
if target not in self._connections:
|
||||||
|
logger.info("Connecting to ESXi host %s", target)
|
||||||
|
self._connections[target] = VMwareConnection(
|
||||||
|
self._servers[target].to_settings(self._settings)
|
||||||
|
)
|
||||||
|
return self._connections[target]
|
||||||
|
|
||||||
|
def describe(self) -> list[dict[str, Any]]:
|
||||||
|
"""Read-only summary of managed servers (no connection attempts)."""
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"host": host,
|
||||||
|
"user": cfg.user,
|
||||||
|
"network": cfg.network,
|
||||||
|
"insecure": cfg.insecure,
|
||||||
|
"default": host == self._default_host,
|
||||||
|
"connected": host in self._connections,
|
||||||
|
}
|
||||||
|
for host, cfg in self._servers.items()
|
||||||
|
]
|
||||||
|
|
||||||
|
def disconnect_all(self) -> None:
|
||||||
|
for conn in self._connections.values():
|
||||||
|
try:
|
||||||
|
conn.disconnect()
|
||||||
|
except Exception:
|
||||||
|
logger.warning("Error disconnecting", exc_info=True)
|
||||||
|
self._connections.clear()
|
||||||
31
src/mcvsphere/mixins/_base.py
Normal file
31
src/mcvsphere/mixins/_base.py
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
"""Shared base for vSphere tool mixins — routes to the target ESXi host.
|
||||||
|
|
||||||
|
Tools that expose multi-host selection take a ``host`` argument and call
|
||||||
|
``self._conn(host)``. Tools that don't use ``self.conn``, which resolves to the
|
||||||
|
default (first) managed host. Both go through the ConnectionManager, so
|
||||||
|
connections are shared and established lazily.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from fastmcp.contrib.mcp_mixin import MCPMixin
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from mcvsphere.connection import VMwareConnection
|
||||||
|
from mcvsphere.connection_manager import ConnectionManager
|
||||||
|
|
||||||
|
|
||||||
|
class VSphereMixin(MCPMixin):
|
||||||
|
"""Base class giving every mixin host-aware connection access."""
|
||||||
|
|
||||||
|
def __init__(self, manager: "ConnectionManager"):
|
||||||
|
self.manager = manager
|
||||||
|
|
||||||
|
@property
|
||||||
|
def conn(self) -> "VMwareConnection":
|
||||||
|
"""The default host's connection (back-compat for single-host tools)."""
|
||||||
|
return self.manager.get()
|
||||||
|
|
||||||
|
def _conn(self, host: str | None = None) -> "VMwareConnection":
|
||||||
|
"""Resolve the connection for a specific managed host (default if None)."""
|
||||||
|
return self.manager.get(host)
|
||||||
@ -6,20 +6,19 @@ from datetime import datetime, timedelta
|
|||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
from fastmcp.contrib.mcp_mixin import MCPMixin, mcp_tool
|
from fastmcp.contrib.mcp_mixin import mcp_tool
|
||||||
from mcp.types import ToolAnnotations
|
from mcp.types import ToolAnnotations
|
||||||
from pyVmomi import vim
|
from pyVmomi import vim
|
||||||
|
|
||||||
|
from mcvsphere.mixins._base import VSphereMixin
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from mcvsphere.connection import VMwareConnection
|
pass
|
||||||
|
|
||||||
|
|
||||||
class ConsoleMixin(MCPMixin):
|
class ConsoleMixin(VSphereMixin):
|
||||||
"""VM console operations - screenshots and VMware Tools monitoring."""
|
"""VM console operations - screenshots and VMware Tools monitoring."""
|
||||||
|
|
||||||
def __init__(self, conn: "VMwareConnection"):
|
|
||||||
self.conn = conn
|
|
||||||
|
|
||||||
@mcp_tool(
|
@mcp_tool(
|
||||||
name="wait_for_vm_tools",
|
name="wait_for_vm_tools",
|
||||||
description="Wait for VMware Tools to become available on a VM. Useful after powering on a VM.",
|
description="Wait for VMware Tools to become available on a VM. Useful after powering on a VM.",
|
||||||
|
|||||||
@ -2,20 +2,19 @@
|
|||||||
|
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
from fastmcp.contrib.mcp_mixin import MCPMixin, mcp_tool
|
from fastmcp.contrib.mcp_mixin import mcp_tool
|
||||||
from mcp.types import ToolAnnotations
|
from mcp.types import ToolAnnotations
|
||||||
from pyVmomi import vim
|
from pyVmomi import vim
|
||||||
|
|
||||||
|
from mcvsphere.mixins._base import VSphereMixin
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from mcvsphere.connection import VMwareConnection
|
pass
|
||||||
|
|
||||||
|
|
||||||
class DiskManagementMixin(MCPMixin):
|
class DiskManagementMixin(VSphereMixin):
|
||||||
"""Virtual disk and ISO management tools."""
|
"""Virtual disk and ISO management tools."""
|
||||||
|
|
||||||
def __init__(self, conn: "VMwareConnection"):
|
|
||||||
self.conn = conn
|
|
||||||
|
|
||||||
def _get_next_disk_unit_number(self, vm: vim.VirtualMachine) -> tuple[int, vim.vm.device.VirtualSCSIController]:
|
def _get_next_disk_unit_number(self, vm: vim.VirtualMachine) -> tuple[int, vim.vm.device.VirtualSCSIController]:
|
||||||
"""Find the next available SCSI unit number and controller."""
|
"""Find the next available SCSI unit number and controller."""
|
||||||
scsi_controllers = []
|
scsi_controllers = []
|
||||||
|
|||||||
@ -4,20 +4,19 @@ import base64
|
|||||||
import time
|
import time
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
from fastmcp.contrib.mcp_mixin import MCPMixin, mcp_tool
|
from fastmcp.contrib.mcp_mixin import mcp_tool
|
||||||
from mcp.types import ToolAnnotations
|
from mcp.types import ToolAnnotations
|
||||||
from pyVmomi import vim
|
from pyVmomi import vim
|
||||||
|
|
||||||
|
from mcvsphere.mixins._base import VSphereMixin
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from mcvsphere.connection import VMwareConnection
|
pass
|
||||||
|
|
||||||
|
|
||||||
class GuestOpsMixin(MCPMixin):
|
class GuestOpsMixin(VSphereMixin):
|
||||||
"""Guest OS operations (requires VMware Tools running in the VM)."""
|
"""Guest OS operations (requires VMware Tools running in the VM)."""
|
||||||
|
|
||||||
def __init__(self, conn: "VMwareConnection"):
|
|
||||||
self.conn = conn
|
|
||||||
|
|
||||||
def _get_guest_auth(
|
def _get_guest_auth(
|
||||||
self, username: str, password: str
|
self, username: str, password: str
|
||||||
) -> vim.vm.guest.NamePasswordAuthentication:
|
) -> vim.vm.guest.NamePasswordAuthentication:
|
||||||
|
|||||||
@ -2,20 +2,19 @@
|
|||||||
|
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
from fastmcp.contrib.mcp_mixin import MCPMixin, mcp_tool
|
from fastmcp.contrib.mcp_mixin import mcp_tool
|
||||||
from mcp.types import ToolAnnotations
|
from mcp.types import ToolAnnotations
|
||||||
from pyVmomi import vim
|
from pyVmomi import vim
|
||||||
|
|
||||||
|
from mcvsphere.mixins._base import VSphereMixin
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from mcvsphere.connection import VMwareConnection
|
pass
|
||||||
|
|
||||||
|
|
||||||
class HostManagementMixin(MCPMixin):
|
class HostManagementMixin(VSphereMixin):
|
||||||
"""ESXi host management tools."""
|
"""ESXi host management tools."""
|
||||||
|
|
||||||
def __init__(self, conn: "VMwareConnection"):
|
|
||||||
self.conn = conn
|
|
||||||
|
|
||||||
def _get_host(self) -> vim.HostSystem:
|
def _get_host(self) -> vim.HostSystem:
|
||||||
"""Get the ESXi host system."""
|
"""Get the ESXi host system."""
|
||||||
for entity in self.conn.datacenter.hostFolder.childEntity:
|
for entity in self.conn.datacenter.hostFolder.childEntity:
|
||||||
|
|||||||
@ -3,20 +3,19 @@
|
|||||||
from datetime import UTC, datetime, timedelta
|
from datetime import UTC, datetime, timedelta
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
from fastmcp.contrib.mcp_mixin import MCPMixin, mcp_tool
|
from fastmcp.contrib.mcp_mixin import mcp_tool
|
||||||
from mcp.types import ToolAnnotations
|
from mcp.types import ToolAnnotations
|
||||||
from pyVmomi import vim
|
from pyVmomi import vim
|
||||||
|
|
||||||
|
from mcvsphere.mixins._base import VSphereMixin
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from mcvsphere.connection import VMwareConnection
|
pass
|
||||||
|
|
||||||
|
|
||||||
class MonitoringMixin(MCPMixin):
|
class MonitoringMixin(VSphereMixin):
|
||||||
"""VM and host monitoring tools."""
|
"""VM and host monitoring tools."""
|
||||||
|
|
||||||
def __init__(self, conn: "VMwareConnection"):
|
|
||||||
self.conn = conn
|
|
||||||
|
|
||||||
@mcp_tool(
|
@mcp_tool(
|
||||||
name="get_vm_stats",
|
name="get_vm_stats",
|
||||||
description="Get current performance statistics for a virtual machine",
|
description="Get current performance statistics for a virtual machine",
|
||||||
|
|||||||
@ -2,20 +2,19 @@
|
|||||||
|
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
from fastmcp.contrib.mcp_mixin import MCPMixin, mcp_tool
|
from fastmcp.contrib.mcp_mixin import mcp_tool
|
||||||
from mcp.types import ToolAnnotations
|
from mcp.types import ToolAnnotations
|
||||||
from pyVmomi import vim
|
from pyVmomi import vim
|
||||||
|
|
||||||
|
from mcvsphere.mixins._base import VSphereMixin
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from mcvsphere.connection import VMwareConnection
|
pass
|
||||||
|
|
||||||
|
|
||||||
class NICManagementMixin(MCPMixin):
|
class NICManagementMixin(VSphereMixin):
|
||||||
"""Virtual network adapter management tools."""
|
"""Virtual network adapter management tools."""
|
||||||
|
|
||||||
def __init__(self, conn: "VMwareConnection"):
|
|
||||||
self.conn = conn
|
|
||||||
|
|
||||||
def _find_nic_by_label(
|
def _find_nic_by_label(
|
||||||
self, vm: vim.VirtualMachine, label: str
|
self, vm: vim.VirtualMachine, label: str
|
||||||
) -> vim.vm.device.VirtualEthernetCard | None:
|
) -> vim.vm.device.VirtualEthernetCard | None:
|
||||||
|
|||||||
@ -14,20 +14,19 @@ from pathlib import Path
|
|||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
from fastmcp.contrib.mcp_mixin import MCPMixin, mcp_tool
|
from fastmcp.contrib.mcp_mixin import mcp_tool
|
||||||
from mcp.types import ToolAnnotations
|
from mcp.types import ToolAnnotations
|
||||||
from pyVmomi import vim
|
from pyVmomi import vim
|
||||||
|
|
||||||
|
from mcvsphere.mixins._base import VSphereMixin
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from mcvsphere.connection import VMwareConnection
|
pass
|
||||||
|
|
||||||
|
|
||||||
class OVFManagementMixin(MCPMixin):
|
class OVFManagementMixin(VSphereMixin):
|
||||||
"""OVF/OVA deployment and export tools."""
|
"""OVF/OVA deployment and export tools."""
|
||||||
|
|
||||||
def __init__(self, conn: "VMwareConnection"):
|
|
||||||
self.conn = conn
|
|
||||||
|
|
||||||
def _extract_ova(self, ova_path: str) -> tuple[str, str, list[str]]:
|
def _extract_ova(self, ova_path: str) -> tuple[str, str, list[str]]:
|
||||||
"""Extract OVA file and return (temp_dir, ovf_path, disk_files)."""
|
"""Extract OVA file and return (temp_dir, ovf_path, disk_files)."""
|
||||||
temp_dir = tempfile.mkdtemp(prefix="ovf_")
|
temp_dir = tempfile.mkdtemp(prefix="ovf_")
|
||||||
|
|||||||
@ -2,20 +2,19 @@
|
|||||||
|
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from fastmcp.contrib.mcp_mixin import MCPMixin, mcp_tool
|
from fastmcp.contrib.mcp_mixin import mcp_tool
|
||||||
from mcp.types import ToolAnnotations
|
from mcp.types import ToolAnnotations
|
||||||
from pyVmomi import vim
|
from pyVmomi import vim
|
||||||
|
|
||||||
|
from mcvsphere.mixins._base import VSphereMixin
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from mcvsphere.connection import VMwareConnection
|
pass
|
||||||
|
|
||||||
|
|
||||||
class PowerOpsMixin(MCPMixin):
|
class PowerOpsMixin(VSphereMixin):
|
||||||
"""VM power management tools."""
|
"""VM power management tools."""
|
||||||
|
|
||||||
def __init__(self, conn: "VMwareConnection"):
|
|
||||||
self.conn = conn
|
|
||||||
|
|
||||||
@mcp_tool(
|
@mcp_tool(
|
||||||
name="power_on",
|
name="power_on",
|
||||||
description="Power on a virtual machine",
|
description="Power on a virtual machine",
|
||||||
|
|||||||
@ -2,20 +2,19 @@
|
|||||||
|
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
from fastmcp.contrib.mcp_mixin import MCPMixin, mcp_resource, mcp_tool
|
from fastmcp.contrib.mcp_mixin import mcp_resource, mcp_tool
|
||||||
from mcp.types import ToolAnnotations
|
from mcp.types import ToolAnnotations
|
||||||
from pyVmomi import vim
|
from pyVmomi import vim
|
||||||
|
|
||||||
|
from mcvsphere.mixins._base import VSphereMixin
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from mcvsphere.connection import VMwareConnection
|
pass
|
||||||
|
|
||||||
|
|
||||||
class ResourcesMixin(MCPMixin):
|
class ResourcesMixin(VSphereMixin):
|
||||||
"""MCP Resources for vSphere infrastructure."""
|
"""MCP Resources for vSphere infrastructure."""
|
||||||
|
|
||||||
def __init__(self, conn: "VMwareConnection"):
|
|
||||||
self.conn = conn
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
# Datastore File Browser (templated resource)
|
# Datastore File Browser (templated resource)
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@ -5,15 +5,17 @@ import socket
|
|||||||
import time
|
import time
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
from fastmcp.contrib.mcp_mixin import MCPMixin, mcp_tool
|
from fastmcp.contrib.mcp_mixin import mcp_tool
|
||||||
from mcp.types import ToolAnnotations
|
from mcp.types import ToolAnnotations
|
||||||
from pyVmomi import vim
|
from pyVmomi import vim
|
||||||
|
|
||||||
|
from mcvsphere.mixins._base import VSphereMixin
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from mcvsphere.connection import VMwareConnection
|
pass
|
||||||
|
|
||||||
|
|
||||||
class SerialPortMixin(MCPMixin):
|
class SerialPortMixin(VSphereMixin):
|
||||||
"""Serial port management for VM network console access.
|
"""Serial port management for VM network console access.
|
||||||
|
|
||||||
Network serial ports allow telnet/TCP connections to VM consoles,
|
Network serial ports allow telnet/TCP connections to VM consoles,
|
||||||
@ -26,9 +28,6 @@ class SerialPortMixin(MCPMixin):
|
|||||||
- tcp+ssl: Encrypted SSL over TCP
|
- tcp+ssl: Encrypted SSL over TCP
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, conn: "VMwareConnection"):
|
|
||||||
self.conn = conn
|
|
||||||
|
|
||||||
def _get_serial_port(self, vm: vim.VirtualMachine) -> vim.vm.device.VirtualSerialPort | None:
|
def _get_serial_port(self, vm: vim.VirtualMachine) -> vim.vm.device.VirtualSerialPort | None:
|
||||||
"""Find existing serial port with URI backing on a VM."""
|
"""Find existing serial port with URI backing on a VM."""
|
||||||
if not vm.config:
|
if not vm.config:
|
||||||
|
|||||||
@ -2,20 +2,19 @@
|
|||||||
|
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
from fastmcp.contrib.mcp_mixin import MCPMixin, mcp_tool
|
from fastmcp.contrib.mcp_mixin import mcp_tool
|
||||||
from mcp.types import ToolAnnotations
|
from mcp.types import ToolAnnotations
|
||||||
from pyVmomi import vim
|
from pyVmomi import vim
|
||||||
|
|
||||||
|
from mcvsphere.mixins._base import VSphereMixin
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from mcvsphere.connection import VMwareConnection
|
pass
|
||||||
|
|
||||||
|
|
||||||
class SnapshotsMixin(MCPMixin):
|
class SnapshotsMixin(VSphereMixin):
|
||||||
"""VM snapshot management tools."""
|
"""VM snapshot management tools."""
|
||||||
|
|
||||||
def __init__(self, conn: "VMwareConnection"):
|
|
||||||
self.conn = conn
|
|
||||||
|
|
||||||
def _get_snapshot_tree(
|
def _get_snapshot_tree(
|
||||||
self, snapshots: list, parent_path: str = ""
|
self, snapshots: list, parent_path: str = ""
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
|
|||||||
@ -3,19 +3,31 @@
|
|||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
from fastmcp.contrib.mcp_mixin import MCPMixin, mcp_tool
|
from fastmcp.contrib.mcp_mixin import mcp_tool
|
||||||
from mcp.types import ToolAnnotations
|
from mcp.types import ToolAnnotations
|
||||||
from pyVmomi import vim
|
from pyVmomi import vim
|
||||||
|
|
||||||
|
from mcvsphere.mixins._base import VSphereMixin
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from mcvsphere.connection import VMwareConnection
|
pass
|
||||||
|
|
||||||
|
|
||||||
class VCenterOpsMixin(MCPMixin):
|
class VCenterOpsMixin(VSphereMixin):
|
||||||
"""vCenter-specific operations (require vCenter, not just ESXi)."""
|
"""vCenter-specific operations (require vCenter, not just ESXi)."""
|
||||||
|
|
||||||
def __init__(self, conn: "VMwareConnection"):
|
@mcp_tool(
|
||||||
self.conn = conn
|
name="list_servers",
|
||||||
|
description="List the ESXi hosts this server manages. Pass a host's value as the 'host' argument to other tools to target it.",
|
||||||
|
annotations=ToolAnnotations(readOnlyHint=True),
|
||||||
|
)
|
||||||
|
def list_servers(self) -> list[dict[str, Any]]:
|
||||||
|
"""List managed ESXi hosts and their connection status.
|
||||||
|
|
||||||
|
The ``host`` field is the identifier to pass as the ``host`` argument on
|
||||||
|
host-aware tools; omitting it targets the ``default`` host.
|
||||||
|
"""
|
||||||
|
return self.manager.describe()
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
# Storage vMotion (works even on single-host vCenter)
|
# Storage vMotion (works even on single-host vCenter)
|
||||||
|
|||||||
@ -2,29 +2,34 @@
|
|||||||
|
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
from fastmcp.contrib.mcp_mixin import MCPMixin, mcp_tool
|
from fastmcp.contrib.mcp_mixin import mcp_tool
|
||||||
from mcp.types import ToolAnnotations
|
from mcp.types import ToolAnnotations
|
||||||
from pyVmomi import vim
|
from pyVmomi import vim
|
||||||
|
|
||||||
|
from mcvsphere.mixins._base import VSphereMixin
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from mcvsphere.connection import VMwareConnection
|
pass
|
||||||
|
|
||||||
|
|
||||||
class VMLifecycleMixin(MCPMixin):
|
class VMLifecycleMixin(VSphereMixin):
|
||||||
"""VM lifecycle management tools - CRUD operations for virtual machines."""
|
"""VM lifecycle management tools - CRUD operations for virtual machines."""
|
||||||
|
|
||||||
def __init__(self, conn: "VMwareConnection"):
|
|
||||||
self.conn = conn
|
|
||||||
|
|
||||||
@mcp_tool(
|
@mcp_tool(
|
||||||
name="list_vms",
|
name="list_vms",
|
||||||
description="List all virtual machines in the vSphere inventory",
|
description="List all virtual machines in the vSphere inventory",
|
||||||
annotations=ToolAnnotations(readOnlyHint=True),
|
annotations=ToolAnnotations(readOnlyHint=True),
|
||||||
)
|
)
|
||||||
def list_vms(self) -> list[dict[str, Any]]:
|
def list_vms(self, host: str | None = None) -> list[dict[str, Any]]:
|
||||||
"""List all virtual machines with basic info."""
|
"""List all virtual machines with basic info.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
host: Managed ESXi host to query (default: the default host).
|
||||||
|
Use list_servers to see available hosts.
|
||||||
|
"""
|
||||||
|
conn = self._conn(host)
|
||||||
vms = []
|
vms = []
|
||||||
for vm in self.conn.get_all_vms():
|
for vm in conn.get_all_vms():
|
||||||
vms.append(
|
vms.append(
|
||||||
{
|
{
|
||||||
"name": vm.name,
|
"name": vm.name,
|
||||||
@ -41,9 +46,15 @@ class VMLifecycleMixin(MCPMixin):
|
|||||||
description="Get detailed information about a specific virtual machine",
|
description="Get detailed information about a specific virtual machine",
|
||||||
annotations=ToolAnnotations(readOnlyHint=True),
|
annotations=ToolAnnotations(readOnlyHint=True),
|
||||||
)
|
)
|
||||||
def get_vm_info(self, name: str) -> dict[str, Any]:
|
def get_vm_info(self, name: str, host: str | None = None) -> dict[str, Any]:
|
||||||
"""Get detailed VM information including hardware, network, and storage."""
|
"""Get detailed VM information including hardware, network, and storage.
|
||||||
vm = self.conn.find_vm(name)
|
|
||||||
|
Args:
|
||||||
|
name: VM name
|
||||||
|
host: Managed ESXi host to query (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")
|
||||||
|
|
||||||
|
|||||||
@ -8,7 +8,7 @@ from fastmcp import FastMCP
|
|||||||
|
|
||||||
from mcvsphere.auth import create_auth_provider
|
from mcvsphere.auth import create_auth_provider
|
||||||
from mcvsphere.config import Settings, get_settings
|
from mcvsphere.config import Settings, get_settings
|
||||||
from mcvsphere.connection import VMwareConnection
|
from mcvsphere.connection_manager import ConnectionManager
|
||||||
from mcvsphere.middleware import RBACMiddleware
|
from mcvsphere.middleware import RBACMiddleware
|
||||||
from mcvsphere.mixins import (
|
from mcvsphere.mixins import (
|
||||||
ConsoleMixin,
|
ConsoleMixin,
|
||||||
@ -25,6 +25,7 @@ from mcvsphere.mixins import (
|
|||||||
VCenterOpsMixin,
|
VCenterOpsMixin,
|
||||||
VMLifecycleMixin,
|
VMLifecycleMixin,
|
||||||
)
|
)
|
||||||
|
from mcvsphere.servers import ServerConfig, load_servers
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -74,25 +75,43 @@ def create_server(settings: Settings | None = None) -> FastMCP:
|
|||||||
mcp.add_middleware(RBACMiddleware())
|
mcp.add_middleware(RBACMiddleware())
|
||||||
logger.info("RBAC middleware enabled - permissions enforced via OAuth groups")
|
logger.info("RBAC middleware enabled - permissions enforced via OAuth groups")
|
||||||
|
|
||||||
# Create shared VMware connection
|
# Build the managed-server inventory (pluggable source: ESXI_HOST[_N] env
|
||||||
logger.info("Connecting to VMware vCenter/ESXi...")
|
# now, an API later). Fall back to the single VCENTER_* server if no
|
||||||
conn = VMwareConnection(settings)
|
# ESXI_* hosts are configured.
|
||||||
|
servers = load_servers()
|
||||||
|
if not servers:
|
||||||
|
servers = [
|
||||||
|
ServerConfig(
|
||||||
|
host=settings.vcenter_host,
|
||||||
|
user=settings.vcenter_user,
|
||||||
|
password=settings.vcenter_password,
|
||||||
|
insecure=settings.vcenter_insecure,
|
||||||
|
network=settings.vcenter_network,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
manager = ConnectionManager(servers, settings)
|
||||||
|
logger.info(
|
||||||
|
"Managing %d ESXi host(s); connecting to default %s...",
|
||||||
|
len(manager.hosts),
|
||||||
|
manager.default_host,
|
||||||
|
)
|
||||||
|
manager.get() # eager-connect the default host (fail fast); others are lazy
|
||||||
|
|
||||||
# Create and register all mixins
|
# Create and register all mixins
|
||||||
mixins = [
|
mixins = [
|
||||||
VMLifecycleMixin(conn),
|
VMLifecycleMixin(manager),
|
||||||
PowerOpsMixin(conn),
|
PowerOpsMixin(manager),
|
||||||
SnapshotsMixin(conn),
|
SnapshotsMixin(manager),
|
||||||
MonitoringMixin(conn),
|
MonitoringMixin(manager),
|
||||||
GuestOpsMixin(conn),
|
GuestOpsMixin(manager),
|
||||||
ResourcesMixin(conn),
|
ResourcesMixin(manager),
|
||||||
DiskManagementMixin(conn),
|
DiskManagementMixin(manager),
|
||||||
NICManagementMixin(conn),
|
NICManagementMixin(manager),
|
||||||
OVFManagementMixin(conn),
|
OVFManagementMixin(manager),
|
||||||
HostManagementMixin(conn),
|
HostManagementMixin(manager),
|
||||||
VCenterOpsMixin(conn),
|
VCenterOpsMixin(manager),
|
||||||
ConsoleMixin(conn),
|
ConsoleMixin(manager),
|
||||||
SerialPortMixin(conn),
|
SerialPortMixin(manager),
|
||||||
]
|
]
|
||||||
|
|
||||||
tool_count = 0
|
tool_count = 0
|
||||||
|
|||||||
112
src/mcvsphere/servers.py
Normal file
112
src/mcvsphere/servers.py
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
"""ESXi server inventory — the pluggable source of hosts to manage.
|
||||||
|
|
||||||
|
This module is the single seam between "where the server list comes from" and
|
||||||
|
the rest of the app. Today ``load_servers`` reads ``ESXI_HOST[_N]`` environment
|
||||||
|
variables (from ``.env`` or the process environment). When mcvsphere becomes an
|
||||||
|
HTTP service, replace ``load_servers`` with an API-backed implementation —
|
||||||
|
nothing downstream depends on the source, only on the ``ServerConfig`` shape.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from dotenv import dotenv_values
|
||||||
|
from pydantic import BaseModel, SecretStr
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from mcvsphere.config import Settings
|
||||||
|
|
||||||
|
|
||||||
|
class ServerConfig(BaseModel):
|
||||||
|
"""A single ESXi host to manage. Servers are identified by ``host``."""
|
||||||
|
|
||||||
|
host: str
|
||||||
|
user: str
|
||||||
|
password: SecretStr
|
||||||
|
insecure: bool = True
|
||||||
|
network: str = "VM Network"
|
||||||
|
|
||||||
|
def to_settings(self, base: "Settings") -> "Settings":
|
||||||
|
"""Derive a per-server Settings from the base settings.
|
||||||
|
|
||||||
|
Connection fields are overridden with this server's values; everything
|
||||||
|
else (transport, OAuth, logging) is inherited so a single VMwareConnection
|
||||||
|
can be built per host without duplicating global config.
|
||||||
|
"""
|
||||||
|
return base.model_copy(
|
||||||
|
update={
|
||||||
|
"vcenter_host": self.host,
|
||||||
|
"vcenter_user": self.user,
|
||||||
|
"vcenter_password": self.password,
|
||||||
|
"vcenter_insecure": self.insecure,
|
||||||
|
"vcenter_network": self.network,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _as_bool(value: str | None, default: bool = True) -> bool:
|
||||||
|
if value is None or value == "":
|
||||||
|
return default
|
||||||
|
return str(value).lower() in ("true", "1", "yes", "on")
|
||||||
|
|
||||||
|
|
||||||
|
def load_servers(env_file: str = ".env") -> list[ServerConfig]:
|
||||||
|
"""Return the list of ESXi servers to manage.
|
||||||
|
|
||||||
|
Source seam — currently the ``ESXI_HOST[_N]`` env-var families:
|
||||||
|
|
||||||
|
ESXI_HOST / ESXI_USER / ESXI_PASS [/ ESXI_INSECURE / ESXI_NETWORK]
|
||||||
|
ESXI_HOST_1 / ESXI_USER_1 / ESXI_PASS_1 [/ ...]
|
||||||
|
ESXI_HOST_2 / ...
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- Servers are keyed by ``host``; the first (unsuffixed ``ESXI_HOST``) is the
|
||||||
|
default used when a tool call omits ``host``.
|
||||||
|
- A suffixed host that omits its own USER/PASS/INSECURE/NETWORK inherits the
|
||||||
|
unsuffixed ``ESXI_USER``/``ESXI_PASS``/``ESXI_INSECURE``/``ESXI_NETWORK``.
|
||||||
|
- ``.env`` and the real environment are merged (environment wins), so the
|
||||||
|
list works whether vars are exported or only present in ``.env``.
|
||||||
|
"""
|
||||||
|
env: dict[str, str | None] = {**dotenv_values(env_file), **os.environ}
|
||||||
|
|
||||||
|
base_user = env.get("ESXI_USER")
|
||||||
|
base_pass = env.get("ESXI_PASS")
|
||||||
|
base_insecure = env.get("ESXI_INSECURE")
|
||||||
|
base_network = env.get("ESXI_NETWORK")
|
||||||
|
|
||||||
|
# Discover suffixes in order: "" (base), then _1, _2, ... while present.
|
||||||
|
suffixes: list[str] = []
|
||||||
|
if env.get("ESXI_HOST"):
|
||||||
|
suffixes.append("")
|
||||||
|
index = 1
|
||||||
|
while env.get(f"ESXI_HOST_{index}"):
|
||||||
|
suffixes.append(f"_{index}")
|
||||||
|
index += 1
|
||||||
|
|
||||||
|
servers: list[ServerConfig] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for suffix in suffixes:
|
||||||
|
host = env.get(f"ESXI_HOST{suffix}")
|
||||||
|
if not host or host in seen:
|
||||||
|
continue
|
||||||
|
user = env.get(f"ESXI_USER{suffix}") or base_user
|
||||||
|
password = env.get(f"ESXI_PASS{suffix}") or base_pass
|
||||||
|
if not user or not password:
|
||||||
|
# Incomplete credentials and no base fallback — skip this host.
|
||||||
|
continue
|
||||||
|
insecure = env.get(f"ESXI_INSECURE{suffix}")
|
||||||
|
if insecure is None:
|
||||||
|
insecure = base_insecure
|
||||||
|
network = env.get(f"ESXI_NETWORK{suffix}") or base_network or "VM Network"
|
||||||
|
|
||||||
|
seen.add(host)
|
||||||
|
servers.append(
|
||||||
|
ServerConfig(
|
||||||
|
host=host,
|
||||||
|
user=user,
|
||||||
|
password=SecretStr(password),
|
||||||
|
insecure=_as_bool(insecure),
|
||||||
|
network=network,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return servers
|
||||||
Loading…
x
Reference in New Issue
Block a user