Compare commits
No commits in common. "main" and "feature/oauth-authentication" have entirely different histories.
main
...
feature/oa
@ -1,82 +0,0 @@
|
|||||||
# mcvsphere OAuth Configuration Template
|
|
||||||
# Copy to .env and configure for your environment
|
|
||||||
#
|
|
||||||
# Usage:
|
|
||||||
# cp .env.oauth.example .env
|
|
||||||
# # Edit .env with your values
|
|
||||||
# docker compose -f docker-compose.oauth-standalone.yml up -d
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
# Docker Compose
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
COMPOSE_PROJECT_NAME=mcvsphere
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
# vCenter Connection (Required)
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
VCENTER_HOST=vcenter.example.com
|
|
||||||
VCENTER_USER=mcpservice@vsphere.local
|
|
||||||
VCENTER_PASSWORD=your-secure-password
|
|
||||||
|
|
||||||
# Optional: Skip SSL verification (dev only - use false in production)
|
|
||||||
VCENTER_INSECURE=false
|
|
||||||
|
|
||||||
# Optional: Specify defaults (auto-detected if not set)
|
|
||||||
# VCENTER_DATACENTER=Datacenter
|
|
||||||
# VCENTER_CLUSTER=Cluster
|
|
||||||
# VCENTER_DATASTORE=datastore1
|
|
||||||
# VCENTER_NETWORK=VM Network
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
# MCP Transport (Required for OAuth)
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
MCP_TRANSPORT=streamable-http
|
|
||||||
MCP_HOST=0.0.0.0
|
|
||||||
MCP_PORT=8080
|
|
||||||
|
|
||||||
# Your public domain (must match Caddy proxy)
|
|
||||||
MCP_DOMAIN=mcp.example.com
|
|
||||||
|
|
||||||
# TLS mode: 'internal' for self-signed (dev), remove for auto HTTPS (prod)
|
|
||||||
MCP_TLS_MODE=internal
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
# OAuth / OIDC Provider (Required)
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
OAUTH_ENABLED=true
|
|
||||||
|
|
||||||
# OIDC Discovery URL (ends with /.well-known/openid-configuration)
|
|
||||||
# Examples:
|
|
||||||
# Authentik: https://auth.example.com/application/o/mcvsphere/
|
|
||||||
# Keycloak: https://keycloak.example.com/realms/myrealm
|
|
||||||
# Auth0: https://myapp.auth0.com/
|
|
||||||
# Okta: https://myorg.okta.com/oauth2/default
|
|
||||||
OAUTH_ISSUER_URL=https://auth.example.com/application/o/mcvsphere/
|
|
||||||
|
|
||||||
# OAuth Client Credentials (from your OIDC provider)
|
|
||||||
OAUTH_CLIENT_ID=your-client-id
|
|
||||||
OAUTH_CLIENT_SECRET=your-client-secret
|
|
||||||
|
|
||||||
# Public callback URL (must be accessible from browser)
|
|
||||||
OAUTH_BASE_URL=https://mcp.example.com
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
# RBAC Permission Groups
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
# Create these groups in your OIDC provider and assign users:
|
|
||||||
#
|
|
||||||
# | Group | Access Level |
|
|
||||||
# |------------------------|---------------------------------|
|
|
||||||
# | vsphere-super-admins | Full control (all 94 tools) |
|
|
||||||
# | vsphere-host-admins | Host operations + VM management |
|
|
||||||
# | vsphere-admins | VM lifecycle management |
|
|
||||||
# | vsphere-operators | Power ops + snapshots |
|
|
||||||
# | vsphere-readers | Read-only |
|
|
||||||
#
|
|
||||||
# Users without any vsphere-* group will be denied access (default-deny).
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
# Optional Settings
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
# LOG_LEVEL=INFO
|
|
||||||
# MCVSPHERE_VERSION=0.2.2
|
|
||||||
14
Dockerfile
14
Dockerfile
@ -48,11 +48,8 @@ ENV PATH="/app/.venv/bin:$PATH"
|
|||||||
ENV PYTHONUNBUFFERED=1
|
ENV PYTHONUNBUFFERED=1
|
||||||
ENV PYTHONDONTWRITEBYTECODE=1
|
ENV PYTHONDONTWRITEBYTECODE=1
|
||||||
|
|
||||||
# Default transport - override with MCP_TRANSPORT env var
|
# Default to SSE transport for Docker
|
||||||
# stdio: Direct CLI usage (default for local)
|
ENV MCP_TRANSPORT=sse
|
||||||
# sse: Server-Sent Events (legacy HTTP)
|
|
||||||
# streamable-http: HTTP with OAuth support (required for multi-user)
|
|
||||||
ENV MCP_TRANSPORT=streamable-http
|
|
||||||
ENV MCP_HOST=0.0.0.0
|
ENV MCP_HOST=0.0.0.0
|
||||||
ENV MCP_PORT=8080
|
ENV MCP_PORT=8080
|
||||||
|
|
||||||
@ -61,9 +58,10 @@ USER mcpuser
|
|||||||
|
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
|
|
||||||
# Health check - works with both SSE and streamable-http
|
# Health check
|
||||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \
|
||||||
CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/.well-known/oauth-authorization-server')" || exit 1
|
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8080')" || exit 1
|
||||||
|
|
||||||
# Run the MCP server - transport configured via environment
|
# Run the MCP server
|
||||||
ENTRYPOINT ["mcvsphere"]
|
ENTRYPOINT ["mcvsphere"]
|
||||||
|
CMD ["--transport", "sse"]
|
||||||
|
|||||||
@ -1,17 +1,17 @@
|
|||||||
# OAuth & RBAC Architecture for mcvsphere
|
# OAuth Architecture for vSphere MCP Server
|
||||||
|
|
||||||
## Overview
|
## The Problem
|
||||||
|
|
||||||
mcvsphere supports multi-user OAuth 2.1 authentication with Role-Based Access Control (RBAC). This enables:
|
We need to add authentication to the MCP server so that:
|
||||||
|
1. Users authenticate via **OAuth 2.1 / OIDC** (using Authentik as IdP)
|
||||||
|
2. The MCP server knows WHO is making requests (for audit logging)
|
||||||
|
3. vCenter permissions are respected per-user
|
||||||
|
|
||||||
1. **Single Sign-On** via any OIDC provider (Authentik, Keycloak, Auth0, etc.)
|
**Challenge:** vCenter 7.0.3 doesn't support OAuth token exchange (RFC 8693), so we can't pass OAuth tokens directly to vCenter.
|
||||||
2. **User Identity** for audit logging - know WHO made each request
|
|
||||||
3. **Group-Based Permissions** - control what users can do based on OAuth groups
|
|
||||||
4. **Audit Trail** - every tool invocation logged with user identity and timing
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Architecture
|
## Architecture Overview
|
||||||
|
|
||||||
```
|
```
|
||||||
┌─────────────────────────────────────────────────────────────────┐
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
@ -22,352 +22,360 @@ mcvsphere supports multi-user OAuth 2.1 authentication with Role-Based Access Co
|
|||||||
│ (browser opens for login)
|
│ (browser opens for login)
|
||||||
│
|
│
|
||||||
┌────────────────────────────▼────────────────────────────────────┐
|
┌────────────────────────────▼────────────────────────────────────┐
|
||||||
│ OIDC Provider │
|
│ Authentik │
|
||||||
│ (Authentik, Keycloak, Auth0, etc.) │
|
│ (Self-hosted OIDC IdP) │
|
||||||
│ │
|
│ │
|
||||||
│ - Issues JWT access tokens │
|
│ - Issues JWT access tokens │
|
||||||
│ - Validates user credentials │
|
│ - Validates user credentials │
|
||||||
│ - Includes groups claim in token │
|
│ - Includes user identity in token (sub, email, groups) │
|
||||||
└────────────────────────────┬────────────────────────────────────┘
|
└────────────────────────────┬────────────────────────────────────┘
|
||||||
│
|
│
|
||||||
│ 2. JWT Bearer token
|
│ 2. JWT Bearer token
|
||||||
│ Authorization: Bearer <jwt>
|
│ Authorization: Bearer <jwt>
|
||||||
│
|
│
|
||||||
┌────────────────────────────▼────────────────────────────────────┐
|
┌────────────────────────────▼────────────────────────────────────┐
|
||||||
│ mcvsphere │
|
│ vSphere MCP Server │
|
||||||
│ (FastMCP + pyvmomi) │
|
│ (FastMCP + pyvmomi) │
|
||||||
│ │
|
│ │
|
||||||
│ ┌─────────────────────────────────────────────────────────┐ │
|
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||||
│ │ OIDCProxy (FastMCP) │ │
|
│ │ OIDCProxy (FastMCP) │ │
|
||||||
│ │ - Validates JWT signature via JWKS endpoint │ │
|
│ │ - Validates JWT signature via Authentik JWKS │ │
|
||||||
│ │ - Extracts user identity (preferred_username, email) │ │
|
│ │ - Extracts user identity (preferred_username) │ │
|
||||||
│ │ - Extracts groups from token claims │ │
|
│ │ - Makes user available via ctx.request_context.user │ │
|
||||||
│ └─────────────────────────────────────────────────────────┘ │
|
│ └─────────────────────────────────────────────────────────┘ │
|
||||||
│ │ │
|
│ │ │
|
||||||
│ ▼ │
|
│ ▼ │
|
||||||
│ ┌─────────────────────────────────────────────────────────┐ │
|
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||||
│ │ RBACMiddleware │ │
|
│ │ Credential Broker │ │
|
||||||
│ │ - Intercepts ALL tool calls via on_call_tool() │ │
|
│ │ - Maps OAuth user → vCenter credentials │ │
|
||||||
│ │ - Maps OAuth groups → Permission levels │ │
|
│ │ - Caches pyvmomi connections per-user │ │
|
||||||
│ │ - Denies access if user lacks required permission │ │
|
│ │ - Retrieves passwords from Vault / env vars │ │
|
||||||
│ │ - Logs audit events with user identity │ │
|
|
||||||
│ └─────────────────────────────────────────────────────────┘ │
|
│ └─────────────────────────────────────────────────────────┘ │
|
||||||
│ │ │
|
│ │ │
|
||||||
│ ▼ │
|
│ ▼ │
|
||||||
│ ┌─────────────────────────────────────────────────────────┐ │
|
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||||
│ │ VMware Tools (94) │ │
|
│ │ Audit Logger │ │
|
||||||
│ │ - Execute vCenter/ESXi operations via pyvmomi │ │
|
│ │ - Logs all tool invocations with OAuth identity │ │
|
||||||
│ │ - Single service account connection to vCenter │ │
|
│ │ - "User ryan@example.com powered on VM web-server" │ │
|
||||||
│ └─────────────────────────────────────────────────────────┘ │
|
│ └─────────────────────────────────────────────────────────┘ │
|
||||||
└────────────────────────────┬────────────────────────────────────┘
|
└────────────────────────────┬────────────────────────────────────┘
|
||||||
│
|
│
|
||||||
│ 3. pyvmomi (service account)
|
│ 3. pyvmomi (as mapped user)
|
||||||
│
|
│
|
||||||
┌────────────────────────────▼────────────────────────────────────┐
|
┌────────────────────────────▼────────────────────────────────────┐
|
||||||
│ vCenter / ESXi │
|
│ vCenter 7.0.3 │
|
||||||
│ - Receives API calls as service account │
|
│ - Receives API calls as the actual user │
|
||||||
│ - mcvsphere audit logs show real user identity │
|
│ - Native audit logs show real user identity │
|
||||||
|
│ - vCenter permissions apply naturally │
|
||||||
└─────────────────────────────────────────────────────────────────┘
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## RBAC Permission Model
|
## User Mapping Strategies
|
||||||
|
|
||||||
### Permission Levels
|
Since we can't exchange OAuth tokens for vCenter tokens, we need a "credential broker":
|
||||||
|
|
||||||
mcvsphere defines 5 permission levels, from least to most privileged:
|
| Strategy | How it Works | Security | Use Case |
|
||||||
|
|----------|--------------|----------|----------|
|
||||||
|
| **Service Account** | All requests use one vCenter admin account | Medium | Simple/dev |
|
||||||
|
| **Per-User Mapping** | Map OAuth username → vCenter credentials from Vault | High | Production |
|
||||||
|
| **LDAP Sync** | Same username/password in Authentik and vCenter SSO | Medium | AD environments |
|
||||||
|
|
||||||
| Level | Description | Example Tools |
|
### Recommended: Per-User Mapping with Fallback
|
||||||
|-------|-------------|---------------|
|
|
||||||
| `READ_ONLY` | View-only operations | `list_vms`, `get_vm_info`, `vm_screenshot` |
|
|
||||||
| `POWER_OPS` | Power and snapshot operations | `power_on`, `create_snapshot`, `reboot_guest` |
|
|
||||||
| `VM_LIFECYCLE` | Create/delete/modify VMs | `create_vm`, `clone_vm`, `add_disk`, `deploy_ovf` |
|
|
||||||
| `HOST_ADMIN` | ESXi host operations | `reboot_host`, `enter_maintenance_mode` |
|
|
||||||
| `FULL_ADMIN` | Everything including guest OS ops | `run_command_in_guest`, `restart_service` |
|
|
||||||
|
|
||||||
### OAuth Groups → Permissions
|
|
||||||
|
|
||||||
Users are granted permissions based on their OAuth group memberships:
|
|
||||||
|
|
||||||
| OAuth Group | Permissions Granted |
|
|
||||||
|-------------|---------------------|
|
|
||||||
| `vsphere-readers` | READ_ONLY |
|
|
||||||
| `vsphere-operators` | READ_ONLY, POWER_OPS |
|
|
||||||
| `vsphere-admins` | READ_ONLY, POWER_OPS, VM_LIFECYCLE |
|
|
||||||
| `vsphere-host-admins` | READ_ONLY, POWER_OPS, VM_LIFECYCLE, HOST_ADMIN |
|
|
||||||
| `vsphere-super-admins` | ALL (full access) |
|
|
||||||
|
|
||||||
**Security Note:** Users with NO recognized groups are denied ALL access. There is no default permission.
|
|
||||||
|
|
||||||
### Tool → Permission Mapping
|
|
||||||
|
|
||||||
All 94 tools are mapped to permission levels in `src/mcvsphere/permissions.py`:
|
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# READ_ONLY - 32 tools
|
class CredentialBroker:
|
||||||
"list_vms", "get_vm_info", "list_snapshots", "get_vm_stats", ...
|
"""Maps OAuth users to vCenter credentials."""
|
||||||
|
|
||||||
# POWER_OPS - 14 tools
|
def __init__(self, vcenter_host: str, fallback_user: str = None, fallback_password: str = None):
|
||||||
"power_on", "power_off", "create_snapshot", "revert_to_snapshot", ...
|
self.vcenter_host = vcenter_host
|
||||||
|
self.fallback_user = fallback_user # Service account fallback
|
||||||
|
self.fallback_password = fallback_password
|
||||||
|
self._connections: dict[str, ServiceInstance] = {}
|
||||||
|
|
||||||
# VM_LIFECYCLE - 33 tools
|
def get_connection_for_user(self, oauth_user: dict) -> ServiceInstance:
|
||||||
"create_vm", "clone_vm", "delete_vm", "add_disk", "deploy_ovf", ...
|
"""Get pyvmomi connection for this OAuth user."""
|
||||||
|
username = oauth_user.get("preferred_username")
|
||||||
|
|
||||||
# HOST_ADMIN - 6 tools
|
# Try per-user credentials first
|
||||||
"enter_maintenance_mode", "reboot_host", "shutdown_host", ...
|
try:
|
||||||
|
vcenter_creds = self._lookup_credentials(username)
|
||||||
|
return self._get_or_create_connection(
|
||||||
|
vcenter_creds["user"],
|
||||||
|
vcenter_creds["password"]
|
||||||
|
)
|
||||||
|
except KeyError:
|
||||||
|
# Fall back to service account
|
||||||
|
if self.fallback_user:
|
||||||
|
return self._get_or_create_connection(
|
||||||
|
self.fallback_user,
|
||||||
|
self.fallback_password
|
||||||
|
)
|
||||||
|
raise ValueError(f"No vCenter credentials for user: {username}")
|
||||||
|
|
||||||
# FULL_ADMIN - 11 tools
|
def _lookup_credentials(self, username: str) -> dict:
|
||||||
"run_command_in_guest", "write_guest_file", "restart_service", ...
|
"""Look up vCenter credentials for OAuth user."""
|
||||||
|
# Option 1: Environment variable
|
||||||
|
env_key = f"VCENTER_PASSWORD_{username.upper().replace('@', '_').replace('.', '_')}"
|
||||||
|
if password := os.environ.get(env_key):
|
||||||
|
return {"user": f"{username}@vsphere.local", "password": password}
|
||||||
|
|
||||||
|
# Option 2: HashiCorp Vault (production)
|
||||||
|
# return vault_client.read(f"secret/vcenter/users/{username}")
|
||||||
|
|
||||||
|
raise KeyError(f"No credentials found for {username}")
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Implementation Details
|
## FastMCP OAuth Integration
|
||||||
|
|
||||||
### Key Files
|
### 1. Add OIDCProxy to server.py
|
||||||
|
|
||||||
| File | Purpose |
|
|
||||||
|------|---------|
|
|
||||||
| `src/mcvsphere/auth.py` | OIDCProxy configuration |
|
|
||||||
| `src/mcvsphere/permissions.py` | Permission levels and tool mappings |
|
|
||||||
| `src/mcvsphere/middleware.py` | RBACMiddleware implementation |
|
|
||||||
| `src/mcvsphere/audit.py` | Audit logging with user context |
|
|
||||||
| `src/mcvsphere/server.py` | Server setup with OAuth + RBAC |
|
|
||||||
|
|
||||||
### RBACMiddleware Flow
|
|
||||||
|
|
||||||
```python
|
```python
|
||||||
class RBACMiddleware(Middleware):
|
import os
|
||||||
"""Intercepts all tool calls to enforce permissions."""
|
from fastmcp import FastMCP
|
||||||
|
from fastmcp.server.auth import OIDCProxy
|
||||||
|
|
||||||
async def on_call_tool(self, context, call_next):
|
# Configure OAuth with Authentik
|
||||||
# 1. Extract user from OAuth token
|
auth = OIDCProxy(
|
||||||
claims = self._extract_user_from_context(context.fastmcp_context)
|
# Authentik OIDC Discovery URL
|
||||||
username = claims.get("preferred_username", "unknown")
|
config_url=os.environ["AUTHENTIK_OIDC_URL"],
|
||||||
groups = claims.get("groups", [])
|
# e.g., "https://auth.example.com/application/o/vsphere-mcp/.well-known/openid-configuration"
|
||||||
|
|
||||||
# 2. Check permission
|
# Application credentials from Authentik
|
||||||
tool_name = context.message.name
|
client_id=os.environ["AUTHENTIK_CLIENT_ID"],
|
||||||
if not check_permission(tool_name, groups):
|
client_secret=os.environ["AUTHENTIK_CLIENT_SECRET"],
|
||||||
required = get_required_permission(tool_name)
|
|
||||||
audit_permission_denied(tool_name, {...}, required.value)
|
|
||||||
raise PermissionDeniedError(username, tool_name, required)
|
|
||||||
|
|
||||||
# 3. Execute tool with timing
|
# MCP Server base URL (for redirects)
|
||||||
start = time.perf_counter()
|
base_url=os.environ.get("MCP_BASE_URL", "http://localhost:8000"),
|
||||||
result = await call_next(context)
|
|
||||||
duration_ms = (time.perf_counter() - start) * 1000
|
|
||||||
|
|
||||||
# 4. Audit log
|
# Token validation
|
||||||
audit_log(tool_name, {...}, result="success", duration_ms=duration_ms)
|
required_scopes=["openid", "profile", "email"],
|
||||||
return result
|
|
||||||
|
# Allow Claude Code localhost redirects
|
||||||
|
allowed_client_redirect_uris=["http://localhost:*", "http://127.0.0.1:*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create MCP server with OAuth
|
||||||
|
mcp = FastMCP(
|
||||||
|
"vSphere MCP Server",
|
||||||
|
auth=auth,
|
||||||
|
# Use Streamable HTTP transport for OAuth
|
||||||
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
### Audit Log Format
|
### 2. Access User Identity in Tools
|
||||||
|
|
||||||
```json
|
```python
|
||||||
{
|
from fastmcp import Context
|
||||||
"timestamp": "2025-12-27T08:15:32.123456+00:00",
|
|
||||||
"user": "ryan@example.com",
|
|
||||||
"groups": ["vsphere-admins", "vsphere-operators"],
|
|
||||||
"tool": "power_on",
|
|
||||||
"args": {"vm_name": "web-server"},
|
|
||||||
"duration_ms": 1234.56,
|
|
||||||
"result": "success"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Permission denied events:
|
@mcp.tool()
|
||||||
```json
|
async def power_on_vm(ctx: Context, vm_name: str) -> str:
|
||||||
{
|
"""Power on a virtual machine."""
|
||||||
"timestamp": "2025-12-27T08:15:32.123456+00:00",
|
# Get authenticated user from OAuth token
|
||||||
"user": "guest@example.com",
|
user = ctx.request_context.user
|
||||||
"groups": ["vsphere-readers"],
|
username = user.get("preferred_username", user.get("sub"))
|
||||||
"tool": "delete_vm",
|
|
||||||
"args": {"vm_name": "web-server"},
|
# Get vCenter connection for this user
|
||||||
"required_permission": "vm_lifecycle",
|
broker = get_credential_broker()
|
||||||
"event": "PERMISSION_DENIED"
|
connection = broker.get_connection_for_user(user)
|
||||||
}
|
|
||||||
|
# Execute operation
|
||||||
|
content = connection.RetrieveContent()
|
||||||
|
vm = find_vm(content, vm_name)
|
||||||
|
vm.PowerOnVM_Task()
|
||||||
|
|
||||||
|
# Audit log
|
||||||
|
logger.info(f"User {username} powered on VM {vm_name}")
|
||||||
|
|
||||||
|
return f"VM '{vm_name}' is powering on"
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Configuration
|
## MCP Transport: Streamable HTTP
|
||||||
|
|
||||||
### Environment Variables
|
OAuth requires HTTP transport (not stdio). Use Streamable HTTP:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# In server.py or via environment
|
||||||
|
mcp = FastMCP(
|
||||||
|
"vSphere MCP Server",
|
||||||
|
auth=auth,
|
||||||
|
)
|
||||||
|
|
||||||
|
def main():
|
||||||
|
# Run with HTTP transport for OAuth support
|
||||||
|
mcp.run(transport="streamable-http", host="0.0.0.0", port=8000)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Transport characteristics:**
|
||||||
|
- Single HTTP endpoint (`/mcp`)
|
||||||
|
- POST requests with JSON-RPC body
|
||||||
|
- Server-Sent Events (SSE) for streaming responses
|
||||||
|
- `Authorization: Bearer <token>` header on every request
|
||||||
|
- `Mcp-Session-Id` header for session continuity
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# ═══════════════════════════════════════════════════════════════
|
# Authentik OIDC
|
||||||
# OAuth Configuration
|
AUTHENTIK_OIDC_URL=https://auth.example.com/application/o/vsphere-mcp/.well-known/openid-configuration
|
||||||
# ═══════════════════════════════════════════════════════════════
|
AUTHENTIK_CLIENT_ID=<from-authentik-application>
|
||||||
OAUTH_ENABLED=true
|
AUTHENTIK_CLIENT_SECRET=<from-authentik-application>
|
||||||
OAUTH_ISSUER_URL=https://auth.example.com/application/o/mcvsphere/
|
|
||||||
OAUTH_CLIENT_ID=<from-oidc-provider>
|
|
||||||
OAUTH_CLIENT_SECRET=<from-oidc-provider>
|
|
||||||
OAUTH_BASE_URL=https://mcp.example.com # Public URL for callbacks
|
|
||||||
OAUTH_SCOPES='["openid", "profile", "email", "groups"]'
|
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════
|
# MCP Server
|
||||||
# Transport (must be HTTP for OAuth)
|
MCP_BASE_URL=https://mcp.example.com # Public URL for OAuth redirects
|
||||||
# ═══════════════════════════════════════════════════════════════
|
|
||||||
MCP_TRANSPORT=streamable-http
|
MCP_TRANSPORT=streamable-http
|
||||||
MCP_HOST=0.0.0.0
|
|
||||||
MCP_PORT=8080
|
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════
|
# vCenter Connection (service account fallback)
|
||||||
# vCenter Connection (service account)
|
|
||||||
# ═══════════════════════════════════════════════════════════════
|
|
||||||
VCENTER_HOST=vcenter.example.com
|
VCENTER_HOST=vcenter.example.com
|
||||||
VCENTER_USER=svc-mcvsphere@vsphere.local
|
VCENTER_USER=svc-mcp@vsphere.local
|
||||||
VCENTER_PASSWORD=<service-account-password>
|
VCENTER_PASSWORD=<service-account-password>
|
||||||
VCENTER_INSECURE=false
|
VCENTER_INSECURE=true
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════
|
# Per-user credentials (optional, for testing)
|
||||||
# Optional
|
VCENTER_PASSWORD_RYAN=<ryan's-vcenter-password>
|
||||||
# ═══════════════════════════════════════════════════════════════
|
VCENTER_PASSWORD_ALICE=<alice's-vcenter-password>
|
||||||
LOG_LEVEL=INFO
|
|
||||||
```
|
|
||||||
|
|
||||||
### Server Startup Banner
|
# User Mapping Mode
|
||||||
|
USER_MAPPING_MODE=service_account # or 'per_user', 'ldap_sync'
|
||||||
When OAuth is enabled, the server shows:
|
|
||||||
```
|
|
||||||
mcvsphere v0.2.1
|
|
||||||
────────────────────────────────────────
|
|
||||||
Starting HTTP transport on 0.0.0.0:8080
|
|
||||||
OAuth: ENABLED via https://auth.example.com/application/o/mcvsphere/
|
|
||||||
RBAC: ENABLED - permissions enforced via groups
|
|
||||||
────────────────────────────────────────
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## OIDC Provider Setup
|
## Authentik Setup (Quick Reference)
|
||||||
|
|
||||||
### Authentik (Recommended)
|
|
||||||
|
|
||||||
1. **Create OAuth2/OIDC Provider:**
|
1. **Create OAuth2/OIDC Provider:**
|
||||||
- Name: `mcvsphere`
|
- Name: `vsphere-mcp`
|
||||||
- Client Type: **Confidential**
|
- Client Type: Confidential
|
||||||
- Redirect URIs:
|
- Redirect URIs:
|
||||||
- `http://localhost:*/callback` (for local dev)
|
- `http://localhost:*/callback`
|
||||||
- `https://mcp.example.com/auth/callback`
|
- `https://mcp.example.com/auth/callback`
|
||||||
- Signing Key: Select RS256 certificate
|
- Scopes: `openid`, `profile`, `email`
|
||||||
|
- Signing Key: Select or create RS256 key
|
||||||
|
|
||||||
2. **Create Application:**
|
2. **Create Application:**
|
||||||
- Name: `mcvsphere`
|
- Name: `vSphere MCP Server`
|
||||||
- Slug: `mcvsphere`
|
- Slug: `vsphere-mcp`
|
||||||
- Provider: Select provider from step 1
|
- Provider: Select the provider above
|
||||||
|
- Note the **Client ID** and **Client Secret**
|
||||||
|
|
||||||
3. **Create Groups:**
|
3. **Configure Groups (optional):**
|
||||||
- `vsphere-readers`
|
- `vsphere-admins` - Full access
|
||||||
- `vsphere-operators`
|
- `vsphere-operators` - Limited access
|
||||||
- `vsphere-admins`
|
- Groups are included in JWT `groups` claim
|
||||||
- `vsphere-host-admins`
|
|
||||||
- `vsphere-super-admins`
|
|
||||||
|
|
||||||
4. **Add Scope Mapping for Groups:**
|
|
||||||
- Ensure `groups` claim is included in tokens
|
|
||||||
- Authentik includes this by default
|
|
||||||
|
|
||||||
5. **Note Credentials:**
|
|
||||||
- Copy Client ID and Client Secret
|
|
||||||
- Discovery URL: `https://auth.example.com/application/o/mcvsphere/.well-known/openid-configuration`
|
|
||||||
|
|
||||||
### Other Providers
|
|
||||||
|
|
||||||
The same pattern works with Keycloak, Auth0, Okta, etc. Key requirements:
|
|
||||||
- OIDC Discovery endpoint (`.well-known/openid-configuration`)
|
|
||||||
- JWT access tokens (not opaque)
|
|
||||||
- `groups` claim in tokens with group names
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## OAuth Flow
|
## OAuth Flow (End-to-End)
|
||||||
|
|
||||||
```
|
```
|
||||||
1. Client connects to mcvsphere
|
1. Claude Code connects to MCP Server
|
||||||
→ POST /mcp (no auth)
|
→ GET /mcp
|
||||||
→ Server returns 401 + OAuth metadata URL
|
→ Server returns 401 Unauthorized
|
||||||
|
→ WWW-Authenticate header includes OAuth metadata URL
|
||||||
|
|
||||||
2. Client initiates OAuth flow
|
2. Claude Code fetches OAuth metadata
|
||||||
→ Opens browser to OIDC provider
|
→ Discovers Authentik authorization URL
|
||||||
→ User logs in
|
→ Discovers required scopes
|
||||||
→ Provider redirects with authorization code
|
|
||||||
|
|
||||||
3. Client exchanges code for tokens
|
3. Claude Code initiates OAuth flow
|
||||||
→ POST to provider token endpoint
|
→ Opens browser to Authentik login page
|
||||||
→ Receives JWT access token
|
→ User enters credentials
|
||||||
|
→ Authentik redirects back with authorization code
|
||||||
|
|
||||||
4. Client reconnects with token
|
4. Claude Code exchanges code for tokens
|
||||||
|
→ POST to Authentik token endpoint
|
||||||
|
→ Receives JWT access token + refresh token
|
||||||
|
|
||||||
|
5. Claude Code reconnects with Bearer token
|
||||||
→ POST /mcp with Authorization: Bearer <jwt>
|
→ POST /mcp with Authorization: Bearer <jwt>
|
||||||
→ Server validates JWT via JWKS
|
→ Server validates JWT via Authentik JWKS
|
||||||
→ Server extracts user + groups
|
→ Server extracts user identity
|
||||||
→ RBACMiddleware checks permissions
|
→ User can now invoke tools
|
||||||
→ User can invoke allowed tools
|
|
||||||
|
|
||||||
5. Tool invocation
|
6. Tool invocation
|
||||||
→ Client: "power on web-server"
|
→ Client: "power on web-server VM"
|
||||||
→ Middleware: Validate user has POWER_OPS
|
→ Server: Validates token, maps user to vCenter creds
|
||||||
→ Tool: Execute pyvmomi call
|
→ Server: Executes pyvmomi call
|
||||||
→ Audit: Log with user identity
|
→ Server: Logs "User ryan@example.com powered on web-server"
|
||||||
→ Client: Receive response
|
→ Client: Receives success response
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Implementation Status
|
## Implementation Checklist
|
||||||
|
|
||||||
### Completed
|
### Phase 1: Prepare Server
|
||||||
|
- [ ] Add `fastmcp[auth]` to dependencies
|
||||||
|
- [ ] Create `auth.py` with OIDCProxy configuration
|
||||||
|
- [ ] Create `credential_broker.py` for user mapping
|
||||||
|
- [ ] Add audit logging to all tools
|
||||||
|
- [ ] Update server.py to use HTTP transport
|
||||||
|
|
||||||
- [x] OIDCProxy configuration (`auth.py`)
|
### Phase 2: Deploy Authentik
|
||||||
- [x] Permission levels and tool mappings (`permissions.py`)
|
- [ ] Docker Compose for Authentik
|
||||||
- [x] RBACMiddleware with FastMCP integration (`middleware.py`)
|
- [ ] Create OIDC provider and application
|
||||||
- [x] Audit logging with user context (`audit.py`)
|
- [ ] Configure redirect URIs
|
||||||
- [x] Server integration with OAuth + RBAC (`server.py`)
|
- [ ] Note client credentials
|
||||||
- [x] Startup banner showing OAuth/RBAC status
|
- [ ] Test OIDC flow manually with curl
|
||||||
- [x] Security fix: deny-by-default for no groups
|
|
||||||
- [x] Authentik setup with 5 vsphere-* groups
|
|
||||||
|
|
||||||
### Future Enhancements
|
### Phase 3: Integration
|
||||||
|
- [ ] Configure environment variables
|
||||||
|
- [ ] Test with `fastmcp dev` (OAuth mode)
|
||||||
|
- [ ] Test with Claude Code (`claude mcp add --auth oauth`)
|
||||||
|
- [ ] Verify audit logs show correct user identity
|
||||||
|
|
||||||
- [ ] Per-user vCenter credential mapping (Vault integration)
|
### Phase 4: Production
|
||||||
- [ ] Rate limiting per user
|
- [ ] HTTPS via Caddy reverse proxy
|
||||||
- [ ] Session management and token refresh
|
- [ ] Secrets in Docker secrets / Vault
|
||||||
- [ ] Admin tools for permission management
|
- [ ] Service account with minimal vCenter permissions
|
||||||
- [ ] Prometheus metrics for RBAC decisions
|
- [ ] Log aggregation and monitoring
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Security Considerations
|
## Files to Create/Modify
|
||||||
|
|
||||||
1. **Default Deny**: Users without recognized groups get NO access
|
```
|
||||||
2. **Token Validation**: JWTs validated via OIDC provider's JWKS endpoint
|
esxi-mcp-server/
|
||||||
3. **Audit Trail**: All operations logged with user identity
|
├── src/esxi_mcp_server/
|
||||||
4. **Secrets**: Client secrets should be stored securely (env vars, Docker secrets, Vault)
|
│ ├── auth.py # NEW: OIDCProxy configuration
|
||||||
5. **HTTPS**: Production deployments should use TLS (via Caddy, nginx, etc.)
|
│ ├── credential_broker.py # NEW: OAuth → vCenter credential mapping
|
||||||
6. **Service Account**: Use minimal vCenter permissions for the service account
|
│ ├── server.py # MODIFY: Add auth, HTTP transport
|
||||||
|
│ └── mixins/
|
||||||
|
│ └── *.py # MODIFY: Add ctx.request_context.user logging
|
||||||
|
├── .env.example # MODIFY: Add OAuth variables
|
||||||
|
└── docker-compose.yml # MODIFY: Add Authentik services
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Troubleshooting
|
## Key Insight
|
||||||
|
|
||||||
### "401 Unauthorized" on all requests
|
The "middleman" role of the MCP server is critical:
|
||||||
- Check `OAUTH_ISSUER_URL` points to valid OIDC discovery endpoint
|
|
||||||
- Verify client ID and secret match provider configuration
|
|
||||||
- Ensure token hasn't expired
|
|
||||||
|
|
||||||
### "Permission denied" errors
|
```
|
||||||
- Check user's group memberships in OIDC provider
|
OAuth Token (Authentik) ──┐
|
||||||
- Verify groups claim is included in JWT (decode at jwt.io)
|
│
|
||||||
- Confirm group names match exactly (e.g., `vsphere-admins` not `vsphere_admins`)
|
▼
|
||||||
|
┌─────────────┐
|
||||||
|
│ MCP Server │ ← Validates OAuth, maps to vCenter creds
|
||||||
|
│ (Middleman) │ ← Logs audit trail with OAuth identity
|
||||||
|
└─────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
vCenter API (pyvmomi)
|
||||||
|
```
|
||||||
|
|
||||||
### Token validation fails
|
The MCP server doesn't pass OAuth tokens to vCenter. Instead, it:
|
||||||
- Ensure OIDC provider issues JWTs (not opaque tokens)
|
1. **Authenticates** users via OAuth (trusts Authentik)
|
||||||
- Check signing key is configured in provider
|
2. **Authorizes** by mapping OAuth identity to vCenter credentials
|
||||||
- Verify `OAUTH_BASE_URL` matches redirect URI in provider
|
3. **Audits** by logging all actions with the OAuth user identity
|
||||||
|
4. **Executes** vCenter API calls using mapped credentials
|
||||||
|
|
||||||
### Audit logs not showing user
|
This gives you SSO-like experience while working within vCenter 7.0.3's authentication limitations.
|
||||||
- Check `groups` scope is requested
|
|
||||||
- Verify token contains `preferred_username` or `email` claim
|
|
||||||
|
|||||||
32
README.md
32
README.md
@ -195,41 +195,11 @@ Claude: [snapshots 12 VMs in parallel]
|
|||||||
| `VCENTER_CLUSTER` | Target cluster | *auto-detect* |
|
| `VCENTER_CLUSTER` | Target cluster | *auto-detect* |
|
||||||
| `VCENTER_DATASTORE` | Default datastore | *auto-detect* |
|
| `VCENTER_DATASTORE` | Default datastore | *auto-detect* |
|
||||||
| `VCENTER_NETWORK` | Default network | *auto-detect* |
|
| `VCENTER_NETWORK` | Default network | *auto-detect* |
|
||||||
| `MCP_TRANSPORT` | `stdio` or `streamable-http` | `stdio` |
|
| `MCP_TRANSPORT` | `stdio` or `sse` | `stdio` |
|
||||||
| `LOG_LEVEL` | Logging verbosity | `INFO` |
|
| `LOG_LEVEL` | Logging verbosity | `INFO` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Multi-User / OAuth Mode
|
|
||||||
|
|
||||||
For shared infrastructure or production deployments, mcvsphere supports OAuth 2.1 with any OIDC provider (Authentik, Keycloak, Auth0, etc.):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Enable HTTP transport with OAuth
|
|
||||||
export MCP_TRANSPORT=streamable-http
|
|
||||||
export OAUTH_ENABLED=true
|
|
||||||
export OAUTH_ISSUER_URL=https://auth.example.com/application/o/mcvsphere/
|
|
||||||
export OAUTH_CLIENT_ID=your-client-id
|
|
||||||
export OAUTH_CLIENT_SECRET=your-client-secret
|
|
||||||
export OAUTH_BASE_URL=https://mcp.example.com
|
|
||||||
|
|
||||||
uvx mcvsphere
|
|
||||||
```
|
|
||||||
|
|
||||||
Users authenticate via browser, and group memberships map to permission levels:
|
|
||||||
|
|
||||||
| Group | Access |
|
|
||||||
|-------|--------|
|
|
||||||
| `vsphere-super-admins` | Full control (all 94 tools) |
|
|
||||||
| `vsphere-host-admins` | Host operations + VM management |
|
|
||||||
| `vsphere-admins` | VM lifecycle management |
|
|
||||||
| `vsphere-operators` | Power ops + snapshots |
|
|
||||||
| `vsphere-readers` | Read-only |
|
|
||||||
|
|
||||||
See [OAUTH-ARCHITECTURE.md](OAUTH-ARCHITECTURE.md) for detailed setup instructions.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Docker
|
## Docker
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@ -235,89 +235,15 @@ MCP_LOG_LEVEL=DEBUG
|
|||||||
log_level: "DEBUG"
|
log_level: "DEBUG"
|
||||||
```
|
```
|
||||||
|
|
||||||
## OAuth Multi-User Mode
|
|
||||||
|
|
||||||
For shared infrastructure or production deployments, mcvsphere supports OAuth 2.1 with any OIDC provider. This enables:
|
|
||||||
|
|
||||||
- **Browser-based authentication** via Authentik, Keycloak, Auth0, Okta, etc.
|
|
||||||
- **Group-based RBAC** with 5 permission levels
|
|
||||||
- **Audit logging** with user identity
|
|
||||||
|
|
||||||
### Quick Start (Existing OIDC Provider)
|
|
||||||
|
|
||||||
If you already have an OIDC provider:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. Copy and configure environment
|
|
||||||
cp .env.oauth.example .env
|
|
||||||
# Edit .env with your OIDC provider details
|
|
||||||
|
|
||||||
# 2. Start mcvsphere with OAuth
|
|
||||||
docker compose -f docker-compose.oauth-standalone.yml up -d
|
|
||||||
|
|
||||||
# 3. Add to Claude Code
|
|
||||||
claude mcp add -t http vsphere https://mcp.example.com/mcp
|
|
||||||
```
|
|
||||||
|
|
||||||
### Quick Start (New Authentik Deployment)
|
|
||||||
|
|
||||||
To deploy mcvsphere with a complete Authentik identity provider:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. Generate secrets and configure
|
|
||||||
./scripts/setup-oauth.sh
|
|
||||||
|
|
||||||
# 2. Start full stack (mcvsphere + Authentik + PostgreSQL + Redis)
|
|
||||||
docker compose -f docker-compose.oauth.yml up -d
|
|
||||||
|
|
||||||
# 3. Configure Authentik at https://auth.yourdomain.com
|
|
||||||
# - Create OAuth2 provider
|
|
||||||
# - Create vsphere-* groups
|
|
||||||
# - Assign users to groups
|
|
||||||
```
|
|
||||||
|
|
||||||
### RBAC Permission Groups
|
|
||||||
|
|
||||||
Create these groups in your OIDC provider:
|
|
||||||
|
|
||||||
| Group | Access Level |
|
|
||||||
|-------|--------------|
|
|
||||||
| `vsphere-super-admins` | Full control (all 94 tools) |
|
|
||||||
| `vsphere-host-admins` | Host operations + VM management |
|
|
||||||
| `vsphere-admins` | VM lifecycle management |
|
|
||||||
| `vsphere-operators` | Power ops + snapshots |
|
|
||||||
| `vsphere-readers` | Read-only |
|
|
||||||
|
|
||||||
Users without any `vsphere-*` group are denied access (default-deny security).
|
|
||||||
|
|
||||||
### Required Environment Variables
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Transport (must be streamable-http for OAuth)
|
|
||||||
MCP_TRANSPORT=streamable-http
|
|
||||||
|
|
||||||
# OIDC Provider
|
|
||||||
OAUTH_ENABLED=true
|
|
||||||
OAUTH_ISSUER_URL=https://auth.example.com/application/o/mcvsphere/
|
|
||||||
OAUTH_CLIENT_ID=your-client-id
|
|
||||||
OAUTH_CLIENT_SECRET=your-client-secret
|
|
||||||
OAUTH_BASE_URL=https://mcp.example.com
|
|
||||||
```
|
|
||||||
|
|
||||||
See [OAUTH-ARCHITECTURE.md](OAUTH-ARCHITECTURE.md) for detailed setup and troubleshooting.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Production Deployment
|
## Production Deployment
|
||||||
|
|
||||||
### Security Recommendations
|
### Security Recommendations
|
||||||
|
|
||||||
1. Use a dedicated service account for vCenter access
|
1. Use a dedicated user account for vCenter access
|
||||||
2. Enable OAuth authentication (not API keys)
|
2. Enable API key authentication
|
||||||
3. Use valid SSL certificates (set `VCENTER_INSECURE=false`)
|
3. Use valid SSL certificates (set `insecure: false`)
|
||||||
4. Limit container resources
|
4. Limit container resources
|
||||||
5. Use Docker secrets for sensitive data
|
5. Use Docker secrets for sensitive data
|
||||||
6. Deploy behind a reverse proxy with HTTPS (Caddy recommended)
|
|
||||||
|
|
||||||
### High Availability
|
### High Availability
|
||||||
|
|
||||||
|
|||||||
@ -1,64 +0,0 @@
|
|||||||
# mcvsphere with OAuth - Standalone Mode
|
|
||||||
# For users who already have an OIDC provider (Authentik, Keycloak, Auth0, Okta, etc.)
|
|
||||||
#
|
|
||||||
# Usage:
|
|
||||||
# 1. Copy .env.oauth.example to .env
|
|
||||||
# 2. Configure your OIDC provider settings
|
|
||||||
# 3. docker compose -f docker-compose.oauth-standalone.yml up -d
|
|
||||||
#
|
|
||||||
# Requires:
|
|
||||||
# - External OIDC provider with OAuth 2.1 support
|
|
||||||
# - Caddy network (caddy-docker-proxy) for HTTPS termination
|
|
||||||
#
|
|
||||||
# For full Authentik deployment, use docker-compose.oauth.yml instead
|
|
||||||
|
|
||||||
services:
|
|
||||||
mcvsphere:
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
image: mcvsphere:${MCVSPHERE_VERSION:-latest}
|
|
||||||
container_name: mcvsphere
|
|
||||||
restart: unless-stopped
|
|
||||||
env_file:
|
|
||||||
- .env
|
|
||||||
environment:
|
|
||||||
# Transport - streamable-http required for OAuth
|
|
||||||
MCP_TRANSPORT: streamable-http
|
|
||||||
MCP_HOST: 0.0.0.0
|
|
||||||
MCP_PORT: 8080
|
|
||||||
# OAuth - set in .env file
|
|
||||||
OAUTH_ENABLED: ${OAUTH_ENABLED:-true}
|
|
||||||
volumes:
|
|
||||||
- ./logs:/app/logs
|
|
||||||
networks:
|
|
||||||
- caddy
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/.well-known/oauth-authorization-server')"]
|
|
||||||
interval: 30s
|
|
||||||
timeout: 10s
|
|
||||||
retries: 3
|
|
||||||
start_period: 15s
|
|
||||||
labels:
|
|
||||||
# Caddy reverse proxy - configure domain in .env
|
|
||||||
caddy: ${MCP_DOMAIN:-mcp.localhost}
|
|
||||||
caddy.reverse_proxy: "{{upstreams 8080}}"
|
|
||||||
# TLS - use 'internal' for local dev, remove for production (auto HTTPS)
|
|
||||||
caddy.tls: ${MCP_TLS_MODE:-internal}
|
|
||||||
# WebSocket/streaming support
|
|
||||||
caddy.reverse_proxy.flush_interval: "-1"
|
|
||||||
caddy.reverse_proxy.transport: http
|
|
||||||
caddy.reverse_proxy.transport.read_timeout: "0"
|
|
||||||
caddy.reverse_proxy.transport.write_timeout: "0"
|
|
||||||
deploy:
|
|
||||||
resources:
|
|
||||||
limits:
|
|
||||||
memory: 512M
|
|
||||||
cpus: '1.0'
|
|
||||||
reservations:
|
|
||||||
memory: 256M
|
|
||||||
cpus: '0.25'
|
|
||||||
|
|
||||||
networks:
|
|
||||||
caddy:
|
|
||||||
external: true
|
|
||||||
19
docs/.gitignore
vendored
19
docs/.gitignore
vendored
@ -1,19 +0,0 @@
|
|||||||
# Dependencies
|
|
||||||
node_modules/
|
|
||||||
|
|
||||||
# Build output
|
|
||||||
dist/
|
|
||||||
.astro/
|
|
||||||
|
|
||||||
# Editor
|
|
||||||
.idea/
|
|
||||||
.vscode/
|
|
||||||
*.swp
|
|
||||||
*.swo
|
|
||||||
|
|
||||||
# OS
|
|
||||||
.DS_Store
|
|
||||||
Thumbs.db
|
|
||||||
|
|
||||||
# Logs
|
|
||||||
*.log
|
|
||||||
@ -1,2 +0,0 @@
|
|||||||
esbuild@*
|
|
||||||
sharp@*
|
|
||||||
@ -1,70 +0,0 @@
|
|||||||
// @ts-check
|
|
||||||
import { defineConfig } from 'astro/config';
|
|
||||||
import starlight from '@astrojs/starlight';
|
|
||||||
|
|
||||||
// https://astro.build/config
|
|
||||||
export default defineConfig({
|
|
||||||
site: 'https://docs.mcvsphere.dev',
|
|
||||||
telemetry: false,
|
|
||||||
devToolbar: { enabled: false },
|
|
||||||
integrations: [
|
|
||||||
starlight({
|
|
||||||
title: 'mcvsphere',
|
|
||||||
description: 'AI-driven VMware vSphere management via Model Context Protocol',
|
|
||||||
logo: {
|
|
||||||
src: './src/assets/logo.svg',
|
|
||||||
replacesTitle: false,
|
|
||||||
},
|
|
||||||
social: {
|
|
||||||
github: 'https://git.supported.systems/MCP/mcvsphere',
|
|
||||||
},
|
|
||||||
sidebar: [
|
|
||||||
{
|
|
||||||
label: 'Getting Started',
|
|
||||||
items: [
|
|
||||||
{ label: 'Introduction', slug: 'getting-started/introduction' },
|
|
||||||
{ label: 'Installation', slug: 'getting-started/installation' },
|
|
||||||
{ label: 'Quick Start', slug: 'getting-started/quickstart' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Configuration',
|
|
||||||
items: [
|
|
||||||
{ label: 'Environment Variables', slug: 'configuration/environment' },
|
|
||||||
{ label: 'vCenter Connection', slug: 'configuration/vcenter' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Deployment',
|
|
||||||
items: [
|
|
||||||
{ label: 'Docker Setup', slug: 'deployment/docker' },
|
|
||||||
{ label: 'OAuth Multi-User', slug: 'deployment/oauth' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Reference',
|
|
||||||
items: [
|
|
||||||
{ label: 'Tool Reference', slug: 'reference/tools' },
|
|
||||||
{ label: 'RBAC Permissions', slug: 'reference/rbac' },
|
|
||||||
{ label: 'Architecture', slug: 'reference/architecture' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
customCss: [
|
|
||||||
'./src/styles/custom.css',
|
|
||||||
],
|
|
||||||
head: [
|
|
||||||
{
|
|
||||||
tag: 'meta',
|
|
||||||
attrs: {
|
|
||||||
name: 'theme-color',
|
|
||||||
content: '#146eb4',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
editLink: {
|
|
||||||
baseUrl: 'https://git.supported.systems/MCP/mcvsphere/edit/main/docs/',
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
});
|
|
||||||
@ -1,15 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "mcvsphere-docs",
|
|
||||||
"type": "module",
|
|
||||||
"version": "0.0.1",
|
|
||||||
"scripts": {
|
|
||||||
"dev": "astro dev",
|
|
||||||
"build": "astro build",
|
|
||||||
"preview": "astro preview"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@astrojs/starlight": "^0.32.3",
|
|
||||||
"astro": "^5.1.6",
|
|
||||||
"sharp": "^0.33.5"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
4429
docs/pnpm-lock.yaml
generated
4429
docs/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -1,4 +0,0 @@
|
|||||||
User-agent: *
|
|
||||||
Allow: /
|
|
||||||
|
|
||||||
Sitemap: https://docs.mcvsphere.dev/sitemap-index.xml
|
|
||||||
@ -1,22 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" fill="none">
|
|
||||||
<!-- vSphere-inspired cube -->
|
|
||||||
<defs>
|
|
||||||
<linearGradient id="grad1" x1="0%" y1="0%" x2="100%" y2="100%">
|
|
||||||
<stop offset="0%" style="stop-color:#3d9cdb;stop-opacity:1" />
|
|
||||||
<stop offset="100%" style="stop-color:#146eb4;stop-opacity:1" />
|
|
||||||
</linearGradient>
|
|
||||||
<linearGradient id="grad2" x1="0%" y1="100%" x2="100%" y2="0%">
|
|
||||||
<stop offset="0%" style="stop-color:#0d4f8c;stop-opacity:1" />
|
|
||||||
<stop offset="100%" style="stop-color:#146eb4;stop-opacity:1" />
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
<!-- Main cube shape -->
|
|
||||||
<path d="M24 4L6 14v20l18 10 18-10V14L24 4z" fill="url(#grad1)" opacity="0.9"/>
|
|
||||||
<path d="M24 4L6 14l18 10 18-10L24 4z" fill="url(#grad2)" opacity="0.8"/>
|
|
||||||
<path d="M24 24v20l18-10V14L24 24z" fill="#0d4f8c" opacity="0.7"/>
|
|
||||||
<path d="M24 24L6 14v20l18 10V24z" fill="#146eb4" opacity="0.6"/>
|
|
||||||
<!-- VM representation lines -->
|
|
||||||
<path d="M18 20l6-3.5 6 3.5" stroke="#fff" stroke-width="1.5" fill="none" opacity="0.8"/>
|
|
||||||
<path d="M18 26l6-3.5 6 3.5" stroke="#fff" stroke-width="1.5" fill="none" opacity="0.6"/>
|
|
||||||
<path d="M18 32l6-3.5 6 3.5" stroke="#fff" stroke-width="1.5" fill="none" opacity="0.4"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.2 KiB |
@ -1,6 +0,0 @@
|
|||||||
import { defineCollection } from 'astro:content';
|
|
||||||
import { docsSchema } from '@astrojs/starlight/schema';
|
|
||||||
|
|
||||||
export const collections = {
|
|
||||||
docs: defineCollection({ schema: docsSchema() }),
|
|
||||||
};
|
|
||||||
@ -1,141 +0,0 @@
|
|||||||
---
|
|
||||||
title: Environment Variables
|
|
||||||
description: Complete reference for all mcvsphere configuration options
|
|
||||||
---
|
|
||||||
|
|
||||||
import { Aside } from '@astrojs/starlight/components';
|
|
||||||
|
|
||||||
## vCenter Connection
|
|
||||||
|
|
||||||
These are required to connect to your VMware infrastructure.
|
|
||||||
|
|
||||||
| Variable | Description | Default |
|
|
||||||
|----------|-------------|---------|
|
|
||||||
| `VCENTER_HOST` | vCenter or ESXi hostname/IP | **required** |
|
|
||||||
| `VCENTER_USER` | Username (e.g., `admin@vsphere.local`) | **required** |
|
|
||||||
| `VCENTER_PASSWORD` | Password | **required** |
|
|
||||||
| `VCENTER_INSECURE` | Skip SSL certificate verification | `false` |
|
|
||||||
| `VCENTER_DATACENTER` | Target datacenter | auto-detect |
|
|
||||||
| `VCENTER_CLUSTER` | Target cluster | auto-detect |
|
|
||||||
| `VCENTER_DATASTORE` | Default datastore | auto-detect |
|
|
||||||
| `VCENTER_NETWORK` | Default network | auto-detect |
|
|
||||||
|
|
||||||
<Aside type="caution">
|
|
||||||
Only set `VCENTER_INSECURE=true` in development environments. Production deployments should use valid SSL certificates.
|
|
||||||
</Aside>
|
|
||||||
|
|
||||||
## Managing Multiple ESXi Hosts
|
|
||||||
|
|
||||||
mcvsphere can manage a list of ESXi hosts and route each tool call to whichever one you choose. Define the list with numbered `ESXI_*` variables:
|
|
||||||
|
|
||||||
| Variable | Description |
|
|
||||||
|----------|-------------|
|
|
||||||
| `ESXI_HOST` | First host — the **default** used when a call omits `host` |
|
|
||||||
| `ESXI_USER` / `ESXI_PASS` | Credentials for the default host |
|
|
||||||
| `ESXI_INSECURE` / `ESXI_NETWORK` | SSL skip / default network for the default host |
|
|
||||||
| `ESXI_HOST_1`, `ESXI_HOST_2`, … | Additional hosts |
|
|
||||||
| `ESXI_USER_1` / `ESXI_PASS_1` / … | Per-host credentials (optional) |
|
|
||||||
| `ESXI_INSECURE_1` / `ESXI_NETWORK_1` / … | Per-host SSL / network (optional) |
|
|
||||||
|
|
||||||
A suffixed host that omits its own `USER`/`PASS`/`INSECURE`/`NETWORK` inherits the unsuffixed value, so hosts that share credentials only need a `HOST` line:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ESXI_HOST=10.0.0.10
|
|
||||||
ESXI_USER=root
|
|
||||||
ESXI_PASS=secret
|
|
||||||
ESXI_NETWORK=VM Network
|
|
||||||
|
|
||||||
ESXI_HOST_1=10.0.0.11 # reuses root / secret / VM Network
|
|
||||||
ESXI_HOST_2=10.0.0.12 # reuses root / secret / VM Network
|
|
||||||
```
|
|
||||||
|
|
||||||
Hosts are identified by their `host` value. Connections are established **lazily** on first use, so an unreachable host never blocks startup or the other hosts.
|
|
||||||
|
|
||||||
### Selecting a host per call
|
|
||||||
|
|
||||||
Every tool except `list_servers` accepts an optional `host` argument:
|
|
||||||
|
|
||||||
```text
|
|
||||||
list_servers() → list managed hosts + connection status
|
|
||||||
list_vms(host="10.0.0.11") → VMs on that specific host
|
|
||||||
get_host_info() → omit host → the default host
|
|
||||||
create_vm(name="web", host="10.0.0.12")
|
|
||||||
```
|
|
||||||
|
|
||||||
:::note
|
|
||||||
The host list is produced by a single pluggable function, `load_servers()`. The `ESXI_*` env-var families are the current source; a future HTTP deployment can back it with an API with no tool changes — the per-call `host` selector is stateless by design.
|
|
||||||
:::
|
|
||||||
|
|
||||||
## MCP Transport
|
|
||||||
|
|
||||||
Control how mcvsphere communicates with MCP clients.
|
|
||||||
|
|
||||||
| Variable | Description | Default |
|
|
||||||
|----------|-------------|---------|
|
|
||||||
| `MCP_TRANSPORT` | `stdio` or `streamable-http` | `stdio` |
|
|
||||||
| `MCP_HOST` | HTTP bind address | `0.0.0.0` |
|
|
||||||
| `MCP_PORT` | HTTP port | `8080` |
|
|
||||||
|
|
||||||
### Transport Modes
|
|
||||||
|
|
||||||
- **stdio**: Direct subprocess communication. Used for single-user local development.
|
|
||||||
- **streamable-http**: HTTP server with streaming support. Required for OAuth multi-user deployments.
|
|
||||||
|
|
||||||
## OAuth Authentication
|
|
||||||
|
|
||||||
Required when `OAUTH_ENABLED=true` for multi-user deployments.
|
|
||||||
|
|
||||||
| Variable | Description |
|
|
||||||
|----------|-------------|
|
|
||||||
| `OAUTH_ENABLED` | Enable OAuth authentication |
|
|
||||||
| `OAUTH_ISSUER_URL` | OIDC discovery URL |
|
|
||||||
| `OAUTH_CLIENT_ID` | OAuth client ID |
|
|
||||||
| `OAUTH_CLIENT_SECRET` | OAuth client secret |
|
|
||||||
| `OAUTH_BASE_URL` | Public HTTPS URL for callbacks |
|
|
||||||
|
|
||||||
Example OIDC discovery URLs:
|
|
||||||
- **Authentik**: `https://auth.example.com/application/o/mcvsphere/`
|
|
||||||
- **Keycloak**: `https://keycloak.example.com/realms/myrealm`
|
|
||||||
- **Auth0**: `https://myapp.auth0.com/`
|
|
||||||
- **Okta**: `https://myorg.okta.com/oauth2/default`
|
|
||||||
|
|
||||||
## Logging
|
|
||||||
|
|
||||||
| Variable | Description | Default |
|
|
||||||
|----------|-------------|---------|
|
|
||||||
| `LOG_LEVEL` | Logging verbosity (`DEBUG`, `INFO`, `WARNING`, `ERROR`) | `INFO` |
|
|
||||||
|
|
||||||
## Example Configurations
|
|
||||||
|
|
||||||
### Local Development
|
|
||||||
|
|
||||||
```bash
|
|
||||||
export VCENTER_HOST=10.20.0.222
|
|
||||||
export VCENTER_USER=admin@vsphere.local
|
|
||||||
export VCENTER_PASSWORD=secret
|
|
||||||
export VCENTER_INSECURE=true
|
|
||||||
export LOG_LEVEL=DEBUG
|
|
||||||
```
|
|
||||||
|
|
||||||
### Production (Single User)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
export VCENTER_HOST=vcenter.prod.example.com
|
|
||||||
export VCENTER_USER=svc-mcvsphere@vsphere.local
|
|
||||||
export VCENTER_PASSWORD="${VCENTER_PASSWORD}" # from secrets manager
|
|
||||||
export VCENTER_INSECURE=false
|
|
||||||
```
|
|
||||||
|
|
||||||
### Production (Multi-User OAuth)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
export VCENTER_HOST=vcenter.prod.example.com
|
|
||||||
export VCENTER_USER=svc-mcvsphere@vsphere.local
|
|
||||||
export VCENTER_PASSWORD="${VCENTER_PASSWORD}"
|
|
||||||
export MCP_TRANSPORT=streamable-http
|
|
||||||
export OAUTH_ENABLED=true
|
|
||||||
export OAUTH_ISSUER_URL=https://auth.example.com/application/o/mcvsphere/
|
|
||||||
export OAUTH_CLIENT_ID=mcvsphere-client
|
|
||||||
export OAUTH_CLIENT_SECRET="${OAUTH_CLIENT_SECRET}"
|
|
||||||
export OAUTH_BASE_URL=https://mcp.example.com
|
|
||||||
```
|
|
||||||
@ -1,126 +0,0 @@
|
|||||||
---
|
|
||||||
title: vCenter Connection
|
|
||||||
description: Configuring vCenter/ESXi access for mcvsphere
|
|
||||||
---
|
|
||||||
|
|
||||||
import { Aside, Steps } from '@astrojs/starlight/components';
|
|
||||||
|
|
||||||
## Service Account Setup
|
|
||||||
|
|
||||||
For production deployments, create a dedicated service account rather than using your personal administrator credentials.
|
|
||||||
|
|
||||||
<Steps>
|
|
||||||
|
|
||||||
1. **Create the user in vCenter**
|
|
||||||
|
|
||||||
In the vSphere Client, go to **Administration** → **Single Sign-On** → **Users and Groups** → **Add User**
|
|
||||||
|
|
||||||
- Username: `svc-mcvsphere`
|
|
||||||
- Domain: `vsphere.local`
|
|
||||||
- Password: Generate a strong password
|
|
||||||
|
|
||||||
2. **Create a role with required permissions**
|
|
||||||
|
|
||||||
Go to **Administration** → **Access Control** → **Roles** → **Add Role**
|
|
||||||
|
|
||||||
Required permissions vary by which tools you need:
|
|
||||||
|
|
||||||
| Permission Category | Required For |
|
|
||||||
|---------------------|--------------|
|
|
||||||
| Virtual machine → Interaction | Power operations, console |
|
|
||||||
| Virtual machine → Inventory | Create, delete, rename VMs |
|
|
||||||
| Virtual machine → Configuration | CPU, memory, disk changes |
|
|
||||||
| Virtual machine → Snapshot management | Snapshots |
|
|
||||||
| Virtual machine → Guest operations | Commands, file transfer |
|
|
||||||
| Datastore → Browse datastore | OVF deploy, file operations |
|
|
||||||
| Network → Assign network | Network adapter changes |
|
|
||||||
| Host → Configuration | Host management tools |
|
|
||||||
|
|
||||||
3. **Assign the role**
|
|
||||||
|
|
||||||
Go to your datacenter or cluster, **Permissions** tab, **Add Permission**
|
|
||||||
|
|
||||||
- User: `svc-mcvsphere@vsphere.local`
|
|
||||||
- Role: The role you created
|
|
||||||
- Propagate to children: **Yes**
|
|
||||||
|
|
||||||
</Steps>
|
|
||||||
|
|
||||||
## Connection Testing
|
|
||||||
|
|
||||||
Test your credentials before configuring mcvsphere:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Set credentials
|
|
||||||
export VCENTER_HOST=vcenter.example.com
|
|
||||||
export VCENTER_USER=svc-mcvsphere@vsphere.local
|
|
||||||
export VCENTER_PASSWORD=your-password
|
|
||||||
|
|
||||||
# Quick test with Python
|
|
||||||
python -c "
|
|
||||||
from pyVim.connect import SmartConnect
|
|
||||||
import ssl
|
|
||||||
ctx = ssl._create_unverified_context()
|
|
||||||
si = SmartConnect(host='$VCENTER_HOST', user='$VCENTER_USER', pwd='$VCENTER_PASSWORD', sslContext=ctx)
|
|
||||||
print(f'Connected to: {si.content.about.fullName}')
|
|
||||||
"
|
|
||||||
```
|
|
||||||
|
|
||||||
## SSL Certificates
|
|
||||||
|
|
||||||
### Self-Signed Certificates (Development)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
export VCENTER_INSECURE=true
|
|
||||||
```
|
|
||||||
|
|
||||||
<Aside type="caution">
|
|
||||||
This disables SSL verification. Only use in lab environments.
|
|
||||||
</Aside>
|
|
||||||
|
|
||||||
### Custom CA Certificates (Production)
|
|
||||||
|
|
||||||
If your vCenter uses certificates signed by an internal CA:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Add your CA to the system trust store
|
|
||||||
sudo cp your-ca.crt /usr/local/share/ca-certificates/
|
|
||||||
sudo update-ca-certificates
|
|
||||||
|
|
||||||
# Or specify the CA bundle
|
|
||||||
export REQUESTS_CA_BUNDLE=/path/to/ca-bundle.crt
|
|
||||||
```
|
|
||||||
|
|
||||||
## Auto-Detection Behavior
|
|
||||||
|
|
||||||
When `VCENTER_DATACENTER`, `VCENTER_CLUSTER`, `VCENTER_DATASTORE`, or `VCENTER_NETWORK` are not set, mcvsphere auto-detects defaults:
|
|
||||||
|
|
||||||
1. **Datacenter**: First datacenter found
|
|
||||||
2. **Cluster**: First cluster in the datacenter
|
|
||||||
3. **Datastore**: Datastore with most free space
|
|
||||||
4. **Network**: Network named "VM Network", or first network found
|
|
||||||
|
|
||||||
To see what was auto-detected, check the startup logs:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
LOG_LEVEL=DEBUG uvx mcvsphere
|
|
||||||
```
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### "Cannot complete login due to an incorrect user name or password"
|
|
||||||
|
|
||||||
- Check username includes domain: `admin@vsphere.local` not `admin`
|
|
||||||
- Verify password doesn't contain special characters that need escaping
|
|
||||||
- Confirm the account isn't locked in vCenter
|
|
||||||
|
|
||||||
### "SSL: CERTIFICATE_VERIFY_FAILED"
|
|
||||||
|
|
||||||
- Use `VCENTER_INSECURE=true` for self-signed certs
|
|
||||||
- Or install the vCenter CA certificate (see above)
|
|
||||||
|
|
||||||
### "Permission denied" for specific operations
|
|
||||||
|
|
||||||
- Check the service account has the required vCenter permissions
|
|
||||||
- Permissions must be assigned at the right level (datacenter, cluster, or VM folder)
|
|
||||||
- Ensure "Propagate to children" is enabled
|
|
||||||
@ -1,186 +0,0 @@
|
|||||||
---
|
|
||||||
title: Docker Deployment
|
|
||||||
description: Run mcvsphere in Docker for production deployments
|
|
||||||
---
|
|
||||||
|
|
||||||
import { Tabs, TabItem, Aside, Steps } from '@astrojs/starlight/components';
|
|
||||||
|
|
||||||
## Quick Start
|
|
||||||
|
|
||||||
<Steps>
|
|
||||||
|
|
||||||
1. **Create configuration**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Create environment file
|
|
||||||
cat > .env << 'EOF'
|
|
||||||
COMPOSE_PROJECT_NAME=mcvsphere
|
|
||||||
|
|
||||||
# vCenter Connection
|
|
||||||
VCENTER_HOST=vcenter.example.com
|
|
||||||
VCENTER_USER=svc-mcvsphere@vsphere.local
|
|
||||||
VCENTER_PASSWORD=your-password
|
|
||||||
VCENTER_INSECURE=false
|
|
||||||
|
|
||||||
# Transport
|
|
||||||
MCP_TRANSPORT=streamable-http
|
|
||||||
MCP_HOST=0.0.0.0
|
|
||||||
MCP_PORT=8080
|
|
||||||
EOF
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Run the container**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker run -d \
|
|
||||||
--name mcvsphere \
|
|
||||||
--env-file .env \
|
|
||||||
-p 8080:8080 \
|
|
||||||
ghcr.io/supportedsystems/mcvsphere:latest
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Verify it's running**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl http://localhost:8080/.well-known/oauth-authorization-server
|
|
||||||
```
|
|
||||||
|
|
||||||
</Steps>
|
|
||||||
|
|
||||||
## Docker Compose
|
|
||||||
|
|
||||||
For production deployments, use Docker Compose:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
# docker-compose.yml
|
|
||||||
services:
|
|
||||||
mcvsphere:
|
|
||||||
image: ghcr.io/supportedsystems/mcvsphere:latest
|
|
||||||
container_name: mcvsphere
|
|
||||||
restart: unless-stopped
|
|
||||||
env_file:
|
|
||||||
- .env
|
|
||||||
ports:
|
|
||||||
- "8080:8080"
|
|
||||||
volumes:
|
|
||||||
- ./logs:/app/logs
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/.well-known/oauth-authorization-server')"]
|
|
||||||
interval: 30s
|
|
||||||
timeout: 10s
|
|
||||||
retries: 3
|
|
||||||
start_period: 15s
|
|
||||||
deploy:
|
|
||||||
resources:
|
|
||||||
limits:
|
|
||||||
memory: 512M
|
|
||||||
cpus: '1.0'
|
|
||||||
reservations:
|
|
||||||
memory: 256M
|
|
||||||
cpus: '0.25'
|
|
||||||
```
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose up -d
|
|
||||||
docker compose logs -f
|
|
||||||
```
|
|
||||||
|
|
||||||
## With Caddy Reverse Proxy
|
|
||||||
|
|
||||||
For HTTPS termination, use [caddy-docker-proxy](https://github.com/lucaslorentz/caddy-docker-proxy):
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
# docker-compose.yml
|
|
||||||
services:
|
|
||||||
mcvsphere:
|
|
||||||
image: ghcr.io/supportedsystems/mcvsphere:latest
|
|
||||||
restart: unless-stopped
|
|
||||||
env_file:
|
|
||||||
- .env
|
|
||||||
networks:
|
|
||||||
- caddy
|
|
||||||
labels:
|
|
||||||
caddy: mcp.example.com
|
|
||||||
caddy.reverse_proxy: "{{upstreams 8080}}"
|
|
||||||
# WebSocket/streaming support
|
|
||||||
caddy.reverse_proxy.flush_interval: "-1"
|
|
||||||
caddy.reverse_proxy.transport: http
|
|
||||||
caddy.reverse_proxy.transport.read_timeout: "0"
|
|
||||||
caddy.reverse_proxy.transport.write_timeout: "0"
|
|
||||||
|
|
||||||
networks:
|
|
||||||
caddy:
|
|
||||||
external: true
|
|
||||||
```
|
|
||||||
|
|
||||||
<Aside type="tip">
|
|
||||||
The `flush_interval` and timeout settings are important for MCP's streaming protocol. Without them, connections may drop unexpectedly.
|
|
||||||
</Aside>
|
|
||||||
|
|
||||||
## Building from Source
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Clone repository
|
|
||||||
git clone https://git.supported.systems/MCP/mcvsphere.git
|
|
||||||
cd mcvsphere
|
|
||||||
|
|
||||||
# Build image
|
|
||||||
docker build -t mcvsphere:local .
|
|
||||||
|
|
||||||
# Run
|
|
||||||
docker run -d --env-file .env mcvsphere:local
|
|
||||||
```
|
|
||||||
|
|
||||||
### Multi-Stage Build
|
|
||||||
|
|
||||||
The Dockerfile uses a multi-stage build for minimal image size:
|
|
||||||
|
|
||||||
1. **Builder stage**: Installs dependencies with `uv`
|
|
||||||
2. **Production stage**: Copies only the virtual environment, runs as non-root user
|
|
||||||
|
|
||||||
## Health Checks
|
|
||||||
|
|
||||||
The container includes automatic health checks that verify the MCP endpoint is responding:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Check container health
|
|
||||||
docker inspect --format='{{.State.Health.Status}}' mcvsphere
|
|
||||||
|
|
||||||
# View health check logs
|
|
||||||
docker inspect --format='{{range .State.Health.Log}}{{.Output}}{{end}}' mcvsphere
|
|
||||||
```
|
|
||||||
|
|
||||||
## Logging
|
|
||||||
|
|
||||||
Logs are written to `/app/logs` inside the container. Mount a volume to persist them:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
volumes:
|
|
||||||
- ./logs:/app/logs
|
|
||||||
```
|
|
||||||
|
|
||||||
View logs:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Docker logs
|
|
||||||
docker logs mcvsphere
|
|
||||||
|
|
||||||
# Application logs (if volume mounted)
|
|
||||||
tail -f ./logs/mcvsphere.log
|
|
||||||
```
|
|
||||||
|
|
||||||
## Resource Limits
|
|
||||||
|
|
||||||
Recommended resource limits:
|
|
||||||
|
|
||||||
| Setting | Development | Production |
|
|
||||||
|---------|-------------|------------|
|
|
||||||
| Memory limit | 256M | 512M |
|
|
||||||
| Memory reservation | 128M | 256M |
|
|
||||||
| CPU limit | 0.5 | 1.0 |
|
|
||||||
| CPU reservation | 0.1 | 0.25 |
|
|
||||||
|
|
||||||
## Next Steps
|
|
||||||
|
|
||||||
- [OAuth Multi-User Mode](/deployment/oauth/) — Enable authentication for shared deployments
|
|
||||||
- [RBAC Permissions](/reference/rbac/) — Understand permission levels
|
|
||||||
@ -1,259 +0,0 @@
|
|||||||
---
|
|
||||||
title: OAuth Multi-User Mode
|
|
||||||
description: Enable browser-based authentication with any OIDC provider
|
|
||||||
---
|
|
||||||
|
|
||||||
import { Tabs, TabItem, Aside, Steps, Badge } from '@astrojs/starlight/components';
|
|
||||||
|
|
||||||
For shared infrastructure or production deployments, mcvsphere supports OAuth 2.1 with any OIDC-compliant provider. This enables:
|
|
||||||
|
|
||||||
- **Browser-based authentication** via Authentik, Keycloak, Auth0, Okta, etc.
|
|
||||||
- **Group-based RBAC** with 5 permission levels
|
|
||||||
- **Audit logging** with user identity
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────────────────────────────┐
|
|
||||||
│ MCP Client (Claude Code) │
|
|
||||||
└─────────────────────────────┬───────────────────────────────┘
|
|
||||||
│ 1. OAuth 2.1 + PKCE flow
|
|
||||||
│ (browser opens for login)
|
|
||||||
┌─────────────────────────────▼───────────────────────────────┐
|
|
||||||
│ OIDC Provider │
|
|
||||||
│ (Authentik, Keycloak, Auth0, etc.) │
|
|
||||||
│ - Issues JWT access tokens │
|
|
||||||
│ - Validates user credentials │
|
|
||||||
│ - Includes groups claim in token │
|
|
||||||
└─────────────────────────────┬───────────────────────────────┘
|
|
||||||
│ 2. JWT Bearer token
|
|
||||||
┌─────────────────────────────▼───────────────────────────────┐
|
|
||||||
│ mcvsphere │
|
|
||||||
│ ┌────────────────────────────────────────────────────┐ │
|
|
||||||
│ │ OIDCProxy: Validates JWT via JWKS, extracts claims │ │
|
|
||||||
│ └────────────────────────────────────────────────────┘ │
|
|
||||||
│ ┌────────────────────────────────────────────────────┐ │
|
|
||||||
│ │ RBACMiddleware: Maps groups → permissions │ │
|
|
||||||
│ └────────────────────────────────────────────────────┘ │
|
|
||||||
│ ┌────────────────────────────────────────────────────┐ │
|
|
||||||
│ │ VMware Tools: Execute vCenter operations │ │
|
|
||||||
│ └────────────────────────────────────────────────────┘ │
|
|
||||||
└─────────────────────────────┬───────────────────────────────┘
|
|
||||||
│ 3. pyvmomi (service account)
|
|
||||||
┌─────────────────────────────▼───────────────────────────────┐
|
|
||||||
│ vCenter / ESXi │
|
|
||||||
└─────────────────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
## Quick Start
|
|
||||||
|
|
||||||
<Tabs>
|
|
||||||
<TabItem label="Existing OIDC Provider">
|
|
||||||
If you already have Authentik, Keycloak, Auth0, or another OIDC provider:
|
|
||||||
|
|
||||||
<Steps>
|
|
||||||
|
|
||||||
1. **Create OAuth application in your provider**
|
|
||||||
|
|
||||||
- Client type: Confidential
|
|
||||||
- Redirect URI: `https://mcp.example.com/auth/callback`
|
|
||||||
- Scopes: `openid`, `profile`, `email`, `groups`
|
|
||||||
|
|
||||||
2. **Configure environment**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cat > .env << 'EOF'
|
|
||||||
# vCenter
|
|
||||||
VCENTER_HOST=vcenter.example.com
|
|
||||||
VCENTER_USER=svc-mcvsphere@vsphere.local
|
|
||||||
VCENTER_PASSWORD=your-password
|
|
||||||
|
|
||||||
# Transport (required for OAuth)
|
|
||||||
MCP_TRANSPORT=streamable-http
|
|
||||||
MCP_DOMAIN=mcp.example.com
|
|
||||||
|
|
||||||
# OAuth
|
|
||||||
OAUTH_ENABLED=true
|
|
||||||
OAUTH_ISSUER_URL=https://auth.example.com/application/o/mcvsphere/
|
|
||||||
OAUTH_CLIENT_ID=your-client-id
|
|
||||||
OAUTH_CLIENT_SECRET=your-client-secret
|
|
||||||
OAUTH_BASE_URL=https://mcp.example.com
|
|
||||||
EOF
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Deploy with Docker**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose -f docker-compose.oauth-standalone.yml up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
4. **Add to Claude Code**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
claude mcp add -t http vsphere https://mcp.example.com/mcp
|
|
||||||
```
|
|
||||||
|
|
||||||
</Steps>
|
|
||||||
</TabItem>
|
|
||||||
<TabItem label="New Authentik Setup">
|
|
||||||
To deploy mcvsphere with a fresh Authentik identity provider:
|
|
||||||
|
|
||||||
<Steps>
|
|
||||||
|
|
||||||
1. **Generate secrets**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./scripts/setup-oauth.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Start full stack**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose -f docker-compose.oauth.yml up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
This starts:
|
|
||||||
- mcvsphere (MCP server)
|
|
||||||
- Authentik (identity provider)
|
|
||||||
- PostgreSQL (Authentik database)
|
|
||||||
- Redis (Authentik cache)
|
|
||||||
|
|
||||||
3. **Configure Authentik**
|
|
||||||
|
|
||||||
Open `https://auth.yourdomain.com` and:
|
|
||||||
- Create OAuth2 provider for mcvsphere
|
|
||||||
- Create `vsphere-*` groups
|
|
||||||
- Assign users to groups
|
|
||||||
|
|
||||||
</Steps>
|
|
||||||
</TabItem>
|
|
||||||
</Tabs>
|
|
||||||
|
|
||||||
## RBAC Permission Groups
|
|
||||||
|
|
||||||
Create these groups in your OIDC provider and assign users:
|
|
||||||
|
|
||||||
| Group | Access Level |
|
|
||||||
|-------|--------------|
|
|
||||||
| `vsphere-super-admins` | Full control (all 94 tools) |
|
|
||||||
| `vsphere-host-admins` | Host operations + VM management |
|
|
||||||
| `vsphere-admins` | VM lifecycle management |
|
|
||||||
| `vsphere-operators` | Power ops + snapshots |
|
|
||||||
| `vsphere-readers` | Read-only |
|
|
||||||
|
|
||||||
<Aside type="caution">
|
|
||||||
**Default Deny**: Users without any `vsphere-*` group are denied all access. This is a security feature, not a bug.
|
|
||||||
</Aside>
|
|
||||||
|
|
||||||
See [RBAC Permissions Reference](/reference/rbac/) for complete tool-to-permission mappings.
|
|
||||||
|
|
||||||
## Authentik Configuration
|
|
||||||
|
|
||||||
<Steps>
|
|
||||||
|
|
||||||
1. **Create OAuth2/OIDC Provider**
|
|
||||||
|
|
||||||
In Authentik, go to **Applications** → **Providers** → **Create**
|
|
||||||
|
|
||||||
- Name: `mcvsphere`
|
|
||||||
- Authorization flow: `default-authorization-flow`
|
|
||||||
- Client type: **Confidential**
|
|
||||||
- Client ID: (auto-generated, copy this)
|
|
||||||
- Client Secret: (auto-generated, copy this)
|
|
||||||
- Redirect URIs:
|
|
||||||
```
|
|
||||||
http://localhost:*/callback
|
|
||||||
https://mcp.example.com/auth/callback
|
|
||||||
```
|
|
||||||
- Signing Key: Select your RS256 certificate
|
|
||||||
|
|
||||||
2. **Create Application**
|
|
||||||
|
|
||||||
Go to **Applications** → **Applications** → **Create**
|
|
||||||
|
|
||||||
- Name: `mcvsphere`
|
|
||||||
- Slug: `mcvsphere`
|
|
||||||
- Provider: Select the provider from step 1
|
|
||||||
|
|
||||||
3. **Create Groups**
|
|
||||||
|
|
||||||
Go to **Directory** → **Groups** → **Create** for each:
|
|
||||||
|
|
||||||
- `vsphere-readers`
|
|
||||||
- `vsphere-operators`
|
|
||||||
- `vsphere-admins`
|
|
||||||
- `vsphere-host-admins`
|
|
||||||
- `vsphere-super-admins`
|
|
||||||
|
|
||||||
4. **Assign Users to Groups**
|
|
||||||
|
|
||||||
Edit each user and add them to appropriate groups.
|
|
||||||
|
|
||||||
</Steps>
|
|
||||||
|
|
||||||
## OAuth Flow
|
|
||||||
|
|
||||||
When a user connects with Claude Code:
|
|
||||||
|
|
||||||
1. **Initial connection** — Client connects without auth, server returns 401 with OAuth metadata URL
|
|
||||||
2. **Discovery** — Client fetches OIDC configuration from provider
|
|
||||||
3. **Authorization** — Browser opens, user logs in via OIDC provider
|
|
||||||
4. **Token exchange** — Authorization code exchanged for JWT access token
|
|
||||||
5. **Authenticated requests** — Client includes `Authorization: Bearer <jwt>` header
|
|
||||||
6. **Permission check** — RBACMiddleware validates user's groups against required tool permissions
|
|
||||||
7. **Audit logging** — Every operation logged with user identity and timing
|
|
||||||
|
|
||||||
## Audit Logging
|
|
||||||
|
|
||||||
All tool invocations are logged with user context:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"timestamp": "2025-01-16T08:15:32.123456+00:00",
|
|
||||||
"user": "ryan@example.com",
|
|
||||||
"groups": ["vsphere-admins", "vsphere-operators"],
|
|
||||||
"tool": "power_on",
|
|
||||||
"args": {"vm_name": "web-server"},
|
|
||||||
"duration_ms": 1234.56,
|
|
||||||
"result": "success"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Permission denied events:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"timestamp": "2025-01-16T08:15:32.123456+00:00",
|
|
||||||
"user": "guest@example.com",
|
|
||||||
"groups": ["vsphere-readers"],
|
|
||||||
"tool": "delete_vm",
|
|
||||||
"required_permission": "vm_lifecycle",
|
|
||||||
"event": "PERMISSION_DENIED"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### "401 Unauthorized" on all requests
|
|
||||||
|
|
||||||
- Check `OAUTH_ISSUER_URL` points to valid OIDC discovery endpoint
|
|
||||||
- Verify client ID and secret match provider configuration
|
|
||||||
- Ensure token hasn't expired
|
|
||||||
|
|
||||||
### "Permission denied" errors
|
|
||||||
|
|
||||||
- Check user's group memberships in OIDC provider
|
|
||||||
- Verify groups claim is included in JWT (decode at [jwt.io](https://jwt.io))
|
|
||||||
- Confirm group names match exactly: `vsphere-admins` not `vsphere_admins`
|
|
||||||
|
|
||||||
### Token validation fails
|
|
||||||
|
|
||||||
- Ensure OIDC provider issues JWTs (not opaque tokens)
|
|
||||||
- Check signing key is configured in provider
|
|
||||||
- Verify `OAUTH_BASE_URL` matches redirect URI in provider
|
|
||||||
|
|
||||||
### User shows as "anonymous"
|
|
||||||
|
|
||||||
- Restart Claude Code session after OAuth flow completes
|
|
||||||
- Check server logs for claims extraction errors
|
|
||||||
- Verify `groups` scope is included in token request
|
|
||||||
@ -1,71 +0,0 @@
|
|||||||
---
|
|
||||||
title: Installation
|
|
||||||
description: Install mcvsphere in under 30 seconds
|
|
||||||
---
|
|
||||||
|
|
||||||
import { Tabs, TabItem } from '@astrojs/starlight/components';
|
|
||||||
|
|
||||||
## Quick Install
|
|
||||||
|
|
||||||
<Tabs>
|
|
||||||
<TabItem label="uvx (Recommended)">
|
|
||||||
```bash
|
|
||||||
uvx mcvsphere
|
|
||||||
```
|
|
||||||
|
|
||||||
No installation needed. uvx downloads and runs in one command.
|
|
||||||
</TabItem>
|
|
||||||
<TabItem label="pip">
|
|
||||||
```bash
|
|
||||||
pip install mcvsphere
|
|
||||||
```
|
|
||||||
|
|
||||||
Then run with:
|
|
||||||
```bash
|
|
||||||
mcvsphere
|
|
||||||
```
|
|
||||||
</TabItem>
|
|
||||||
<TabItem label="pipx">
|
|
||||||
```bash
|
|
||||||
pipx install mcvsphere
|
|
||||||
```
|
|
||||||
|
|
||||||
Isolated environment, available system-wide.
|
|
||||||
</TabItem>
|
|
||||||
</Tabs>
|
|
||||||
|
|
||||||
## Requirements
|
|
||||||
|
|
||||||
| Requirement | Version |
|
|
||||||
|------------|---------|
|
|
||||||
| Python | 3.11+ |
|
|
||||||
| VMware vSphere | 7.0+ |
|
|
||||||
| VMware Tools | Latest (for guest operations) |
|
|
||||||
|
|
||||||
## Verify Installation
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Check version
|
|
||||||
uvx mcvsphere --version
|
|
||||||
|
|
||||||
# Or if installed via pip
|
|
||||||
mcvsphere --version
|
|
||||||
```
|
|
||||||
|
|
||||||
You should see something like:
|
|
||||||
|
|
||||||
```
|
|
||||||
mcvsphere v0.2.3
|
|
||||||
```
|
|
||||||
|
|
||||||
## Dependencies
|
|
||||||
|
|
||||||
mcvsphere installs these automatically:
|
|
||||||
|
|
||||||
- **[FastMCP](https://gofastmcp.com)** — MCP server framework
|
|
||||||
- **[pyvmomi](https://github.com/vmware/pyvmomi)** — VMware vSphere Python SDK
|
|
||||||
- **[pydantic](https://docs.pydantic.dev)** — Configuration validation
|
|
||||||
|
|
||||||
## Next Steps
|
|
||||||
|
|
||||||
Now that mcvsphere is installed, [configure your vCenter connection](/getting-started/quickstart/).
|
|
||||||
@ -1,71 +0,0 @@
|
|||||||
---
|
|
||||||
title: Introduction
|
|
||||||
description: What is mcvsphere and why you'd want it
|
|
||||||
---
|
|
||||||
|
|
||||||
mcvsphere is a Model Context Protocol (MCP) server that connects AI assistants like Claude to your VMware vSphere infrastructure. Instead of clicking through the vSphere UI or writing scripts, you can manage your virtual machines through natural language conversation.
|
|
||||||
|
|
||||||
## How It Works
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────────────────────────────┐
|
|
||||||
│ Claude Code / MCP Client │
|
|
||||||
└──────────────────────┬──────────────────────────────────────┘
|
|
||||||
│ MCP Protocol (stdio or HTTP)
|
|
||||||
│
|
|
||||||
┌──────────────────────▼──────────────────────────────────────┐
|
|
||||||
│ mcvsphere │
|
|
||||||
│ FastMCP + pyvmomi │
|
|
||||||
│ │
|
|
||||||
│ 94 Tools 6 Resources │
|
|
||||||
│ • VM lifecycle • VMs list │
|
|
||||||
│ • Power operations • Hosts list │
|
|
||||||
│ • Snapshots • Datastores │
|
|
||||||
│ • Guest operations • Networks │
|
|
||||||
│ • Disk/NIC management • Clusters │
|
|
||||||
│ • Host management • Resource pools │
|
|
||||||
└──────────────────────┬──────────────────────────────────────┘
|
|
||||||
│ pyvmomi (vSphere API)
|
|
||||||
│
|
|
||||||
┌──────────────────────▼──────────────────────────────────────┐
|
|
||||||
│ VMware vCenter / ESXi Host │
|
|
||||||
└─────────────────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
## Two Operating Modes
|
|
||||||
|
|
||||||
### STDIO Mode (Single User)
|
|
||||||
|
|
||||||
Direct connection for local development or single-user setups. Claude Code spawns the MCP server as a subprocess.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
claude mcp add vsphere "uvx mcvsphere"
|
|
||||||
```
|
|
||||||
|
|
||||||
### HTTP + OAuth Mode (Multi-User)
|
|
||||||
|
|
||||||
Browser-based authentication via any OIDC provider. Multiple users share one server, each with their own permissions based on group membership.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
claude mcp add -t http vsphere https://mcp.example.com/mcp
|
|
||||||
```
|
|
||||||
|
|
||||||
## What Can You Do?
|
|
||||||
|
|
||||||
With mcvsphere connected, you can ask Claude to:
|
|
||||||
|
|
||||||
- **Create infrastructure**: "Spin up 3 Ubuntu VMs with 4 CPUs each"
|
|
||||||
- **Manage power**: "Power on all VMs starting with 'prod-'"
|
|
||||||
- **Take snapshots**: "Snapshot all production VMs before the update"
|
|
||||||
- **Run commands**: "Check the disk usage on linux-server" (no SSH needed)
|
|
||||||
- **Configure hardware**: "Add a 100GB disk to the database server"
|
|
||||||
- **Deploy appliances**: "Deploy this OVA to the dev cluster"
|
|
||||||
- **Monitor systems**: "Show me which hosts have high memory usage"
|
|
||||||
|
|
||||||
All 94 tools work through conversation. No scripting required.
|
|
||||||
|
|
||||||
## Next Steps
|
|
||||||
|
|
||||||
- [Install mcvsphere](/getting-started/installation/)
|
|
||||||
- [Configure vCenter connection](/getting-started/quickstart/)
|
|
||||||
- [Learn about all 94 tools](/reference/tools/)
|
|
||||||
@ -1,102 +0,0 @@
|
|||||||
---
|
|
||||||
title: Quick Start
|
|
||||||
description: Connect mcvsphere to vCenter and Claude Code in 2 minutes
|
|
||||||
---
|
|
||||||
|
|
||||||
import { Steps, Aside } from '@astrojs/starlight/components';
|
|
||||||
|
|
||||||
## Single-User Setup (STDIO)
|
|
||||||
|
|
||||||
<Steps>
|
|
||||||
|
|
||||||
1. **Set your vCenter credentials**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
export VCENTER_HOST=vcenter.example.com
|
|
||||||
export VCENTER_USER=administrator@vsphere.local
|
|
||||||
export VCENTER_PASSWORD=your-password
|
|
||||||
```
|
|
||||||
|
|
||||||
<Aside type="tip">
|
|
||||||
For self-signed certificates in dev/lab environments:
|
|
||||||
```bash
|
|
||||||
export VCENTER_INSECURE=true
|
|
||||||
```
|
|
||||||
</Aside>
|
|
||||||
|
|
||||||
2. **Add to Claude Code**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
claude mcp add vsphere "uvx mcvsphere"
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Test the connection**
|
|
||||||
|
|
||||||
Open Claude Code and ask:
|
|
||||||
```
|
|
||||||
List all VMs in my vCenter
|
|
||||||
```
|
|
||||||
|
|
||||||
Claude should respond with your VM inventory.
|
|
||||||
|
|
||||||
</Steps>
|
|
||||||
|
|
||||||
## Multi-User Setup (HTTP + OAuth)
|
|
||||||
|
|
||||||
For shared infrastructure with multiple users, see [OAuth Multi-User Deployment](/deployment/oauth/).
|
|
||||||
|
|
||||||
## Example Conversations
|
|
||||||
|
|
||||||
Once connected, try these:
|
|
||||||
|
|
||||||
### Create a VM
|
|
||||||
|
|
||||||
```
|
|
||||||
Create a VM called "test-web" with 4 CPUs, 8GB RAM, and 100GB disk
|
|
||||||
```
|
|
||||||
|
|
||||||
### Manage Snapshots
|
|
||||||
|
|
||||||
```
|
|
||||||
Take a snapshot of production-db called "before-upgrade"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Run Commands in Guests
|
|
||||||
|
|
||||||
```
|
|
||||||
What's the uptime on linux-server?
|
|
||||||
```
|
|
||||||
|
|
||||||
mcvsphere runs `uptime` inside the VM via VMware Tools—no SSH required.
|
|
||||||
|
|
||||||
### Take Console Screenshots
|
|
||||||
|
|
||||||
```
|
|
||||||
Show me what's on the screen of that stuck VM
|
|
||||||
```
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### "Failed to connect to vCenter"
|
|
||||||
|
|
||||||
- Verify `VCENTER_HOST` is reachable: `nc -zv $VCENTER_HOST 443`
|
|
||||||
- Check username format: `admin@vsphere.local` not just `admin`
|
|
||||||
- Try `VCENTER_INSECURE=true` for certificate issues
|
|
||||||
|
|
||||||
### "VMware Tools not running"
|
|
||||||
|
|
||||||
Guest operations require VMware Tools installed and running in the VM:
|
|
||||||
|
|
||||||
```
|
|
||||||
Check VMware Tools status on linux-server
|
|
||||||
```
|
|
||||||
|
|
||||||
### "Permission denied"
|
|
||||||
|
|
||||||
Your vCenter user needs appropriate permissions. For testing, use an administrator account. For production, create a dedicated service account with minimal required permissions.
|
|
||||||
|
|
||||||
## Next Steps
|
|
||||||
|
|
||||||
- [Environment Variables Reference](/configuration/environment/)
|
|
||||||
- [Docker Deployment](/deployment/docker/)
|
|
||||||
- [All 94 Tools](/reference/tools/)
|
|
||||||
@ -1,81 +0,0 @@
|
|||||||
---
|
|
||||||
title: mcvsphere
|
|
||||||
description: 94 tools. Full VMware control. Natural language.
|
|
||||||
template: splash
|
|
||||||
hero:
|
|
||||||
tagline: Stop clicking through vSphere. Start talking to it.
|
|
||||||
image:
|
|
||||||
file: ../../assets/logo.svg
|
|
||||||
actions:
|
|
||||||
- text: Get Started
|
|
||||||
link: /getting-started/installation/
|
|
||||||
icon: right-arrow
|
|
||||||
- text: View on GitHub
|
|
||||||
link: https://git.supported.systems/MCP/mcvsphere
|
|
||||||
icon: external
|
|
||||||
variant: minimal
|
|
||||||
---
|
|
||||||
|
|
||||||
import { Card, CardGrid } from '@astrojs/starlight/components';
|
|
||||||
|
|
||||||
## What is mcvsphere?
|
|
||||||
|
|
||||||
mcvsphere is an MCP server that gives AI assistants like Claude complete control over your VMware infrastructure. Create VMs, manage snapshots, run commands inside guests, take console screenshots, configure network appliances—all through conversation.
|
|
||||||
|
|
||||||
**94 MCP tools.** Not a toy demo. Not "just the basics." The whole thing.
|
|
||||||
|
|
||||||
```
|
|
||||||
You: "Spin up a new VM with 4 CPUs and 16GB RAM, clone it twice,
|
|
||||||
then snapshot all three before I start testing"
|
|
||||||
|
|
||||||
Claude: Done. Three VMs running, all snapshotted. What's next?
|
|
||||||
```
|
|
||||||
|
|
||||||
## Key Features
|
|
||||||
|
|
||||||
<CardGrid stagger>
|
|
||||||
<Card title="94 MCP Tools" icon="rocket">
|
|
||||||
VM lifecycle, power operations, snapshots, guest operations, serial console, disk management, network adapters, OVF/OVA, host management, and more.
|
|
||||||
</Card>
|
|
||||||
<Card title="Guest Operations" icon="seti:shell">
|
|
||||||
Run commands inside VMs, transfer files, browse filesystems—no SSH required. VMware Tools does the heavy lifting.
|
|
||||||
</Card>
|
|
||||||
<Card title="OAuth + RBAC" icon="shield">
|
|
||||||
Multi-user authentication via any OIDC provider with 5 permission levels. Audit logging shows WHO did WHAT.
|
|
||||||
</Card>
|
|
||||||
<Card title="Real-time Resources" icon="list-format">
|
|
||||||
6 MCP resources provide always-current data: VMs, hosts, datastores, networks, clusters, resource pools.
|
|
||||||
</Card>
|
|
||||||
</CardGrid>
|
|
||||||
|
|
||||||
## Quick Example
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Install
|
|
||||||
uvx mcvsphere
|
|
||||||
|
|
||||||
# Configure
|
|
||||||
export VCENTER_HOST=vcenter.example.com
|
|
||||||
export VCENTER_USER=administrator@vsphere.local
|
|
||||||
export VCENTER_PASSWORD=your-password
|
|
||||||
|
|
||||||
# Add to Claude Code
|
|
||||||
claude mcp add vsphere "uvx mcvsphere"
|
|
||||||
```
|
|
||||||
|
|
||||||
That's it. You're managing vSphere with AI now.
|
|
||||||
|
|
||||||
## Requirements
|
|
||||||
|
|
||||||
- Python 3.11+
|
|
||||||
- VMware vSphere 7.0+ (ESXi or vCenter)
|
|
||||||
- VMware Tools (for guest operations)
|
|
||||||
|
|
||||||
## Built With
|
|
||||||
|
|
||||||
- [FastMCP](https://github.com/jlowin/fastmcp) — MCP server framework
|
|
||||||
- [pyVmomi](https://github.com/vmware/pyvmomi) — VMware vSphere API
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**94 tools. Your entire VMware infrastructure. Controlled by conversation.**
|
|
||||||
@ -1,239 +0,0 @@
|
|||||||
---
|
|
||||||
title: Architecture
|
|
||||||
description: Technical overview of mcvsphere internals
|
|
||||||
---
|
|
||||||
|
|
||||||
import { Aside } from '@astrojs/starlight/components';
|
|
||||||
|
|
||||||
## System Architecture
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────────────────────────────┐
|
|
||||||
│ MCP Client (Claude Code) │
|
|
||||||
└──────────────────────────┬──────────────────────────────────┘
|
|
||||||
│
|
|
||||||
│ MCP Protocol
|
|
||||||
│ (stdio or streamable-http)
|
|
||||||
│
|
|
||||||
┌──────────────────────────▼──────────────────────────────────┐
|
|
||||||
│ mcvsphere │
|
|
||||||
│ │
|
|
||||||
│ ┌───────────────────────────────────────────────────────┐ │
|
|
||||||
│ │ FastMCP │ │
|
|
||||||
│ │ - Protocol handling (JSON-RPC 2.0) │ │
|
|
||||||
│ │ - Tool registration and dispatch │ │
|
|
||||||
│ │ - Resource serving │ │
|
|
||||||
│ │ - OAuth integration (OIDCProxy) │ │
|
|
||||||
│ └───────────────────────────────────────────────────────┘ │
|
|
||||||
│ │ │
|
|
||||||
│ ┌───────────────────────────────────────────────────────┐ │
|
|
||||||
│ │ RBACMiddleware │ │
|
|
||||||
│ │ - Intercepts all tool calls │ │
|
|
||||||
│ │ - Extracts user from OAuth token │ │
|
|
||||||
│ │ - Enforces permission checks │ │
|
|
||||||
│ │ - Audit logging │ │
|
|
||||||
│ └───────────────────────────────────────────────────────┘ │
|
|
||||||
│ │ │
|
|
||||||
│ ┌───────────────────────────────────────────────────────┐ │
|
|
||||||
│ │ Tool Implementations │ │
|
|
||||||
│ │ - vm_lifecycle.py (7 tools) │ │
|
|
||||||
│ │ - power_ops.py (6 tools) │ │
|
|
||||||
│ │ - snapshots.py (5 tools) │ │
|
|
||||||
│ │ - guest_ops.py (7 tools) │ │
|
|
||||||
│ │ - ... (94 tools total) │ │
|
|
||||||
│ └───────────────────────────────────────────────────────┘ │
|
|
||||||
│ │ │
|
|
||||||
│ ┌───────────────────────────────────────────────────────┐ │
|
|
||||||
│ │ Connection Manager │ │
|
|
||||||
│ │ - pyvmomi ServiceInstance │ │
|
|
||||||
│ │ - Connection pooling │ │
|
|
||||||
│ │ - Reconnection handling │ │
|
|
||||||
│ └───────────────────────────────────────────────────────┘ │
|
|
||||||
└──────────────────────────┬──────────────────────────────────┘
|
|
||||||
│
|
|
||||||
│ vSphere API (SOAP/REST)
|
|
||||||
│
|
|
||||||
┌──────────────────────────▼──────────────────────────────────┐
|
|
||||||
│ VMware vCenter / ESXi │
|
|
||||||
└─────────────────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
## Source Layout
|
|
||||||
|
|
||||||
```
|
|
||||||
src/mcvsphere/
|
|
||||||
├── __init__.py # Package entry point, version
|
|
||||||
├── server.py # FastMCP server setup
|
|
||||||
├── config.py # Pydantic Settings configuration
|
|
||||||
├── connection.py # vSphere connection manager
|
|
||||||
├── auth.py # OIDCProxy configuration
|
|
||||||
├── middleware.py # RBAC middleware
|
|
||||||
├── permissions.py # Permission levels and mappings
|
|
||||||
├── audit.py # Audit logging
|
|
||||||
└── tools/ # MCP tool implementations
|
|
||||||
├── __init__.py
|
|
||||||
├── vm_lifecycle.py
|
|
||||||
├── power_ops.py
|
|
||||||
├── snapshots.py
|
|
||||||
├── guest_ops.py
|
|
||||||
├── serial_console.py
|
|
||||||
├── disk_mgmt.py
|
|
||||||
├── nic_mgmt.py
|
|
||||||
├── ovf_ops.py
|
|
||||||
├── host_mgmt.py
|
|
||||||
├── datastore_ops.py
|
|
||||||
└── vcenter_ops.py
|
|
||||||
```
|
|
||||||
|
|
||||||
## Key Components
|
|
||||||
|
|
||||||
### FastMCP Server (`server.py`)
|
|
||||||
|
|
||||||
The core MCP server built on [FastMCP](https://gofastmcp.com):
|
|
||||||
|
|
||||||
```python
|
|
||||||
from fastmcp import FastMCP
|
|
||||||
|
|
||||||
mcp = FastMCP(
|
|
||||||
name="mcvsphere",
|
|
||||||
instructions="VMware vSphere management server..."
|
|
||||||
)
|
|
||||||
|
|
||||||
# Tools are registered via decorators
|
|
||||||
@mcp.tool()
|
|
||||||
def list_vms() -> list[dict]:
|
|
||||||
"""List all virtual machines."""
|
|
||||||
return connection.list_vms()
|
|
||||||
```
|
|
||||||
|
|
||||||
### Configuration (`config.py`)
|
|
||||||
|
|
||||||
Pydantic Settings model for type-safe configuration:
|
|
||||||
|
|
||||||
```python
|
|
||||||
from pydantic_settings import BaseSettings
|
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
|
||||||
vcenter_host: str
|
|
||||||
vcenter_user: str
|
|
||||||
vcenter_password: str
|
|
||||||
vcenter_insecure: bool = False
|
|
||||||
mcp_transport: Literal["stdio", "streamable-http"] = "stdio"
|
|
||||||
oauth_enabled: bool = False
|
|
||||||
# ...
|
|
||||||
```
|
|
||||||
|
|
||||||
### Connection Manager (`connection.py`)
|
|
||||||
|
|
||||||
Manages the pyvmomi ServiceInstance connection:
|
|
||||||
|
|
||||||
```python
|
|
||||||
class VSphereConnection:
|
|
||||||
def __init__(self, settings: Settings):
|
|
||||||
self.si = SmartConnect(
|
|
||||||
host=settings.vcenter_host,
|
|
||||||
user=settings.vcenter_user,
|
|
||||||
pwd=settings.vcenter_password,
|
|
||||||
sslContext=self._get_ssl_context()
|
|
||||||
)
|
|
||||||
|
|
||||||
def list_vms(self) -> list[dict]:
|
|
||||||
content = self.si.RetrieveContent()
|
|
||||||
# ... pyvmomi operations
|
|
||||||
```
|
|
||||||
|
|
||||||
### RBAC Middleware (`middleware.py`)
|
|
||||||
|
|
||||||
Intercepts all tool calls for permission checking:
|
|
||||||
|
|
||||||
```python
|
|
||||||
from fastmcp import Middleware
|
|
||||||
|
|
||||||
class RBACMiddleware(Middleware):
|
|
||||||
async def on_call_tool(self, context, call_next):
|
|
||||||
# 1. Extract user from OAuth token
|
|
||||||
claims = self._extract_user_from_context(context.fastmcp_context)
|
|
||||||
groups = claims.get("groups", [])
|
|
||||||
|
|
||||||
# 2. Check permission
|
|
||||||
tool_name = context.message.name
|
|
||||||
if not check_permission(tool_name, groups):
|
|
||||||
raise PermissionDeniedError(...)
|
|
||||||
|
|
||||||
# 3. Execute with audit logging
|
|
||||||
result = await call_next(context)
|
|
||||||
audit_log(tool_name, claims, result)
|
|
||||||
return result
|
|
||||||
```
|
|
||||||
|
|
||||||
### OAuth Configuration (`auth.py`)
|
|
||||||
|
|
||||||
Configures FastMCP's OIDCProxy for token validation:
|
|
||||||
|
|
||||||
```python
|
|
||||||
from fastmcp.server.auth import OIDCProxy
|
|
||||||
|
|
||||||
def create_auth_provider(settings: Settings) -> OIDCProxy | None:
|
|
||||||
if not settings.oauth_enabled:
|
|
||||||
return None
|
|
||||||
|
|
||||||
return OIDCProxy(
|
|
||||||
issuer_url=settings.oauth_issuer_url,
|
|
||||||
client_id=settings.oauth_client_id,
|
|
||||||
client_secret=settings.oauth_client_secret,
|
|
||||||
redirect_uri=f"{settings.oauth_base_url}/auth/callback",
|
|
||||||
required_scopes=[], # Authentik uses opaque tokens
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Data Flow
|
|
||||||
|
|
||||||
### Tool Invocation (STDIO mode)
|
|
||||||
|
|
||||||
1. Claude Code sends JSON-RPC request via stdin
|
|
||||||
2. FastMCP parses and validates request
|
|
||||||
3. Tool function executes
|
|
||||||
4. pyvmomi calls vSphere API
|
|
||||||
5. Result serialized and returned via stdout
|
|
||||||
|
|
||||||
### Tool Invocation (HTTP + OAuth mode)
|
|
||||||
|
|
||||||
1. Client sends HTTP POST with Bearer token
|
|
||||||
2. OIDCProxy validates JWT via JWKS
|
|
||||||
3. RBACMiddleware extracts claims, checks permissions
|
|
||||||
4. Tool function executes
|
|
||||||
5. Audit log written
|
|
||||||
6. Result returned as HTTP response
|
|
||||||
|
|
||||||
## Performance Considerations
|
|
||||||
|
|
||||||
### Connection Reuse
|
|
||||||
|
|
||||||
A single pyvmomi connection is shared across all requests. This avoids the overhead of establishing new connections for each tool call.
|
|
||||||
|
|
||||||
### Lazy Loading
|
|
||||||
|
|
||||||
vCenter inventory is fetched on-demand rather than cached. This ensures data freshness but means each tool call queries vCenter directly.
|
|
||||||
|
|
||||||
### Resource Pooling
|
|
||||||
|
|
||||||
For high-volume deployments, consider running multiple mcvsphere instances behind a load balancer. Each instance maintains its own vCenter connection.
|
|
||||||
|
|
||||||
## Security Model
|
|
||||||
|
|
||||||
### STDIO Mode
|
|
||||||
|
|
||||||
- No authentication (trusts the parent process)
|
|
||||||
- Single-user by design
|
|
||||||
- Credentials passed via environment variables
|
|
||||||
|
|
||||||
### HTTP + OAuth Mode
|
|
||||||
|
|
||||||
- JWT validation via OIDC provider
|
|
||||||
- Group-based RBAC
|
|
||||||
- Audit trail with user identity
|
|
||||||
- Service account connection to vCenter (user identity tracked in audit logs, not vCenter permissions)
|
|
||||||
|
|
||||||
<Aside>
|
|
||||||
In OAuth mode, ALL users share the same vCenter service account. User identity is tracked in mcvsphere audit logs, not vCenter audit logs. Future versions may support per-user vCenter credentials via Vault integration.
|
|
||||||
</Aside>
|
|
||||||
@ -1,184 +0,0 @@
|
|||||||
---
|
|
||||||
title: RBAC Permissions
|
|
||||||
description: Role-based access control for multi-user deployments
|
|
||||||
---
|
|
||||||
|
|
||||||
import { Aside } from '@astrojs/starlight/components';
|
|
||||||
|
|
||||||
When OAuth is enabled, mcvsphere enforces role-based access control (RBAC) based on the user's OAuth group memberships.
|
|
||||||
|
|
||||||
## Permission Levels
|
|
||||||
|
|
||||||
mcvsphere defines 5 permission levels, from least to most privileged:
|
|
||||||
|
|
||||||
| Level | Description | Tools |
|
|
||||||
|-------|-------------|-------|
|
|
||||||
| `READ_ONLY` | View-only operations | 32 tools |
|
|
||||||
| `POWER_OPS` | Power and snapshot operations | 14 tools |
|
|
||||||
| `VM_LIFECYCLE` | Create/delete/modify VMs | 33 tools |
|
|
||||||
| `HOST_ADMIN` | ESXi host operations | 6 tools |
|
|
||||||
| `FULL_ADMIN` | Everything including guest OS | 11 tools |
|
|
||||||
|
|
||||||
## OAuth Groups
|
|
||||||
|
|
||||||
Map OAuth groups to permission levels:
|
|
||||||
|
|
||||||
| OAuth Group | Permissions Granted |
|
|
||||||
|-------------|---------------------|
|
|
||||||
| `vsphere-readers` | READ_ONLY |
|
|
||||||
| `vsphere-operators` | READ_ONLY + POWER_OPS |
|
|
||||||
| `vsphere-admins` | READ_ONLY + POWER_OPS + VM_LIFECYCLE |
|
|
||||||
| `vsphere-host-admins` | READ_ONLY + POWER_OPS + VM_LIFECYCLE + HOST_ADMIN |
|
|
||||||
| `vsphere-super-admins` | ALL (full access) |
|
|
||||||
|
|
||||||
<Aside type="caution">
|
|
||||||
**Default Deny Security**: Users with NO recognized `vsphere-*` groups are denied ALL access. There is no implicit "read" permission.
|
|
||||||
</Aside>
|
|
||||||
|
|
||||||
## Tool Permission Mapping
|
|
||||||
|
|
||||||
### READ_ONLY (32 tools)
|
|
||||||
|
|
||||||
View and inspect operations:
|
|
||||||
|
|
||||||
```
|
|
||||||
list_vms, get_vm_info, list_snapshots, get_vm_stats
|
|
||||||
get_host_stats, vm_screenshot, get_vm_tools_status
|
|
||||||
wait_for_vm_tools, list_disks, get_disk_info
|
|
||||||
list_nics, get_nic_info, get_serial_port
|
|
||||||
list_hosts, get_host_info, get_host_hardware
|
|
||||||
get_host_networking, list_services, get_service_status
|
|
||||||
get_ntp_config, get_datastore_info, browse_datastore
|
|
||||||
get_vcenter_info, get_resource_pool_info, get_network_info
|
|
||||||
list_templates, get_alarms, get_recent_events
|
|
||||||
list_folders, list_clusters, get_cluster_info
|
|
||||||
list_resource_pools, list_tags, get_vm_tags
|
|
||||||
list_recent_tasks, list_recent_events
|
|
||||||
```
|
|
||||||
|
|
||||||
### POWER_OPS (14 tools)
|
|
||||||
|
|
||||||
Power state and snapshots:
|
|
||||||
|
|
||||||
```
|
|
||||||
power_on_vm, power_off_vm, shutdown_guest
|
|
||||||
reboot_guest, suspend_vm, reset_vm
|
|
||||||
create_snapshot, revert_to_snapshot
|
|
||||||
delete_snapshot, delete_all_snapshots
|
|
||||||
connect_nic, setup_serial_port
|
|
||||||
connect_serial_port, clear_serial_port
|
|
||||||
```
|
|
||||||
|
|
||||||
### VM_LIFECYCLE (33 tools)
|
|
||||||
|
|
||||||
Create, modify, and delete VMs:
|
|
||||||
|
|
||||||
```
|
|
||||||
create_vm, clone_vm, delete_vm
|
|
||||||
reconfigure_vm, rename_vm
|
|
||||||
add_disk, remove_disk, resize_disk
|
|
||||||
add_nic, remove_nic, change_nic_network
|
|
||||||
remove_serial_port
|
|
||||||
deploy_ovf, export_ovf, list_ovf_networks
|
|
||||||
upload_to_datastore, download_from_datastore
|
|
||||||
create_folder, delete_folder, move_vm_to_folder
|
|
||||||
create_resource_pool, delete_resource_pool
|
|
||||||
move_vm_to_resource_pool
|
|
||||||
apply_tag_to_vm, remove_tag_from_vm
|
|
||||||
migrate_vm, cancel_task
|
|
||||||
```
|
|
||||||
|
|
||||||
### HOST_ADMIN (6 tools)
|
|
||||||
|
|
||||||
ESXi host management:
|
|
||||||
|
|
||||||
```
|
|
||||||
start_service, stop_service, restart_service
|
|
||||||
enter_maintenance_mode, exit_maintenance_mode
|
|
||||||
reboot_host, shutdown_host
|
|
||||||
```
|
|
||||||
|
|
||||||
### FULL_ADMIN (11 tools)
|
|
||||||
|
|
||||||
Guest OS operations and full control:
|
|
||||||
|
|
||||||
```
|
|
||||||
run_command_in_guest, read_guest_file, write_guest_file
|
|
||||||
list_guest_processes, list_guest_directory
|
|
||||||
create_guest_directory, delete_guest_file
|
|
||||||
```
|
|
||||||
|
|
||||||
<Aside>
|
|
||||||
Guest operations are the most privileged because they allow arbitrary command execution inside VMs.
|
|
||||||
</Aside>
|
|
||||||
|
|
||||||
## Permission Check Flow
|
|
||||||
|
|
||||||
When a tool is invoked:
|
|
||||||
|
|
||||||
1. **Extract user identity** from OAuth token claims
|
|
||||||
2. **Get user's groups** from `groups` claim
|
|
||||||
3. **Look up required permission** for the tool
|
|
||||||
4. **Check if any group grants** the required permission
|
|
||||||
5. **Allow or deny** with audit logging
|
|
||||||
|
|
||||||
```python
|
|
||||||
# Pseudocode
|
|
||||||
def check_permission(tool_name: str, user_groups: list[str]) -> bool:
|
|
||||||
required = TOOL_PERMISSIONS[tool_name] # e.g., POWER_OPS
|
|
||||||
|
|
||||||
for group in user_groups:
|
|
||||||
if group in GROUP_PERMISSIONS:
|
|
||||||
granted = GROUP_PERMISSIONS[group] # e.g., {READ_ONLY, POWER_OPS}
|
|
||||||
if required in granted:
|
|
||||||
return True
|
|
||||||
|
|
||||||
return False # Default deny
|
|
||||||
```
|
|
||||||
|
|
||||||
## Audit Logging
|
|
||||||
|
|
||||||
Every permission decision is logged:
|
|
||||||
|
|
||||||
### Successful access
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"timestamp": "2025-01-16T08:15:32.123456+00:00",
|
|
||||||
"user": "ryan@example.com",
|
|
||||||
"groups": ["vsphere-admins"],
|
|
||||||
"tool": "power_on_vm",
|
|
||||||
"args": {"vm_name": "web-server"},
|
|
||||||
"duration_ms": 1234.56,
|
|
||||||
"result": "success"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Permission denied
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"timestamp": "2025-01-16T08:15:32.123456+00:00",
|
|
||||||
"user": "intern@example.com",
|
|
||||||
"groups": ["vsphere-readers"],
|
|
||||||
"tool": "delete_vm",
|
|
||||||
"args": {"vm_name": "production-db"},
|
|
||||||
"required_permission": "vm_lifecycle",
|
|
||||||
"event": "PERMISSION_DENIED"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### "Permission denied" for expected access
|
|
||||||
|
|
||||||
1. **Check group names match exactly**: `vsphere-admins` vs `vsphere_admins`
|
|
||||||
2. **Verify groups claim** in JWT token (decode at [jwt.io](https://jwt.io))
|
|
||||||
3. **Check group membership** in your OIDC provider
|
|
||||||
4. **Review audit logs** for the exact permission required
|
|
||||||
|
|
||||||
### User has no permissions at all
|
|
||||||
|
|
||||||
1. **Confirm user is in at least one** `vsphere-*` group
|
|
||||||
2. **Check OIDC provider** includes `groups` in token claims
|
|
||||||
3. **Verify** the groups scope is requested during OAuth flow
|
|
||||||
@ -1,202 +0,0 @@
|
|||||||
---
|
|
||||||
title: Tool Reference
|
|
||||||
description: Complete reference for all 94 MCP tools
|
|
||||||
---
|
|
||||||
|
|
||||||
import { Aside } from '@astrojs/starlight/components';
|
|
||||||
|
|
||||||
mcvsphere provides 94 MCP tools for comprehensive VMware vSphere management.
|
|
||||||
|
|
||||||
## VM Lifecycle
|
|
||||||
|
|
||||||
Create, clone, delete, and configure virtual machines.
|
|
||||||
|
|
||||||
| Tool | Description |
|
|
||||||
|------|-------------|
|
|
||||||
| `create_vm` | Create a new VM from scratch |
|
|
||||||
| `clone_vm` | Clone from template or existing VM |
|
|
||||||
| `delete_vm` | Remove a VM permanently |
|
|
||||||
| `reconfigure_vm` | Change CPU, memory, annotations |
|
|
||||||
| `rename_vm` | Rename a VM |
|
|
||||||
| `list_vms` | List all VMs |
|
|
||||||
| `get_vm_info` | Detailed VM information |
|
|
||||||
|
|
||||||
## Power Operations
|
|
||||||
|
|
||||||
Control VM power state.
|
|
||||||
|
|
||||||
| Tool | Description |
|
|
||||||
|------|-------------|
|
|
||||||
| `power_on_vm` | Start a VM |
|
|
||||||
| `power_off_vm` | Force power off (like pulling the plug) |
|
|
||||||
| `shutdown_guest` | Graceful OS shutdown via VMware Tools |
|
|
||||||
| `reboot_guest` | Graceful OS reboot via VMware Tools |
|
|
||||||
| `suspend_vm` | Suspend to memory |
|
|
||||||
| `reset_vm` | Hard reset |
|
|
||||||
|
|
||||||
<Aside type="tip">
|
|
||||||
Use `shutdown_guest` and `reboot_guest` for graceful operations. They require VMware Tools running in the guest.
|
|
||||||
</Aside>
|
|
||||||
|
|
||||||
## Snapshots
|
|
||||||
|
|
||||||
Point-in-time VM state management.
|
|
||||||
|
|
||||||
| Tool | Description |
|
|
||||||
|------|-------------|
|
|
||||||
| `create_snapshot` | Create a new snapshot |
|
|
||||||
| `revert_to_snapshot` | Restore VM to snapshot state |
|
|
||||||
| `delete_snapshot` | Remove a specific snapshot |
|
|
||||||
| `delete_all_snapshots` | Remove all snapshots (consolidate) |
|
|
||||||
| `list_snapshots` | List all snapshots |
|
|
||||||
|
|
||||||
## Guest Operations
|
|
||||||
|
|
||||||
Execute commands and transfer files inside VMs without SSH.
|
|
||||||
|
|
||||||
| Tool | Description |
|
|
||||||
|------|-------------|
|
|
||||||
| `run_command_in_guest` | Execute any command in the guest OS |
|
|
||||||
| `read_guest_file` | Download file from guest |
|
|
||||||
| `write_guest_file` | Upload file to guest |
|
|
||||||
| `list_guest_processes` | List running processes |
|
|
||||||
| `list_guest_directory` | Browse guest filesystem |
|
|
||||||
| `create_guest_directory` | Create directory in guest |
|
|
||||||
| `delete_guest_file` | Delete file in guest |
|
|
||||||
|
|
||||||
<Aside type="note">
|
|
||||||
Guest operations require VMware Tools installed and running. Use `get_vm_tools_status` to verify.
|
|
||||||
</Aside>
|
|
||||||
|
|
||||||
## Console & Monitoring
|
|
||||||
|
|
||||||
Visual access and performance monitoring.
|
|
||||||
|
|
||||||
| Tool | Description |
|
|
||||||
|------|-------------|
|
|
||||||
| `vm_screenshot` | Capture VM console as image |
|
|
||||||
| `get_vm_stats` | CPU, memory, disk, network metrics |
|
|
||||||
| `get_host_stats` | ESXi host performance metrics |
|
|
||||||
| `wait_for_vm_tools` | Block until Tools are ready |
|
|
||||||
| `get_vm_tools_status` | Check Tools installation state |
|
|
||||||
|
|
||||||
## Serial Console
|
|
||||||
|
|
||||||
For network appliances and headless systems.
|
|
||||||
|
|
||||||
| Tool | Description |
|
|
||||||
|------|-------------|
|
|
||||||
| `setup_serial_port` | Configure serial port |
|
|
||||||
| `get_serial_port` | Get serial port configuration |
|
|
||||||
| `connect_serial_port` | Get connection URI (telnet/ssh) |
|
|
||||||
| `clear_serial_port` | Clear serial port buffer |
|
|
||||||
| `remove_serial_port` | Remove serial port |
|
|
||||||
|
|
||||||
## Disk Management
|
|
||||||
|
|
||||||
Virtual disk operations.
|
|
||||||
|
|
||||||
| Tool | Description |
|
|
||||||
|------|-------------|
|
|
||||||
| `add_disk` | Add new virtual disk |
|
|
||||||
| `remove_disk` | Remove virtual disk |
|
|
||||||
| `resize_disk` | Expand disk capacity |
|
|
||||||
| `list_disks` | List all disks |
|
|
||||||
| `get_disk_info` | Detailed disk information |
|
|
||||||
|
|
||||||
## Network Adapters
|
|
||||||
|
|
||||||
Virtual NIC management.
|
|
||||||
|
|
||||||
| Tool | Description |
|
|
||||||
|------|-------------|
|
|
||||||
| `add_nic` | Add network adapter |
|
|
||||||
| `remove_nic` | Remove network adapter |
|
|
||||||
| `connect_nic` | Connect/disconnect NIC |
|
|
||||||
| `change_nic_network` | Change port group |
|
|
||||||
| `list_nics` | List all NICs |
|
|
||||||
| `get_nic_info` | Detailed NIC information |
|
|
||||||
|
|
||||||
## OVF/OVA Operations
|
|
||||||
|
|
||||||
Deploy and export virtual appliances.
|
|
||||||
|
|
||||||
| Tool | Description |
|
|
||||||
|------|-------------|
|
|
||||||
| `deploy_ovf` | Deploy OVF/OVA package |
|
|
||||||
| `export_ovf` | Export VM as OVF |
|
|
||||||
| `list_ovf_networks` | List networks in OVF |
|
|
||||||
| `upload_to_datastore` | Upload file to datastore |
|
|
||||||
| `download_from_datastore` | Download file from datastore |
|
|
||||||
|
|
||||||
## Host Management
|
|
||||||
|
|
||||||
ESXi host operations.
|
|
||||||
|
|
||||||
| Tool | Description |
|
|
||||||
|------|-------------|
|
|
||||||
| `list_hosts` | List all ESXi hosts |
|
|
||||||
| `get_host_info` | Host configuration details |
|
|
||||||
| `get_host_hardware` | Hardware inventory |
|
|
||||||
| `get_host_networking` | Network configuration |
|
|
||||||
| `list_services` | Host services |
|
|
||||||
| `get_service_status` | Service state |
|
|
||||||
| `start_service` | Start a host service |
|
|
||||||
| `stop_service` | Stop a host service |
|
|
||||||
| `restart_service` | Restart a host service |
|
|
||||||
| `get_ntp_config` | NTP configuration |
|
|
||||||
|
|
||||||
## Datastore & Resources
|
|
||||||
|
|
||||||
Storage and resource inventory.
|
|
||||||
|
|
||||||
| Tool | Description |
|
|
||||||
|------|-------------|
|
|
||||||
| `get_datastore_info` | Datastore capacity and usage |
|
|
||||||
| `browse_datastore` | List datastore contents |
|
|
||||||
| `get_vcenter_info` | vCenter version and configuration |
|
|
||||||
| `get_resource_pool_info` | Resource pool settings |
|
|
||||||
| `get_network_info` | Network/port group details |
|
|
||||||
| `list_templates` | VM templates |
|
|
||||||
| `get_alarms` | Active alarms |
|
|
||||||
| `get_recent_events` | Recent vCenter events |
|
|
||||||
|
|
||||||
## vCenter Operations
|
|
||||||
|
|
||||||
Organization and cluster management.
|
|
||||||
|
|
||||||
| Tool | Description |
|
|
||||||
|------|-------------|
|
|
||||||
| `list_folders` | VM and host folders |
|
|
||||||
| `create_folder` | Create new folder |
|
|
||||||
| `delete_folder` | Remove folder |
|
|
||||||
| `move_vm_to_folder` | Organize VMs |
|
|
||||||
| `list_clusters` | Compute clusters |
|
|
||||||
| `get_cluster_info` | Cluster configuration |
|
|
||||||
| `list_resource_pools` | Resource pools |
|
|
||||||
| `create_resource_pool` | Create resource pool |
|
|
||||||
| `delete_resource_pool` | Remove resource pool |
|
|
||||||
| `move_vm_to_resource_pool` | Move VM to pool |
|
|
||||||
| `list_tags` | vCenter tags |
|
|
||||||
| `get_vm_tags` | Tags on a VM |
|
|
||||||
| `apply_tag_to_vm` | Apply tag |
|
|
||||||
| `remove_tag_from_vm` | Remove tag |
|
|
||||||
| `migrate_vm` | vMotion/Storage vMotion |
|
|
||||||
| `list_recent_tasks` | Recent vCenter tasks |
|
|
||||||
| `list_recent_events` | Recent events |
|
|
||||||
| `cancel_task` | Cancel running task |
|
|
||||||
|
|
||||||
## MCP Resources
|
|
||||||
|
|
||||||
In addition to tools, mcvsphere provides 6 real-time resources:
|
|
||||||
|
|
||||||
| Resource URI | Data |
|
|
||||||
|--------------|------|
|
|
||||||
| `esxi://vms` | All virtual machines |
|
|
||||||
| `esxi://hosts` | All ESXi hosts |
|
|
||||||
| `esxi://datastores` | All datastores |
|
|
||||||
| `esxi://networks` | All networks |
|
|
||||||
| `esxi://clusters` | All clusters |
|
|
||||||
| `esxi://resource-pools` | All resource pools |
|
|
||||||
|
|
||||||
Resources provide always-current inventory data that Claude can reference during conversations.
|
|
||||||
@ -1,74 +0,0 @@
|
|||||||
/* mcvsphere Documentation Custom Styles */
|
|
||||||
|
|
||||||
:root {
|
|
||||||
/* VMware-inspired color palette */
|
|
||||||
--sl-color-accent-low: #1a3a5c;
|
|
||||||
--sl-color-accent: #146eb4;
|
|
||||||
--sl-color-accent-high: #3d9cdb;
|
|
||||||
--sl-color-white: #ffffff;
|
|
||||||
--sl-color-gray-1: #eceef2;
|
|
||||||
--sl-color-gray-2: #c0c2c7;
|
|
||||||
--sl-color-gray-3: #888b96;
|
|
||||||
--sl-color-gray-4: #545861;
|
|
||||||
--sl-color-gray-5: #353841;
|
|
||||||
--sl-color-gray-6: #24272f;
|
|
||||||
--sl-color-black: #17181c;
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[data-theme='dark'] {
|
|
||||||
--sl-color-accent-low: #1a3a5c;
|
|
||||||
--sl-color-accent: #3d9cdb;
|
|
||||||
--sl-color-accent-high: #7fbfeb;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Code block styling */
|
|
||||||
.expressive-code {
|
|
||||||
margin: 1.5rem 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Hero section enhancement */
|
|
||||||
.hero {
|
|
||||||
--sl-color-hero-text: var(--sl-color-white);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Card styling for feature boxes */
|
|
||||||
.card {
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
background: var(--sl-color-gray-6);
|
|
||||||
padding: 1.5rem;
|
|
||||||
margin: 1rem 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Badge styling for permission levels */
|
|
||||||
.badge {
|
|
||||||
display: inline-block;
|
|
||||||
padding: 0.25rem 0.5rem;
|
|
||||||
border-radius: 0.25rem;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge-reader {
|
|
||||||
background: #e3f2fd;
|
|
||||||
color: #1565c0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge-operator {
|
|
||||||
background: #e8f5e9;
|
|
||||||
color: #2e7d32;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge-admin {
|
|
||||||
background: #fff3e0;
|
|
||||||
color: #ef6c00;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge-host-admin {
|
|
||||||
background: #fce4ec;
|
|
||||||
color: #c2185b;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge-super-admin {
|
|
||||||
background: #f3e5f5;
|
|
||||||
color: #7b1fa2;
|
|
||||||
}
|
|
||||||
@ -1,6 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "astro/tsconfigs/strict",
|
|
||||||
"compilerOptions": {
|
|
||||||
"strictNullChecks": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "mcvsphere"
|
name = "mcvsphere"
|
||||||
version = "0.2.3"
|
version = "0.2.1"
|
||||||
description = "Model Control for vSphere - AI-driven VMware virtual machine management via MCP"
|
description = "Model Control for vSphere - AI-driven VMware virtual machine management via MCP"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|||||||
@ -1,68 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# esxi-ssh.sh — on-demand ESXi SSH toggle via the vSphere API (govc + .env creds)
|
|
||||||
#
|
|
||||||
# The API account in .env (ESXI_USER, e.g. "claude") can toggle the TSM-SSH
|
|
||||||
# service but has no interactive host shell, so the `shell` subcommand logs in
|
|
||||||
# as root by default. Override the shell login with ESXI_SSH_USER.
|
|
||||||
#
|
|
||||||
# Usage:
|
|
||||||
# ./esxi-ssh.sh status # show current TSM-SSH state
|
|
||||||
# ./esxi-ssh.sh on # enable + start SSH (persists across reboot)
|
|
||||||
# ./esxi-ssh.sh off # stop + disable SSH (secure default)
|
|
||||||
# ./esxi-ssh.sh shell [cmd...] # start SSH, log in (root), restore prior state on exit
|
|
||||||
#
|
|
||||||
# Env overrides: ENV_FILE (default ../.env), ESXI_SSH_USER (default root)
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
ENV_FILE="${ENV_FILE:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/.env}"
|
|
||||||
[[ -r "$ENV_FILE" ]] || { echo "esxi-ssh: env file not readable: $ENV_FILE" >&2; exit 1; }
|
|
||||||
|
|
||||||
getenv() { grep -E "^$1=" "$ENV_FILE" | head -1 | cut -d= -f2-; }
|
|
||||||
|
|
||||||
HOST="$(getenv ESXI_HOST)"
|
|
||||||
export GOVC_URL="https://${HOST}"
|
|
||||||
export GOVC_USERNAME="$(getenv ESXI_USER)"
|
|
||||||
export GOVC_PASSWORD="$(getenv ESXI_PASS)"
|
|
||||||
export GOVC_INSECURE=1
|
|
||||||
|
|
||||||
SSH_USER="${ESXI_SSH_USER:-root}"
|
|
||||||
|
|
||||||
# Prints the TSM-SSH row's "policy status" (e.g. "off Running" / "on Stopped")
|
|
||||||
ssh_state() { govc host.service.ls 2>/dev/null | awk '/^TSM-SSH[[:space:]]/{print $2, $3}'; }
|
|
||||||
|
|
||||||
status() { govc host.service.ls | awk 'NR==1 || /^TSM-SSH[[:space:]]/'; }
|
|
||||||
|
|
||||||
on() {
|
|
||||||
govc host.service enable TSM-SSH >/dev/null 2>&1 || true
|
|
||||||
govc host.service start TSM-SSH >/dev/null 2>&1 || true
|
|
||||||
echo "esxi-ssh: SSH enabled on ${HOST}"
|
|
||||||
status
|
|
||||||
}
|
|
||||||
|
|
||||||
off() {
|
|
||||||
govc host.service stop TSM-SSH >/dev/null 2>&1 || true
|
|
||||||
govc host.service disable TSM-SSH >/dev/null 2>&1 || true
|
|
||||||
echo "esxi-ssh: SSH disabled on ${HOST}"
|
|
||||||
status
|
|
||||||
}
|
|
||||||
|
|
||||||
shell() {
|
|
||||||
read -r _ prev_status < <(ssh_state)
|
|
||||||
echo "esxi-ssh: prior TSM-SSH status=${prev_status:-unknown}"
|
|
||||||
govc host.service start TSM-SSH >/dev/null 2>&1 || true
|
|
||||||
# Leave it as we found it: only stop SSH on exit if it was NOT already running
|
|
||||||
if [[ "${prev_status:-}" != "Running" ]]; then
|
|
||||||
trap 'echo "esxi-ssh: restoring SSH to stopped"; govc host.service stop TSM-SSH >/dev/null 2>&1 || true' EXIT
|
|
||||||
fi
|
|
||||||
echo "esxi-ssh: connecting as ${SSH_USER}@${HOST} ..."
|
|
||||||
ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "${SSH_USER}@${HOST}" "$@"
|
|
||||||
}
|
|
||||||
|
|
||||||
case "${1:-status}" in
|
|
||||||
on) on ;;
|
|
||||||
off) off ;;
|
|
||||||
status) status ;;
|
|
||||||
shell) shift; shell "$@" ;;
|
|
||||||
*) echo "usage: $(basename "$0") {on|off|status|shell [cmd...]}" >&2; exit 1 ;;
|
|
||||||
esac
|
|
||||||
@ -61,26 +61,6 @@ class VMwareConnection:
|
|||||||
self._setup_datastore()
|
self._setup_datastore()
|
||||||
self._setup_network()
|
self._setup_network()
|
||||||
|
|
||||||
def ensure_connected(self) -> None:
|
|
||||||
"""Reconnect if the cached session has expired server-side.
|
|
||||||
|
|
||||||
ESXi expires idle SOAP sessions; the cached ``ServiceInstance`` then
|
|
||||||
raises ``NotAuthenticated`` on the next call. Probing ``currentSession``
|
|
||||||
is one cheap round-trip — None or a raised fault both mean "dead", so
|
|
||||||
we rebuild the connection (and re-resolve datacenter/datastore/etc).
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
if (
|
|
||||||
self.si is not None
|
|
||||||
and self.content is not None
|
|
||||||
and self.content.sessionManager.currentSession is not None
|
|
||||||
):
|
|
||||||
return # session is alive
|
|
||||||
except Exception:
|
|
||||||
pass # any fault here means the session is unusable
|
|
||||||
logger.info("vSphere session expired; reconnecting to %s", self.settings.vcenter_host)
|
|
||||||
self._connect()
|
|
||||||
|
|
||||||
def _setup_datacenter(self) -> None:
|
def _setup_datacenter(self) -> None:
|
||||||
"""Find and configure the target datacenter."""
|
"""Find and configure the target datacenter."""
|
||||||
datacenters = [
|
datacenters = [
|
||||||
|
|||||||
@ -1,76 +0,0 @@
|
|||||||
"""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)
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
# Cached connection may have a server-expired session; revive it.
|
|
||||||
self._connections[target].ensure_connected()
|
|
||||||
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()
|
|
||||||
@ -3,18 +3,12 @@
|
|||||||
Provides decorators and hooks for wrapping tool execution with:
|
Provides decorators and hooks for wrapping tool execution with:
|
||||||
- OAuth permission validation
|
- OAuth permission validation
|
||||||
- Audit logging with user identity
|
- Audit logging with user identity
|
||||||
- FastMCP middleware integration for RBAC
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
|
||||||
import time
|
import time
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import Any
|
||||||
|
|
||||||
import mcp.types as mt
|
|
||||||
from fastmcp.server.middleware import CallNext, Middleware, MiddlewareContext
|
|
||||||
from fastmcp.tools.tool import ToolResult
|
|
||||||
|
|
||||||
from mcvsphere.audit import (
|
from mcvsphere.audit import (
|
||||||
audit_log,
|
audit_log,
|
||||||
@ -30,11 +24,6 @@ from mcvsphere.permissions import (
|
|||||||
get_required_permission,
|
get_required_permission,
|
||||||
)
|
)
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from fastmcp.server.context import Context
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
def with_permission_check(tool_name: str) -> Callable:
|
def with_permission_check(tool_name: str) -> Callable:
|
||||||
"""Decorator factory for permission checking and audit logging.
|
"""Decorator factory for permission checking and audit logging.
|
||||||
@ -92,26 +81,24 @@ def with_permission_check(tool_name: str) -> Callable:
|
|||||||
def extract_user_from_context(ctx) -> dict[str, Any] | None:
|
def extract_user_from_context(ctx) -> dict[str, Any] | None:
|
||||||
"""Extract user information from FastMCP context.
|
"""Extract user information from FastMCP context.
|
||||||
|
|
||||||
Uses FastMCP's dependency injection to get the access token from
|
|
||||||
the current request's context variable.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
ctx: FastMCP Context object (unused, kept for API compatibility).
|
ctx: FastMCP Context object.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
User info dict from OAuth token claims, or None if not authenticated.
|
User info dict from OAuth token claims, or None if not authenticated.
|
||||||
"""
|
"""
|
||||||
try:
|
if ctx is None:
|
||||||
# FastMCP stores access token in a context variable, accessed via dependency
|
return None
|
||||||
from fastmcp.server.dependencies import get_access_token
|
|
||||||
|
|
||||||
access_token = get_access_token()
|
# Try to get access token from context
|
||||||
if access_token and hasattr(access_token, "claims"):
|
try:
|
||||||
return access_token.claims
|
# FastMCP stores the access token in request_context
|
||||||
except (RuntimeError, ImportError) as e:
|
if hasattr(ctx, "request_context") and ctx.request_context:
|
||||||
# RuntimeError: No active HTTP request context
|
token = getattr(ctx.request_context, "access_token", None)
|
||||||
# ImportError: FastMCP auth dependencies not available
|
if token and hasattr(token, "claims"):
|
||||||
logger.debug("Could not get access token: %s", e)
|
return token.claims
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@ -192,154 +179,3 @@ def get_permission_summary() -> dict[str, list[str]]:
|
|||||||
summary[level].sort()
|
summary[level].sort()
|
||||||
|
|
||||||
return summary
|
return summary
|
||||||
|
|
||||||
|
|
||||||
class RBACMiddleware(Middleware):
|
|
||||||
"""FastMCP middleware for Role-Based Access Control.
|
|
||||||
|
|
||||||
Integrates with FastMCP's middleware system to enforce permissions
|
|
||||||
on every tool call based on OAuth group memberships.
|
|
||||||
|
|
||||||
Example:
|
|
||||||
mcp = FastMCP("my-server", auth=oauth_provider)
|
|
||||||
mcp.add_middleware(RBACMiddleware())
|
|
||||||
"""
|
|
||||||
|
|
||||||
def _extract_user_from_context(
|
|
||||||
self, fastmcp_ctx: "Context | None"
|
|
||||||
) -> dict[str, Any] | None:
|
|
||||||
"""Extract user claims from FastMCP context.
|
|
||||||
|
|
||||||
Uses FastMCP's dependency injection to retrieve the access token
|
|
||||||
from the current request's context variable.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
fastmcp_ctx: FastMCP Context object (unused, kept for API compatibility).
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
User claims dict from OAuth token, or None if not authenticated.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
# FastMCP stores access token in a context variable, accessed via dependency
|
|
||||||
from fastmcp.server.dependencies import get_access_token
|
|
||||||
|
|
||||||
access_token = get_access_token()
|
|
||||||
if access_token and hasattr(access_token, "claims"):
|
|
||||||
return access_token.claims
|
|
||||||
except (RuntimeError, ImportError) as e:
|
|
||||||
# RuntimeError: No active HTTP request context
|
|
||||||
# ImportError: FastMCP auth dependencies not available
|
|
||||||
logger.debug("Could not get access token: %s", e)
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _get_username(self, claims: dict[str, Any] | None) -> str:
|
|
||||||
"""Extract username from OAuth claims.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
claims: OAuth token claims dict.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Username string, or 'anonymous' if no claims.
|
|
||||||
"""
|
|
||||||
if not claims:
|
|
||||||
return "anonymous"
|
|
||||||
|
|
||||||
for claim in ("preferred_username", "email", "sub"):
|
|
||||||
if value := claims.get(claim):
|
|
||||||
return str(value)
|
|
||||||
|
|
||||||
return "unknown"
|
|
||||||
|
|
||||||
def _get_groups(self, claims: dict[str, Any] | None) -> list[str]:
|
|
||||||
"""Extract groups from OAuth claims.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
claims: OAuth token claims dict.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of group names, or empty list if no claims.
|
|
||||||
"""
|
|
||||||
if not claims:
|
|
||||||
return []
|
|
||||||
|
|
||||||
groups = claims.get("groups", [])
|
|
||||||
if isinstance(groups, list):
|
|
||||||
return groups
|
|
||||||
return []
|
|
||||||
|
|
||||||
async def on_call_tool(
|
|
||||||
self,
|
|
||||||
context: MiddlewareContext[mt.CallToolRequestParams],
|
|
||||||
call_next: CallNext[mt.CallToolRequestParams, ToolResult],
|
|
||||||
) -> ToolResult:
|
|
||||||
"""Intercept tool calls to enforce RBAC permissions.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
context: Middleware context containing tool call params.
|
|
||||||
call_next: Next handler in the middleware chain.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Tool result if permitted.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
PermissionDeniedError: If user lacks required permission.
|
|
||||||
"""
|
|
||||||
# Extract tool name and arguments from the request
|
|
||||||
tool_name = context.message.name
|
|
||||||
tool_args = context.message.arguments or {}
|
|
||||||
|
|
||||||
# Get user info from OAuth context
|
|
||||||
claims = self._extract_user_from_context(context.fastmcp_context)
|
|
||||||
username = self._get_username(claims)
|
|
||||||
groups = self._get_groups(claims)
|
|
||||||
|
|
||||||
# Set up audit context for this request
|
|
||||||
set_current_user(claims)
|
|
||||||
|
|
||||||
# Check permission
|
|
||||||
if not check_permission(tool_name, groups):
|
|
||||||
required = get_required_permission(tool_name)
|
|
||||||
logger.warning(
|
|
||||||
"Permission denied: user=%s groups=%s tool=%s required=%s",
|
|
||||||
username,
|
|
||||||
groups,
|
|
||||||
tool_name,
|
|
||||||
required.value,
|
|
||||||
)
|
|
||||||
audit_permission_denied(tool_name, tool_args, required.value)
|
|
||||||
raise PermissionDeniedError(username, tool_name, required)
|
|
||||||
|
|
||||||
# Permission granted - execute tool with timing
|
|
||||||
start_time = time.perf_counter()
|
|
||||||
try:
|
|
||||||
result = await call_next(context)
|
|
||||||
duration_ms = (time.perf_counter() - start_time) * 1000
|
|
||||||
|
|
||||||
# Audit successful execution
|
|
||||||
# ToolResult can be complex, just log that it succeeded
|
|
||||||
audit_log(tool_name, tool_args, result="success", duration_ms=duration_ms)
|
|
||||||
|
|
||||||
logger.debug(
|
|
||||||
"Tool executed: user=%s tool=%s duration=%.2fms",
|
|
||||||
username,
|
|
||||||
tool_name,
|
|
||||||
duration_ms,
|
|
||||||
)
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
except PermissionDeniedError:
|
|
||||||
# Re-raise without additional logging
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
duration_ms = (time.perf_counter() - start_time) * 1000
|
|
||||||
audit_log(tool_name, tool_args, error=str(e), duration_ms=duration_ms)
|
|
||||||
logger.error(
|
|
||||||
"Tool failed: user=%s tool=%s error=%s duration=%.2fms",
|
|
||||||
username,
|
|
||||||
tool_name,
|
|
||||||
str(e),
|
|
||||||
duration_ms,
|
|
||||||
)
|
|
||||||
raise
|
|
||||||
|
|||||||
@ -1,31 +0,0 @@
|
|||||||
"""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,27 +6,27 @@ 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 mcp_tool
|
from fastmcp.contrib.mcp_mixin import MCPMixin, 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:
|
||||||
pass
|
from mcvsphere.connection import VMwareConnection
|
||||||
|
|
||||||
|
|
||||||
class ConsoleMixin(VSphereMixin):
|
class ConsoleMixin(MCPMixin):
|
||||||
"""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.",
|
||||||
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.
|
||||||
|
|
||||||
@ -34,13 +34,11 @@ 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
|
||||||
"""
|
"""
|
||||||
conn = self._conn(host)
|
vm = self.conn.find_vm(name)
|
||||||
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")
|
||||||
|
|
||||||
@ -79,18 +77,16 @@ 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, host: str | None = None) -> dict[str, Any]:
|
def get_vm_tools_status(self, name: str) -> 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
|
||||||
"""
|
"""
|
||||||
conn = self._conn(host)
|
vm = self.conn.find_vm(name)
|
||||||
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")
|
||||||
|
|
||||||
@ -120,7 +116,6 @@ 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.
|
||||||
|
|
||||||
@ -128,21 +123,19 @@ 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
|
||||||
"""
|
"""
|
||||||
conn = self._conn(host)
|
vm = self.conn.find_vm(name)
|
||||||
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}
|
||||||
vcenter_host = conn.settings.vcenter_host
|
host = self.conn.settings.vcenter_host
|
||||||
moid = vm._moId
|
moid = vm._moId
|
||||||
screenshot_url = f"https://{vcenter_host}/screen?id={moid}"
|
screenshot_url = f"https://{host}/screen?id={moid}"
|
||||||
|
|
||||||
# Add optional scaling parameters
|
# Add optional scaling parameters
|
||||||
params = []
|
params = []
|
||||||
@ -154,8 +147,8 @@ class ConsoleMixin(VSphereMixin):
|
|||||||
screenshot_url += "&" + "&".join(params)
|
screenshot_url += "&" + "&".join(params)
|
||||||
|
|
||||||
# Build auth header
|
# Build auth header
|
||||||
username = conn.settings.vcenter_user
|
username = self.conn.settings.vcenter_user
|
||||||
password = conn.settings.vcenter_password.get_secret_value()
|
password = self.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
|
||||||
@ -163,7 +156,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 conn.settings.vcenter_insecure,
|
verify=not self.conn.settings.vcenter_insecure,
|
||||||
timeout=30,
|
timeout=30,
|
||||||
)
|
)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
|
|||||||
@ -2,19 +2,20 @@
|
|||||||
|
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
from fastmcp.contrib.mcp_mixin import mcp_tool
|
from fastmcp.contrib.mcp_mixin import MCPMixin, 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:
|
||||||
pass
|
from mcvsphere.connection import VMwareConnection
|
||||||
|
|
||||||
|
|
||||||
class DiskManagementMixin(VSphereMixin):
|
class DiskManagementMixin(MCPMixin):
|
||||||
"""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 = []
|
||||||
@ -64,104 +65,6 @@ class DiskManagementMixin(VSphereMixin):
|
|||||||
return device
|
return device
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _find_free_ide_slot(
|
|
||||||
self, vm: vim.VirtualMachine
|
|
||||||
) -> tuple[int | None, int | None]:
|
|
||||||
"""Return (controllerKey, unitNumber) for a free IDE slot, or (None, None)."""
|
|
||||||
ide_controllers = [
|
|
||||||
d
|
|
||||||
for d in vm.config.hardware.device
|
|
||||||
if isinstance(d, vim.vm.device.VirtualIDEController)
|
|
||||||
]
|
|
||||||
used: dict[int, set[int]] = {}
|
|
||||||
for d in vm.config.hardware.device:
|
|
||||||
if hasattr(d, "controllerKey") and hasattr(d, "unitNumber"):
|
|
||||||
used.setdefault(d.controllerKey, set()).add(d.unitNumber)
|
|
||||||
for controller in ide_controllers:
|
|
||||||
for unit in (0, 1): # each IDE controller holds two devices
|
|
||||||
if unit not in used.get(controller.key, set()):
|
|
||||||
return controller.key, unit
|
|
||||||
return None, None
|
|
||||||
|
|
||||||
@mcp_tool(
|
|
||||||
name="add_cdrom",
|
|
||||||
description="Add a CD/DVD drive to a VM, optionally mounting an ISO and booting from it",
|
|
||||||
annotations=ToolAnnotations(destructiveHint=True),
|
|
||||||
)
|
|
||||||
def add_cdrom(
|
|
||||||
self,
|
|
||||||
vm_name: str,
|
|
||||||
iso_path: str | None = None,
|
|
||||||
iso_datastore: str | None = None,
|
|
||||||
boot_from_iso: bool = False,
|
|
||||||
host: str | None = None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""Add a CD/DVD drive to an existing VM.
|
|
||||||
|
|
||||||
Useful for appliances deployed from an OVA (e.g. Cisco CUCM), which
|
|
||||||
ship without a CD/DVD drive and must install from a bootable ISO.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
vm_name: Name of the virtual machine
|
|
||||||
iso_path: ISO path on a datastore (e.g. 'iso/installer.iso') to mount
|
|
||||||
iso_datastore: Datastore holding the ISO (default: the VM's datastore)
|
|
||||||
boot_from_iso: Put the CD/DVD first in the boot order (for installers)
|
|
||||||
host: Managed ESXi host to target (default: the default host)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dict with the CD/DVD drive details
|
|
||||||
"""
|
|
||||||
conn = self._conn(host)
|
|
||||||
vm = conn.find_vm(vm_name)
|
|
||||||
if not vm:
|
|
||||||
raise ValueError(f"VM '{vm_name}' not found")
|
|
||||||
|
|
||||||
controller_key, unit = self._find_free_ide_slot(vm)
|
|
||||||
if controller_key is None:
|
|
||||||
raise ValueError("No free IDE slot available for a CD/DVD drive")
|
|
||||||
|
|
||||||
cdrom_spec = vim.vm.device.VirtualDeviceSpec()
|
|
||||||
cdrom_spec.operation = vim.vm.device.VirtualDeviceSpec.Operation.add
|
|
||||||
cdrom_spec.device = vim.vm.device.VirtualCdrom()
|
|
||||||
cdrom_spec.device.controllerKey = controller_key
|
|
||||||
cdrom_spec.device.unitNumber = unit
|
|
||||||
cdrom_spec.device.key = -1
|
|
||||||
connectable = vim.vm.device.VirtualDevice.ConnectInfo()
|
|
||||||
connectable.allowGuestControl = True
|
|
||||||
connectable.connected = False
|
|
||||||
|
|
||||||
mounted_iso = None
|
|
||||||
if iso_path:
|
|
||||||
ds_name = iso_datastore or vm.config.files.vmPathName.split("]")[0].strip("[ ")
|
|
||||||
mounted_iso = f"[{ds_name}] {iso_path}"
|
|
||||||
backing = vim.vm.device.VirtualCdrom.IsoBackingInfo()
|
|
||||||
backing.fileName = mounted_iso
|
|
||||||
connectable.startConnected = True
|
|
||||||
else:
|
|
||||||
backing = vim.vm.device.VirtualCdrom.RemotePassthroughBackingInfo()
|
|
||||||
backing.deviceName = ""
|
|
||||||
backing.exclusive = False
|
|
||||||
connectable.startConnected = False
|
|
||||||
|
|
||||||
cdrom_spec.device.backing = backing
|
|
||||||
cdrom_spec.device.connectable = connectable
|
|
||||||
|
|
||||||
config_spec = vim.vm.ConfigSpec(deviceChange=[cdrom_spec])
|
|
||||||
if iso_path and boot_from_iso:
|
|
||||||
config_spec.bootOptions = vim.vm.BootOptions(
|
|
||||||
bootOrder=[vim.vm.BootOptions.BootableCdromDevice()]
|
|
||||||
)
|
|
||||||
|
|
||||||
task = vm.ReconfigVM_Task(spec=config_spec)
|
|
||||||
conn.wait_for_task(task)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"vm": vm_name,
|
|
||||||
"action": "cdrom_added",
|
|
||||||
"iso": mounted_iso,
|
|
||||||
"boot_from_iso": bool(iso_path and boot_from_iso),
|
|
||||||
}
|
|
||||||
|
|
||||||
@mcp_tool(
|
@mcp_tool(
|
||||||
name="add_disk",
|
name="add_disk",
|
||||||
description="Add a new virtual disk to a VM",
|
description="Add a new virtual disk to a VM",
|
||||||
@ -173,7 +76,6 @@ 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.
|
||||||
|
|
||||||
@ -182,13 +84,11 @@ 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
|
||||||
"""
|
"""
|
||||||
conn = self._conn(host)
|
vm = self.conn.find_vm(vm_name)
|
||||||
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")
|
||||||
|
|
||||||
@ -197,7 +97,7 @@ class DiskManagementMixin(VSphereMixin):
|
|||||||
|
|
||||||
# Determine datastore
|
# Determine datastore
|
||||||
if datastore:
|
if datastore:
|
||||||
ds = conn.find_datastore(datastore)
|
ds = self.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
|
||||||
@ -212,7 +112,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 = conn.find_datastore(ds_name)
|
backing.datastore = self.conn.find_datastore(ds_name)
|
||||||
|
|
||||||
# Create the virtual disk
|
# Create the virtual disk
|
||||||
disk = vim.vm.device.VirtualDisk()
|
disk = vim.vm.device.VirtualDisk()
|
||||||
@ -233,7 +133,7 @@ class DiskManagementMixin(VSphereMixin):
|
|||||||
|
|
||||||
# Reconfigure VM
|
# Reconfigure VM
|
||||||
task = vm.ReconfigVM_Task(spec=config_spec)
|
task = vm.ReconfigVM_Task(spec=config_spec)
|
||||||
conn.wait_for_task(task)
|
self.conn.wait_for_task(task)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"vm": vm_name,
|
"vm": vm_name,
|
||||||
@ -255,7 +155,6 @@ 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.
|
||||||
|
|
||||||
@ -263,13 +162,11 @@ 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
|
||||||
"""
|
"""
|
||||||
conn = self._conn(host)
|
vm = self.conn.find_vm(vm_name)
|
||||||
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")
|
||||||
|
|
||||||
@ -300,7 +197,7 @@ class DiskManagementMixin(VSphereMixin):
|
|||||||
|
|
||||||
# Reconfigure VM
|
# Reconfigure VM
|
||||||
task = vm.ReconfigVM_Task(spec=config_spec)
|
task = vm.ReconfigVM_Task(spec=config_spec)
|
||||||
conn.wait_for_task(task)
|
self.conn.wait_for_task(task)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"vm": vm_name,
|
"vm": vm_name,
|
||||||
@ -321,7 +218,6 @@ 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.
|
||||||
|
|
||||||
@ -329,13 +225,11 @@ 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
|
||||||
"""
|
"""
|
||||||
conn = self._conn(host)
|
vm = self.conn.find_vm(vm_name)
|
||||||
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")
|
||||||
|
|
||||||
@ -369,7 +263,7 @@ class DiskManagementMixin(VSphereMixin):
|
|||||||
|
|
||||||
# Reconfigure VM
|
# Reconfigure VM
|
||||||
task = vm.ReconfigVM_Task(spec=config_spec)
|
task = vm.ReconfigVM_Task(spec=config_spec)
|
||||||
conn.wait_for_task(task)
|
self.conn.wait_for_task(task)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"vm": vm_name,
|
"vm": vm_name,
|
||||||
@ -384,18 +278,16 @@ 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, host: str | None = None) -> list[dict[str, Any]]:
|
def list_disks(self, vm_name: str) -> 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
|
||||||
"""
|
"""
|
||||||
conn = self._conn(host)
|
vm = self.conn.find_vm(vm_name)
|
||||||
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")
|
||||||
|
|
||||||
@ -430,8 +322,6 @@ class DiskManagementMixin(VSphereMixin):
|
|||||||
vm_name: str,
|
vm_name: str,
|
||||||
iso_path: str,
|
iso_path: str,
|
||||||
datastore: str | None = None,
|
datastore: str | None = None,
|
||||||
cdrom_index: int = 0,
|
|
||||||
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.
|
||||||
|
|
||||||
@ -439,32 +329,17 @@ 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)
|
||||||
cdrom_index: Which CD/DVD drive to use, 0-based (default 0 = first).
|
|
||||||
Use 1 for a second drive, e.g. a Cisco answer-file ISO alongside
|
|
||||||
the bootable installer on drive 0.
|
|
||||||
host: Managed ESXi host to target (default: the default host)
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict with attachment details
|
Dict with attachment details
|
||||||
"""
|
"""
|
||||||
conn = self._conn(host)
|
vm = self.conn.find_vm(vm_name)
|
||||||
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")
|
||||||
|
|
||||||
cdroms = [
|
cdrom = self._find_cdrom(vm)
|
||||||
d
|
if not cdrom:
|
||||||
for d in vm.config.hardware.device
|
|
||||||
if isinstance(d, vim.vm.device.VirtualCdrom)
|
|
||||||
]
|
|
||||||
if not cdroms:
|
|
||||||
raise ValueError(f"No CD/DVD drive found on VM '{vm_name}'")
|
raise ValueError(f"No CD/DVD drive found on VM '{vm_name}'")
|
||||||
if cdrom_index >= len(cdroms):
|
|
||||||
raise ValueError(
|
|
||||||
f"VM '{vm_name}' has {len(cdroms)} CD/DVD drive(s); "
|
|
||||||
f"cdrom_index={cdrom_index} is out of range"
|
|
||||||
)
|
|
||||||
cdrom = cdroms[cdrom_index]
|
|
||||||
|
|
||||||
# Determine datastore
|
# Determine datastore
|
||||||
if not datastore:
|
if not datastore:
|
||||||
@ -495,7 +370,7 @@ class DiskManagementMixin(VSphereMixin):
|
|||||||
|
|
||||||
# Reconfigure VM
|
# Reconfigure VM
|
||||||
task = vm.ReconfigVM_Task(spec=config_spec)
|
task = vm.ReconfigVM_Task(spec=config_spec)
|
||||||
conn.wait_for_task(task)
|
self.conn.wait_for_task(task)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"vm": vm_name,
|
"vm": vm_name,
|
||||||
@ -510,71 +385,55 @@ 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, host: str | None = None) -> dict[str, Any]:
|
def detach_iso(self, vm_name: str) -> 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
|
||||||
"""
|
"""
|
||||||
conn = self._conn(host)
|
vm = self.conn.find_vm(vm_name)
|
||||||
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")
|
||||||
|
|
||||||
cdroms = [
|
cdrom = self._find_cdrom(vm)
|
||||||
d
|
if not cdrom:
|
||||||
for d in vm.config.hardware.device
|
|
||||||
if isinstance(d, vim.vm.device.VirtualCdrom)
|
|
||||||
]
|
|
||||||
if not cdroms:
|
|
||||||
raise ValueError(f"No CD/DVD drive found on VM '{vm_name}'")
|
raise ValueError(f"No CD/DVD drive found on VM '{vm_name}'")
|
||||||
|
|
||||||
# Eject every drive that actually has an ISO mounted (a VM may have
|
# Get current ISO path for reporting
|
||||||
# several CD/DVD drives; the ISO is rarely on the first one).
|
old_iso = None
|
||||||
iso_cdroms = [
|
if hasattr(cdrom.backing, "fileName"):
|
||||||
c
|
old_iso = cdrom.backing.fileName
|
||||||
for c in cdroms
|
|
||||||
if isinstance(c.backing, vim.vm.device.VirtualCdrom.IsoBackingInfo)
|
|
||||||
]
|
|
||||||
if not iso_cdroms:
|
|
||||||
return {
|
|
||||||
"vm": vm_name,
|
|
||||||
"action": "no_iso_mounted",
|
|
||||||
"previous_iso": None,
|
|
||||||
"ejected": [],
|
|
||||||
}
|
|
||||||
|
|
||||||
device_changes = []
|
# Create empty client device backing (ejects the ISO)
|
||||||
ejected = []
|
|
||||||
for cdrom in iso_cdroms:
|
|
||||||
ejected.append(
|
|
||||||
{"cdrom": cdrom.deviceInfo.label, "previous_iso": cdrom.backing.fileName}
|
|
||||||
)
|
|
||||||
backing = vim.vm.device.VirtualCdrom.RemotePassthroughBackingInfo()
|
backing = vim.vm.device.VirtualCdrom.RemotePassthroughBackingInfo()
|
||||||
backing.deviceName = ""
|
backing.deviceName = ""
|
||||||
backing.exclusive = False
|
|
||||||
|
# Configure CD-ROM
|
||||||
cdrom.backing = backing
|
cdrom.backing = backing
|
||||||
cdrom.connectable = vim.vm.device.VirtualDevice.ConnectInfo()
|
cdrom.connectable = vim.vm.device.VirtualDevice.ConnectInfo()
|
||||||
cdrom.connectable.connected = False
|
cdrom.connectable.connected = False
|
||||||
cdrom.connectable.startConnected = False
|
cdrom.connectable.startConnected = False
|
||||||
cdrom.connectable.allowGuestControl = True
|
cdrom.connectable.allowGuestControl = True
|
||||||
|
|
||||||
spec = vim.vm.device.VirtualDeviceSpec()
|
# Create device edit spec
|
||||||
spec.operation = vim.vm.device.VirtualDeviceSpec.Operation.edit
|
cdrom_spec = vim.vm.device.VirtualDeviceSpec()
|
||||||
spec.device = cdrom
|
cdrom_spec.operation = vim.vm.device.VirtualDeviceSpec.Operation.edit
|
||||||
device_changes.append(spec)
|
cdrom_spec.device = cdrom
|
||||||
|
|
||||||
config_spec = vim.vm.ConfigSpec(deviceChange=device_changes)
|
# Create VM config spec
|
||||||
|
config_spec = vim.vm.ConfigSpec()
|
||||||
|
config_spec.deviceChange = [cdrom_spec]
|
||||||
|
|
||||||
|
# Reconfigure VM
|
||||||
task = vm.ReconfigVM_Task(spec=config_spec)
|
task = vm.ReconfigVM_Task(spec=config_spec)
|
||||||
conn.wait_for_task(task)
|
self.conn.wait_for_task(task)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"vm": vm_name,
|
"vm": vm_name,
|
||||||
"action": "iso_detached",
|
"action": "iso_detached",
|
||||||
"previous_iso": ejected[0]["previous_iso"],
|
"previous_iso": old_iso,
|
||||||
"ejected": ejected,
|
"cdrom": cdrom.deviceInfo.label,
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,19 +4,20 @@ import base64
|
|||||||
import time
|
import time
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
from fastmcp.contrib.mcp_mixin import mcp_tool
|
from fastmcp.contrib.mcp_mixin import MCPMixin, 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:
|
||||||
pass
|
from mcvsphere.connection import VMwareConnection
|
||||||
|
|
||||||
|
|
||||||
class GuestOpsMixin(VSphereMixin):
|
class GuestOpsMixin(MCPMixin):
|
||||||
"""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:
|
||||||
@ -53,7 +54,6 @@ 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.
|
||||||
|
|
||||||
@ -66,16 +66,14 @@ 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)
|
|
||||||
"""
|
"""
|
||||||
conn = self._conn(host)
|
vm = self.conn.find_vm(name)
|
||||||
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 = conn.content.guestOperationsManager
|
guest_ops = self.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)
|
||||||
|
|
||||||
@ -121,17 +119,16 @@ 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, host: str | None = None
|
self, name: str, username: str, password: str
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
"""List processes running in the guest OS."""
|
"""List processes running in the guest OS."""
|
||||||
conn = self._conn(host)
|
vm = self.conn.find_vm(name)
|
||||||
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 = conn.content.guestOperationsManager
|
guest_ops = self.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)
|
||||||
|
|
||||||
@ -154,8 +151,7 @@ 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.
|
||||||
|
|
||||||
@ -164,16 +160,14 @@ 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)
|
|
||||||
"""
|
"""
|
||||||
conn = self._conn(host)
|
vm = self.conn.find_vm(name)
|
||||||
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 = conn.content.guestOperationsManager
|
guest_ops = self.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)
|
||||||
|
|
||||||
@ -234,7 +228,6 @@ 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.
|
||||||
|
|
||||||
@ -245,16 +238,14 @@ 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)
|
|
||||||
"""
|
"""
|
||||||
conn = self._conn(host)
|
vm = self.conn.find_vm(name)
|
||||||
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 = conn.content.guestOperationsManager
|
guest_ops = self.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)
|
||||||
|
|
||||||
@ -297,18 +288,16 @@ 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."""
|
||||||
conn = self._conn(host)
|
vm = self.conn.find_vm(name)
|
||||||
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 = conn.content.guestOperationsManager
|
guest_ops = self.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)
|
||||||
|
|
||||||
@ -343,17 +332,15 @@ 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."""
|
||||||
conn = self._conn(host)
|
vm = self.conn.find_vm(name)
|
||||||
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 = conn.content.guestOperationsManager
|
guest_ops = self.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)
|
||||||
|
|
||||||
@ -369,18 +356,16 @@ 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."""
|
||||||
conn = self._conn(host)
|
vm = self.conn.find_vm(name)
|
||||||
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 = conn.content.guestOperationsManager
|
guest_ops = self.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)
|
||||||
|
|
||||||
|
|||||||
@ -2,22 +2,23 @@
|
|||||||
|
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
from fastmcp.contrib.mcp_mixin import mcp_tool
|
from fastmcp.contrib.mcp_mixin import MCPMixin, 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:
|
||||||
pass
|
from mcvsphere.connection import VMwareConnection
|
||||||
|
|
||||||
|
|
||||||
class HostManagementMixin(VSphereMixin):
|
class HostManagementMixin(MCPMixin):
|
||||||
"""ESXi host management tools."""
|
"""ESXi host management tools."""
|
||||||
|
|
||||||
def _get_host(self, conn) -> vim.HostSystem:
|
def __init__(self, conn: "VMwareConnection"):
|
||||||
|
self.conn = conn
|
||||||
|
|
||||||
|
def _get_host(self) -> vim.HostSystem:
|
||||||
"""Get the ESXi host system."""
|
"""Get the ESXi host system."""
|
||||||
for entity in conn.datacenter.hostFolder.childEntity:
|
for entity in self.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,17 +31,13 @@ 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, host: str | None = None) -> dict[str, Any]:
|
def get_host_info(self) -> 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
|
||||||
"""
|
"""
|
||||||
conn = self._conn(host)
|
host = self._get_host()
|
||||||
host = self._get_host(conn)
|
|
||||||
summary = host.summary
|
summary = host.summary
|
||||||
hardware = summary.hardware
|
hardware = summary.hardware
|
||||||
config = summary.config
|
config = summary.config
|
||||||
@ -84,20 +81,17 @@ 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
|
||||||
"""
|
"""
|
||||||
conn = self._conn(host)
|
host = self._get_host()
|
||||||
host = self._get_host(conn)
|
|
||||||
|
|
||||||
if host.runtime.inMaintenanceMode:
|
if host.runtime.inMaintenanceMode:
|
||||||
return {
|
return {
|
||||||
@ -111,7 +105,7 @@ class HostManagementMixin(VSphereMixin):
|
|||||||
timeout=timeout_seconds,
|
timeout=timeout_seconds,
|
||||||
evacuatePoweredOffVms=evacuate_vms,
|
evacuatePoweredOffVms=evacuate_vms,
|
||||||
)
|
)
|
||||||
conn.wait_for_task(task)
|
self.conn.wait_for_task(task)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"host": host.name,
|
"host": host.name,
|
||||||
@ -128,19 +122,16 @@ 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
|
||||||
"""
|
"""
|
||||||
conn = self._conn(host)
|
host = self._get_host()
|
||||||
host = self._get_host(conn)
|
|
||||||
|
|
||||||
if not host.runtime.inMaintenanceMode:
|
if not host.runtime.inMaintenanceMode:
|
||||||
return {
|
return {
|
||||||
@ -150,7 +141,7 @@ class HostManagementMixin(VSphereMixin):
|
|||||||
}
|
}
|
||||||
|
|
||||||
task = host.ExitMaintenanceMode_Task(timeout=timeout_seconds)
|
task = host.ExitMaintenanceMode_Task(timeout=timeout_seconds)
|
||||||
conn.wait_for_task(task)
|
self.conn.wait_for_task(task)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"host": host.name,
|
"host": host.name,
|
||||||
@ -163,17 +154,13 @@ 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, host: str | None = None) -> list[dict[str, Any]]:
|
def list_services(self) -> 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
|
||||||
"""
|
"""
|
||||||
conn = self._conn(host)
|
host = self._get_host()
|
||||||
host = self._get_host(conn)
|
|
||||||
service_system = host.configManager.serviceSystem
|
service_system = host.configManager.serviceSystem
|
||||||
|
|
||||||
services = []
|
services = []
|
||||||
@ -194,18 +181,16 @@ 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, host: str | None = None) -> dict[str, Any]:
|
def start_service(self, service_key: str) -> 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
|
||||||
"""
|
"""
|
||||||
conn = self._conn(host)
|
host = self._get_host()
|
||||||
host = self._get_host(conn)
|
|
||||||
service_system = host.configManager.serviceSystem
|
service_system = host.configManager.serviceSystem
|
||||||
|
|
||||||
# Verify service exists
|
# Verify service exists
|
||||||
@ -241,18 +226,16 @@ 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, host: str | None = None) -> dict[str, Any]:
|
def stop_service(self, service_key: str) -> 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
|
||||||
"""
|
"""
|
||||||
conn = self._conn(host)
|
host = self._get_host()
|
||||||
host = self._get_host(conn)
|
|
||||||
service_system = host.configManager.serviceSystem
|
service_system = host.configManager.serviceSystem
|
||||||
|
|
||||||
# Verify service exists
|
# Verify service exists
|
||||||
@ -292,20 +275,17 @@ 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
|
||||||
"""
|
"""
|
||||||
conn = self._conn(host)
|
host = self._get_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"]
|
||||||
@ -339,17 +319,13 @@ 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, host: str | None = None) -> dict[str, Any]:
|
def get_ntp_config(self) -> 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
|
||||||
"""
|
"""
|
||||||
conn = self._conn(host)
|
host = self._get_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
|
||||||
@ -382,20 +358,17 @@ 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
|
||||||
"""
|
"""
|
||||||
conn = self._conn(host)
|
host = self._get_host()
|
||||||
host = self._get_host(conn)
|
|
||||||
datetime_system = host.configManager.dateTimeSystem
|
datetime_system = host.configManager.dateTimeSystem
|
||||||
|
|
||||||
# Create NTP config
|
# Create NTP config
|
||||||
@ -434,18 +407,16 @@ 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, host: str | None = None) -> dict[str, Any]:
|
def reboot_host(self, force: bool = False) -> 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
|
||||||
"""
|
"""
|
||||||
conn = self._conn(host)
|
host = self._get_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(
|
||||||
@ -468,18 +439,16 @@ 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, host: str | None = None) -> dict[str, Any]:
|
def shutdown_host(self, force: bool = False) -> 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
|
||||||
"""
|
"""
|
||||||
conn = self._conn(host)
|
host = self._get_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(
|
||||||
@ -502,17 +471,13 @@ 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, host: str | None = None) -> dict[str, Any]:
|
def get_host_hardware(self) -> 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
|
||||||
"""
|
"""
|
||||||
conn = self._conn(host)
|
host = self._get_host()
|
||||||
host = self._get_host(conn)
|
|
||||||
hardware = host.hardware
|
hardware = host.hardware
|
||||||
|
|
||||||
# CPU info
|
# CPU info
|
||||||
@ -568,17 +533,13 @@ 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, host: str | None = None) -> dict[str, Any]:
|
def get_host_networking(self) -> 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
|
||||||
"""
|
"""
|
||||||
conn = self._conn(host)
|
host = self._get_host()
|
||||||
host = self._get_host(conn)
|
|
||||||
network_config = host.config.network
|
network_config = host.config.network
|
||||||
|
|
||||||
# Virtual switches
|
# Virtual switches
|
||||||
|
|||||||
@ -3,32 +3,28 @@
|
|||||||
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 mcp_tool
|
from fastmcp.contrib.mcp_mixin import MCPMixin, 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:
|
||||||
pass
|
from mcvsphere.connection import VMwareConnection
|
||||||
|
|
||||||
|
|
||||||
class MonitoringMixin(VSphereMixin):
|
class MonitoringMixin(MCPMixin):
|
||||||
"""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",
|
||||||
annotations=ToolAnnotations(readOnlyHint=True),
|
annotations=ToolAnnotations(readOnlyHint=True),
|
||||||
)
|
)
|
||||||
def get_vm_stats(self, name: str, host: str | None = None) -> dict[str, Any]:
|
def get_vm_stats(self, name: str) -> 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")
|
||||||
|
|
||||||
@ -63,25 +59,19 @@ 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(
|
def get_host_stats(self, host_name: str | None = None) -> dict[str, Any]:
|
||||||
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 = conn.find_host(host_name)
|
host = self.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 = conn.content.viewManager.CreateContainerView(
|
container = self.conn.content.viewManager.CreateContainerView(
|
||||||
conn.content.rootFolder, [vim.HostSystem], True
|
self.conn.content.rootFolder, [vim.HostSystem], True
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
hosts = list(container.view)
|
hosts = list(container.view)
|
||||||
@ -128,15 +118,10 @@ 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, host: str | None = None) -> list[dict[str, Any]]:
|
def list_hosts(self) -> list[dict[str, Any]]:
|
||||||
"""List all ESXi hosts with basic info.
|
"""List all ESXi hosts with basic info."""
|
||||||
|
container = self.conn.content.viewManager.CreateContainerView(
|
||||||
Args:
|
self.conn.content.rootFolder, [vim.HostSystem], True
|
||||||
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 = []
|
||||||
@ -163,16 +148,9 @@ 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(
|
def get_recent_tasks(self, count: int = 20) -> list[dict[str, Any]]:
|
||||||
self, count: int = 20, host: str | None = None
|
"""Get recent vSphere tasks."""
|
||||||
) -> list[dict[str, Any]]:
|
task_manager = self.conn.content.taskManager
|
||||||
"""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 = []
|
||||||
@ -210,15 +188,10 @@ 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, host: str | None = None
|
self, count: int = 50, hours: int = 24
|
||||||
) -> 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()
|
||||||
@ -262,22 +235,17 @@ 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, host: str | None = None) -> list[dict[str, Any]]:
|
def get_alarms(self) -> 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 conn.datacenter.triggeredAlarmState:
|
if self.conn.datacenter.triggeredAlarmState:
|
||||||
for alarm_state in conn.datacenter.triggeredAlarmState:
|
for alarm_state in self.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 conn.get_all_vms():
|
for vm in self.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))
|
||||||
|
|||||||
@ -2,19 +2,20 @@
|
|||||||
|
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
from fastmcp.contrib.mcp_mixin import mcp_tool
|
from fastmcp.contrib.mcp_mixin import MCPMixin, 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:
|
||||||
pass
|
from mcvsphere.connection import VMwareConnection
|
||||||
|
|
||||||
|
|
||||||
class NICManagementMixin(VSphereMixin):
|
class NICManagementMixin(MCPMixin):
|
||||||
"""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:
|
||||||
@ -28,10 +29,10 @@ class NICManagementMixin(VSphereMixin):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
def _get_network_backing(
|
def _get_network_backing(
|
||||||
self, conn, network_name: str
|
self, 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 = conn.find_network(network_name)
|
network = self.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,18 +55,16 @@ 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, host: str | None = None) -> list[dict[str, Any]]:
|
def list_nics(self, vm_name: str) -> 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
|
||||||
"""
|
"""
|
||||||
conn = self._conn(host)
|
vm = self.conn.find_vm(vm_name)
|
||||||
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")
|
||||||
|
|
||||||
@ -89,7 +88,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 conn.datacenter.networkFolder.childEntity:
|
for net in self.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
|
||||||
@ -109,7 +108,6 @@ 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.
|
||||||
|
|
||||||
@ -118,13 +116,11 @@ 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
|
||||||
"""
|
"""
|
||||||
conn = self._conn(host)
|
vm = self.conn.find_vm(vm_name)
|
||||||
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")
|
||||||
|
|
||||||
@ -142,7 +138,7 @@ class NICManagementMixin(VSphereMixin):
|
|||||||
|
|
||||||
# Create the NIC
|
# Create the NIC
|
||||||
nic = nic_class()
|
nic = nic_class()
|
||||||
nic.backing = self._get_network_backing(conn, network)
|
nic.backing = self._get_network_backing(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
|
||||||
@ -160,7 +156,7 @@ class NICManagementMixin(VSphereMixin):
|
|||||||
|
|
||||||
# Reconfigure VM
|
# Reconfigure VM
|
||||||
task = vm.ReconfigVM_Task(spec=config_spec)
|
task = vm.ReconfigVM_Task(spec=config_spec)
|
||||||
conn.wait_for_task(task)
|
self.conn.wait_for_task(task)
|
||||||
|
|
||||||
# Get the MAC address that was assigned
|
# Get the MAC address that was assigned
|
||||||
vm.Reload()
|
vm.Reload()
|
||||||
@ -195,20 +191,17 @@ 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
|
||||||
"""
|
"""
|
||||||
conn = self._conn(host)
|
vm = self.conn.find_vm(vm_name)
|
||||||
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")
|
||||||
|
|
||||||
@ -238,7 +231,7 @@ class NICManagementMixin(VSphereMixin):
|
|||||||
|
|
||||||
# Reconfigure VM
|
# Reconfigure VM
|
||||||
task = vm.ReconfigVM_Task(spec=config_spec)
|
task = vm.ReconfigVM_Task(spec=config_spec)
|
||||||
conn.wait_for_task(task)
|
self.conn.wait_for_task(task)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"vm": vm_name,
|
"vm": vm_name,
|
||||||
@ -258,7 +251,6 @@ 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.
|
||||||
|
|
||||||
@ -266,13 +258,11 @@ 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
|
||||||
"""
|
"""
|
||||||
conn = self._conn(host)
|
vm = self.conn.find_vm(vm_name)
|
||||||
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 +281,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(conn, new_network)
|
nic.backing = self._get_network_backing(new_network)
|
||||||
|
|
||||||
# Create device edit spec
|
# Create device edit spec
|
||||||
nic_spec = vim.vm.device.VirtualDeviceSpec()
|
nic_spec = vim.vm.device.VirtualDeviceSpec()
|
||||||
@ -304,7 +294,7 @@ class NICManagementMixin(VSphereMixin):
|
|||||||
|
|
||||||
# Reconfigure VM
|
# Reconfigure VM
|
||||||
task = vm.ReconfigVM_Task(spec=config_spec)
|
task = vm.ReconfigVM_Task(spec=config_spec)
|
||||||
conn.wait_for_task(task)
|
self.conn.wait_for_task(task)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"vm": vm_name,
|
"vm": vm_name,
|
||||||
@ -325,7 +315,6 @@ 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.
|
||||||
|
|
||||||
@ -333,13 +322,11 @@ 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
|
||||||
"""
|
"""
|
||||||
conn = self._conn(host)
|
vm = self.conn.find_vm(vm_name)
|
||||||
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")
|
||||||
|
|
||||||
@ -369,7 +356,7 @@ class NICManagementMixin(VSphereMixin):
|
|||||||
|
|
||||||
# Reconfigure VM
|
# Reconfigure VM
|
||||||
task = vm.ReconfigVM_Task(spec=config_spec)
|
task = vm.ReconfigVM_Task(spec=config_spec)
|
||||||
conn.wait_for_task(task)
|
self.conn.wait_for_task(task)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"vm": vm_name,
|
"vm": vm_name,
|
||||||
@ -388,7 +375,6 @@ 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.
|
||||||
|
|
||||||
@ -396,13 +382,11 @@ 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
|
||||||
"""
|
"""
|
||||||
conn = self._conn(host)
|
vm = self.conn.find_vm(vm_name)
|
||||||
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")
|
||||||
|
|
||||||
@ -437,7 +421,7 @@ class NICManagementMixin(VSphereMixin):
|
|||||||
|
|
||||||
# Reconfigure VM
|
# Reconfigure VM
|
||||||
task = vm.ReconfigVM_Task(spec=config_spec)
|
task = vm.ReconfigVM_Task(spec=config_spec)
|
||||||
conn.wait_for_task(task)
|
self.conn.wait_for_task(task)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"vm": vm_name,
|
"vm": vm_name,
|
||||||
|
|||||||
@ -1,32 +1,26 @@
|
|||||||
"""OVF/OVA Management - deploy and export virtual appliances."""
|
"""OVF/OVA Management - deploy and export virtual appliances."""
|
||||||
|
|
||||||
import contextlib
|
|
||||||
import http.client
|
|
||||||
import shutil
|
|
||||||
import ssl
|
import ssl
|
||||||
import tarfile
|
import tarfile
|
||||||
import tempfile
|
import tempfile
|
||||||
import threading
|
|
||||||
import time
|
|
||||||
import urllib.request
|
import urllib.request
|
||||||
import xml.etree.ElementTree as ET
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
from urllib.parse import urlparse
|
|
||||||
|
|
||||||
from fastmcp.contrib.mcp_mixin import mcp_tool
|
from fastmcp.contrib.mcp_mixin import MCPMixin, 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:
|
||||||
pass
|
from mcvsphere.connection import VMwareConnection
|
||||||
|
|
||||||
|
|
||||||
class OVFManagementMixin(VSphereMixin):
|
class OVFManagementMixin(MCPMixin):
|
||||||
"""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_")
|
||||||
@ -56,12 +50,11 @@ 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 conn.settings.vcenter_insecure:
|
if self.conn.settings.vcenter_insecure:
|
||||||
context.check_hostname = False
|
context.check_hostname = False
|
||||||
context.verify_mode = ssl.CERT_NONE
|
context.verify_mode = ssl.CERT_NONE
|
||||||
|
|
||||||
@ -75,8 +68,8 @@ class OVFManagementMixin(VSphereMixin):
|
|||||||
request.add_header("Connection", "Keep-Alive")
|
request.add_header("Connection", "Keep-Alive")
|
||||||
|
|
||||||
# Add session cookie
|
# Add session cookie
|
||||||
if hasattr(conn.service_instance, "_stub"):
|
if hasattr(self.conn.service_instance, "_stub"):
|
||||||
cookie = conn.service_instance._stub.cookie
|
cookie = self.conn.service_instance._stub.cookie
|
||||||
if cookie:
|
if cookie:
|
||||||
request.add_header("Cookie", cookie)
|
request.add_header("Cookie", cookie)
|
||||||
|
|
||||||
@ -97,7 +90,6 @@ 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.
|
||||||
|
|
||||||
@ -109,23 +101,21 @@ 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 = conn.content.ovfManager
|
ovf_manager = self.conn.content.ovfManager
|
||||||
|
|
||||||
# Find target datastore
|
# Find target datastore
|
||||||
ds = conn.find_datastore(datastore)
|
ds = self.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 conn.datacenter.hostFolder.childEntity:
|
for h in self.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
|
||||||
@ -143,7 +133,7 @@ class OVFManagementMixin(VSphereMixin):
|
|||||||
resource_pool = host.parent.resourcePool
|
resource_pool = host.parent.resourcePool
|
||||||
|
|
||||||
# Get VM folder
|
# Get VM folder
|
||||||
vm_folder = conn.datacenter.vmFolder
|
vm_folder = self.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
|
||||||
@ -158,7 +148,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, conn)
|
ovf_descriptor = self._read_datastore_file(datastore, ovf_path)
|
||||||
|
|
||||||
# Create import spec params
|
# Create import spec params
|
||||||
import_spec_params = vim.OvfManager.CreateImportSpecParams(
|
import_spec_params = vim.OvfManager.CreateImportSpecParams(
|
||||||
@ -168,7 +158,7 @@ class OVFManagementMixin(VSphereMixin):
|
|||||||
|
|
||||||
# If network specified, add network mapping
|
# If network specified, add network mapping
|
||||||
if network:
|
if network:
|
||||||
net = conn.find_network(network)
|
net = self.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
|
||||||
@ -217,7 +207,7 @@ class OVFManagementMixin(VSphereMixin):
|
|||||||
lease.Complete()
|
lease.Complete()
|
||||||
|
|
||||||
# Find the newly created VM
|
# Find the newly created VM
|
||||||
vm = conn.find_vm(vm_name)
|
vm = self.conn.find_vm(vm_name)
|
||||||
|
|
||||||
result = {
|
result = {
|
||||||
"vm": vm_name,
|
"vm": vm_name,
|
||||||
@ -230,35 +220,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()
|
||||||
conn.wait_for_task(task)
|
self.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, conn) -> str:
|
def _read_datastore_file(self, datastore: str, path: str) -> str:
|
||||||
"""Read a text file from datastore."""
|
"""Read a text file from datastore."""
|
||||||
ds = conn.find_datastore(datastore)
|
ds = self.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 = conn.datacenter.name
|
dc_name = self.conn.datacenter.name
|
||||||
url = (
|
url = (
|
||||||
f"https://{conn.settings.vcenter_host}/folder/{path}"
|
f"https://{self.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 conn.settings.vcenter_insecure:
|
if self.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(conn.service_instance, "_stub"):
|
if hasattr(self.conn.service_instance, "_stub"):
|
||||||
cookie = conn.service_instance._stub.cookie
|
cookie = self.conn.service_instance._stub.cookie
|
||||||
if cookie:
|
if cookie:
|
||||||
request.add_header("Cookie", cookie)
|
request.add_header("Cookie", cookie)
|
||||||
|
|
||||||
@ -273,452 +263,6 @@ class OVFManagementMixin(VSphereMixin):
|
|||||||
# For now, document this limitation
|
# For now, document this limitation
|
||||||
pass
|
pass
|
||||||
|
|
||||||
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."""
|
|
||||||
if ova_path.startswith(("http://", "https://")):
|
|
||||||
context = ssl.create_default_context()
|
|
||||||
if conn.settings.vcenter_insecure:
|
|
||||||
context.check_hostname = False
|
|
||||||
context.verify_mode = ssl.CERT_NONE
|
|
||||||
fd, tmp = tempfile.mkstemp(suffix=".ova", prefix="ova_dl_")
|
|
||||||
with open(fd, "wb") as out:
|
|
||||||
req = urllib.request.Request(ova_path)
|
|
||||||
with urllib.request.urlopen(req, context=context) as resp:
|
|
||||||
shutil.copyfileobj(resp, out)
|
|
||||||
return tmp, True
|
|
||||||
if not Path(ova_path).is_file():
|
|
||||||
raise ValueError(f"OVA file not found: {ova_path}")
|
|
||||||
return ova_path, False
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _parse_ovf_network_names(ovf_xml: str) -> list[str]:
|
|
||||||
"""Extract declared network names from an OVF descriptor's NetworkSection."""
|
|
||||||
names: list[str] = []
|
|
||||||
try:
|
|
||||||
root = ET.fromstring(ovf_xml)
|
|
||||||
except ET.ParseError:
|
|
||||||
return names
|
|
||||||
for el in root.iter():
|
|
||||||
if el.tag.rsplit("}", 1)[-1] == "Network":
|
|
||||||
for key, val in el.attrib.items():
|
|
||||||
if key.rsplit("}", 1)[-1] == "name":
|
|
||||||
names.append(val)
|
|
||||||
return names
|
|
||||||
|
|
||||||
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."""
|
|
||||||
parsed = urlparse(device_url)
|
|
||||||
host = parsed.hostname
|
|
||||||
# ESXi returns '*' (meaning "the host you connected to") or its own
|
|
||||||
# mgmt name. For a direct host connection the only address we know is
|
|
||||||
# reachable is the one we connected on, so use that.
|
|
||||||
if (
|
|
||||||
not host
|
|
||||||
or host == "*"
|
|
||||||
or conn.content.about.apiType == "HostAgent"
|
|
||||||
):
|
|
||||||
host = conn.settings.vcenter_host
|
|
||||||
port = parsed.port or 443
|
|
||||||
path = parsed.path + (f"?{parsed.query}" if parsed.query else "")
|
|
||||||
|
|
||||||
context = ssl.create_default_context()
|
|
||||||
if conn.settings.vcenter_insecure:
|
|
||||||
context.check_hostname = False
|
|
||||||
context.verify_mode = ssl.CERT_NONE
|
|
||||||
|
|
||||||
http_conn = http.client.HTTPSConnection(
|
|
||||||
host, port, context=context, timeout=900
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
http_conn.putrequest(
|
|
||||||
"PUT", path, skip_host=True, skip_accept_encoding=True
|
|
||||||
)
|
|
||||||
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
|
|
||||||
# explicitly overwrite it (else ESXi returns 403 "File exists").
|
|
||||||
http_conn.putheader("Overwrite", "t")
|
|
||||||
cookie = getattr(conn.si._stub, "cookie", None)
|
|
||||||
if cookie:
|
|
||||||
http_conn.putheader("Cookie", cookie)
|
|
||||||
http_conn.endheaders()
|
|
||||||
while True:
|
|
||||||
chunk = fileobj.read(1024 * 1024)
|
|
||||||
if not chunk:
|
|
||||||
break
|
|
||||||
http_conn.send(chunk)
|
|
||||||
resp = http_conn.getresponse()
|
|
||||||
resp.read()
|
|
||||||
if resp.status not in (200, 201):
|
|
||||||
raise ValueError(
|
|
||||||
f"Disk upload failed: HTTP {resp.status} {resp.reason}"
|
|
||||||
)
|
|
||||||
finally:
|
|
||||||
http_conn.close()
|
|
||||||
|
|
||||||
@mcp_tool(
|
|
||||||
name="deploy_ova",
|
|
||||||
description="Deploy a VM from an OVA file (local path or URL) by streaming it to the host over an NFC lease",
|
|
||||||
annotations=ToolAnnotations(destructiveHint=True, idempotentHint=False),
|
|
||||||
)
|
|
||||||
def deploy_ova(
|
|
||||||
self,
|
|
||||||
ova_path: str,
|
|
||||||
vm_name: str,
|
|
||||||
datastore: str | None = None,
|
|
||||||
network: str | None = None,
|
|
||||||
power_on: bool = False,
|
|
||||||
disk_provisioning: str = "thin",
|
|
||||||
deployment_option: str | None = None,
|
|
||||||
iso_path: str | None = None,
|
|
||||||
iso_datastore: str | None = None,
|
|
||||||
boot_from_iso: bool = True,
|
|
||||||
host: str | None = None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""Deploy a virtual machine from an OVA file.
|
|
||||||
|
|
||||||
The OVA is read from the MCP server host (local filesystem path) or
|
|
||||||
downloaded if an http(s) URL is given, then streamed to the ESXi host
|
|
||||||
over an NFC lease. Unlike datastore-based deploy, the OVA does not need
|
|
||||||
to be pre-staged on a datastore.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
ova_path: Local path to the .ova file, or an http(s) URL to download
|
|
||||||
vm_name: Name for the new VM
|
|
||||||
datastore: Target datastore (default: largest available)
|
|
||||||
network: Target port group; every network in the OVF is mapped to it
|
|
||||||
power_on: Power on the VM after deployment
|
|
||||||
disk_provisioning: 'thin', 'thick', or 'eagerZeroedThick'
|
|
||||||
deployment_option: OVF configuration id for multi-config templates
|
|
||||||
(e.g. Cisco 'S'/'M'/'L'); defaults to the OVF's default config.
|
|
||||||
Use inspect_ova to list available options.
|
|
||||||
iso_path: ISO on a datastore to mount in a new CD/DVD drive after
|
|
||||||
deploy (for diskless appliance OVAs like CUCM that install from
|
|
||||||
a bootable ISO). A CD/DVD drive is added automatically.
|
|
||||||
iso_datastore: Datastore holding the ISO (default: the VM's datastore)
|
|
||||||
boot_from_iso: When an ISO is given, put the CD/DVD first in boot order
|
|
||||||
host: Managed ESXi host to target (default: the default host)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dict with deployment details
|
|
||||||
"""
|
|
||||||
conn = self._conn(host)
|
|
||||||
ds = conn.find_datastore(datastore) if datastore else conn.datastore
|
|
||||||
if not ds:
|
|
||||||
raise ValueError(f"Datastore '{datastore}' not found")
|
|
||||||
resource_pool = conn.resource_pool
|
|
||||||
vm_folder = conn.datacenter.vmFolder
|
|
||||||
|
|
||||||
# An ESXi host is required as the import target
|
|
||||||
view = conn.content.viewManager.CreateContainerView(
|
|
||||||
conn.content.rootFolder, [vim.HostSystem], True
|
|
||||||
)
|
|
||||||
hosts = list(view.view)
|
|
||||||
view.Destroy()
|
|
||||||
host_system = hosts[0] if hosts else None
|
|
||||||
|
|
||||||
local_ova, is_temp = self._resolve_ova_file(ova_path, conn)
|
|
||||||
import_spec = None
|
|
||||||
try:
|
|
||||||
with tarfile.open(local_ova) as tar:
|
|
||||||
ovf_member = next(
|
|
||||||
(m for m in tar.getmembers() if m.name.lower().endswith(".ovf")),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
if not ovf_member:
|
|
||||||
raise ValueError("No .ovf descriptor found in OVA")
|
|
||||||
ovf_xml = tar.extractfile(ovf_member).read().decode("utf-8")
|
|
||||||
|
|
||||||
# Build the import spec, mapping every OVF network to the target
|
|
||||||
spec_params = vim.OvfManager.CreateImportSpecParams(
|
|
||||||
entityName=vm_name,
|
|
||||||
diskProvisioning=disk_provisioning,
|
|
||||||
)
|
|
||||||
if deployment_option:
|
|
||||||
spec_params.deploymentOption = deployment_option
|
|
||||||
if network:
|
|
||||||
net = conn.find_network(network)
|
|
||||||
if not net:
|
|
||||||
raise ValueError(f"Network '{network}' not found")
|
|
||||||
ovf_nets = self._parse_ovf_network_names(ovf_xml) or ["VM Network"]
|
|
||||||
spec_params.networkMapping = [
|
|
||||||
vim.OvfManager.NetworkMapping(name=n, network=net)
|
|
||||||
for n in ovf_nets
|
|
||||||
]
|
|
||||||
|
|
||||||
ovf_manager = conn.content.ovfManager
|
|
||||||
import_spec = ovf_manager.CreateImportSpec(
|
|
||||||
ovfDescriptor=ovf_xml,
|
|
||||||
resourcePool=resource_pool,
|
|
||||||
datastore=ds,
|
|
||||||
cisp=spec_params,
|
|
||||||
)
|
|
||||||
if import_spec.error:
|
|
||||||
raise ValueError(
|
|
||||||
"OVF import errors: "
|
|
||||||
+ "; ".join(str(e.msg) for e in import_spec.error)
|
|
||||||
)
|
|
||||||
|
|
||||||
lease = resource_pool.ImportVApp(
|
|
||||||
spec=import_spec.importSpec, folder=vm_folder, host=host_system
|
|
||||||
)
|
|
||||||
while lease.state == vim.HttpNfcLease.State.initializing:
|
|
||||||
time.sleep(0.1)
|
|
||||||
if lease.state == vim.HttpNfcLease.State.error:
|
|
||||||
raise ValueError(f"NFC lease error: {lease.error}")
|
|
||||||
|
|
||||||
# Stream each disk to its lease URL, keeping the lease alive
|
|
||||||
total = sum(max(fi.size, 1) for fi in import_spec.fileItem) or 1
|
|
||||||
uploaded = [0]
|
|
||||||
stop = threading.Event()
|
|
||||||
|
|
||||||
def _keepalive() -> None:
|
|
||||||
while not stop.wait(20):
|
|
||||||
try:
|
|
||||||
lease.HttpNfcLeaseProgress(
|
|
||||||
min(99, int(uploaded[0] * 100 / total))
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
return
|
|
||||||
|
|
||||||
keeper = threading.Thread(target=_keepalive, daemon=True)
|
|
||||||
keeper.start()
|
|
||||||
try:
|
|
||||||
url_by_key = {du.importKey: du.url for du in lease.info.deviceUrl}
|
|
||||||
with tarfile.open(local_ova) as tar:
|
|
||||||
by_base = {m.name.rsplit("/", 1)[-1]: m for m in tar.getmembers()}
|
|
||||||
for fi in import_spec.fileItem:
|
|
||||||
device_url = url_by_key.get(fi.deviceId)
|
|
||||||
if not device_url:
|
|
||||||
raise ValueError(f"No lease URL for device {fi.deviceId}")
|
|
||||||
member = by_base.get(fi.path.rsplit("/", 1)[-1])
|
|
||||||
if not member:
|
|
||||||
raise ValueError(f"Disk '{fi.path}' missing from OVA")
|
|
||||||
src = tar.extractfile(member)
|
|
||||||
self._put_stream_to_lease(
|
|
||||||
device_url, src, member.size, conn
|
|
||||||
)
|
|
||||||
uploaded[0] += member.size
|
|
||||||
lease.HttpNfcLeaseProgress(
|
|
||||||
min(99, int(uploaded[0] * 100 / total))
|
|
||||||
)
|
|
||||||
lease.HttpNfcLeaseProgress(100)
|
|
||||||
lease.Complete()
|
|
||||||
except Exception:
|
|
||||||
with contextlib.suppress(Exception):
|
|
||||||
lease.Abort()
|
|
||||||
raise
|
|
||||||
finally:
|
|
||||||
stop.set()
|
|
||||||
finally:
|
|
||||||
if is_temp:
|
|
||||||
Path(local_ova).unlink(missing_ok=True)
|
|
||||||
|
|
||||||
vm = conn.find_vm(vm_name)
|
|
||||||
result: dict[str, Any] = {
|
|
||||||
"vm": vm_name,
|
|
||||||
"action": "ova_deployed",
|
|
||||||
"datastore": ds.name,
|
|
||||||
"source": ova_path,
|
|
||||||
"disks": len(import_spec.fileItem) if import_spec else 0,
|
|
||||||
}
|
|
||||||
if vm:
|
|
||||||
result["uuid"] = vm.config.uuid
|
|
||||||
if iso_path:
|
|
||||||
result["iso"] = self._add_cdrom_with_iso(
|
|
||||||
vm, iso_path, iso_datastore, boot_from_iso, conn
|
|
||||||
)
|
|
||||||
if power_on:
|
|
||||||
task = vm.PowerOnVM_Task()
|
|
||||||
conn.wait_for_task(task)
|
|
||||||
result["power_state"] = "poweredOn"
|
|
||||||
else:
|
|
||||||
result["power_state"] = "poweredOff"
|
|
||||||
return result
|
|
||||||
|
|
||||||
def _add_cdrom_with_iso(
|
|
||||||
self,
|
|
||||||
vm: vim.VirtualMachine,
|
|
||||||
iso_path: str,
|
|
||||||
iso_datastore: str | None,
|
|
||||||
boot_from_iso: bool,
|
|
||||||
conn,
|
|
||||||
) -> str:
|
|
||||||
"""Mount an ISO in the VM's CD/DVD drive (reusing the first existing
|
|
||||||
drive, or adding one if none exist) and optionally boot from it.
|
|
||||||
|
|
||||||
Reusing the existing primary drive matters: many appliance OVAs (e.g.
|
|
||||||
Cisco CUCM) already define empty CD/DVD drives, and the BIOS boots the
|
|
||||||
first one, so the ISO must land there rather than on a newly-added
|
|
||||||
secondary drive.
|
|
||||||
|
|
||||||
Returns the mounted datastore ISO path.
|
|
||||||
"""
|
|
||||||
ds_name = iso_datastore or vm.config.files.vmPathName.split("]")[0].strip("[ ")
|
|
||||||
mounted = f"[{ds_name}] {iso_path}"
|
|
||||||
|
|
||||||
backing = vim.vm.device.VirtualCdrom.IsoBackingInfo()
|
|
||||||
backing.fileName = mounted
|
|
||||||
connectable = vim.vm.device.VirtualDevice.ConnectInfo()
|
|
||||||
connectable.allowGuestControl = True
|
|
||||||
connectable.startConnected = True
|
|
||||||
connectable.connected = (
|
|
||||||
vm.runtime.powerState == vim.VirtualMachinePowerState.poweredOn
|
|
||||||
)
|
|
||||||
|
|
||||||
existing = next(
|
|
||||||
(
|
|
||||||
d
|
|
||||||
for d in vm.config.hardware.device
|
|
||||||
if isinstance(d, vim.vm.device.VirtualCdrom)
|
|
||||||
),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
|
|
||||||
cdrom_spec = vim.vm.device.VirtualDeviceSpec()
|
|
||||||
if existing is not None:
|
|
||||||
existing.backing = backing
|
|
||||||
existing.connectable = connectable
|
|
||||||
cdrom_spec.operation = vim.vm.device.VirtualDeviceSpec.Operation.edit
|
|
||||||
cdrom_spec.device = existing
|
|
||||||
else:
|
|
||||||
# No drive present — add one on a free IDE slot
|
|
||||||
used: dict[int, set[int]] = {}
|
|
||||||
ide_controllers = []
|
|
||||||
for d in vm.config.hardware.device:
|
|
||||||
if isinstance(d, vim.vm.device.VirtualIDEController):
|
|
||||||
ide_controllers.append(d)
|
|
||||||
if hasattr(d, "controllerKey") and hasattr(d, "unitNumber"):
|
|
||||||
used.setdefault(d.controllerKey, set()).add(d.unitNumber)
|
|
||||||
controller_key = unit = None
|
|
||||||
for controller in ide_controllers:
|
|
||||||
for candidate in (0, 1):
|
|
||||||
if candidate not in used.get(controller.key, set()):
|
|
||||||
controller_key, unit = controller.key, candidate
|
|
||||||
break
|
|
||||||
if controller_key is not None:
|
|
||||||
break
|
|
||||||
if controller_key is None:
|
|
||||||
raise ValueError("No free IDE slot for a CD/DVD drive")
|
|
||||||
cdrom_spec.operation = vim.vm.device.VirtualDeviceSpec.Operation.add
|
|
||||||
cdrom_spec.device = vim.vm.device.VirtualCdrom()
|
|
||||||
cdrom_spec.device.controllerKey = controller_key
|
|
||||||
cdrom_spec.device.unitNumber = unit
|
|
||||||
cdrom_spec.device.key = -1
|
|
||||||
cdrom_spec.device.backing = backing
|
|
||||||
cdrom_spec.device.connectable = connectable
|
|
||||||
|
|
||||||
config_spec = vim.vm.ConfigSpec(deviceChange=[cdrom_spec])
|
|
||||||
if boot_from_iso:
|
|
||||||
config_spec.bootOptions = vim.vm.BootOptions(
|
|
||||||
bootOrder=[vim.vm.BootOptions.BootableCdromDevice()]
|
|
||||||
)
|
|
||||||
task = vm.ReconfigVM_Task(spec=config_spec)
|
|
||||||
conn.wait_for_task(task)
|
|
||||||
return mounted
|
|
||||||
|
|
||||||
@mcp_tool(
|
|
||||||
name="inspect_ova",
|
|
||||||
description="Inspect an OVA (local path or URL): product, deployment configurations, networks, and disks",
|
|
||||||
annotations=ToolAnnotations(readOnlyHint=True),
|
|
||||||
)
|
|
||||||
def inspect_ova(self, ova_path: str, host: str | None = None) -> dict[str, Any]:
|
|
||||||
"""Inspect an OVA without deploying it.
|
|
||||||
|
|
||||||
Reads the OVF descriptor and reports the product, guest OS, hardware
|
|
||||||
version, selectable deployment configurations (e.g. Cisco S/M/L), the
|
|
||||||
networks the OVF expects, and its disks. Useful for picking a
|
|
||||||
``deployment_option`` and ``network`` before calling ``deploy_ova``.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
ova_path: Local path to the .ova file, or an http(s) URL
|
|
||||||
host: Managed ESXi host to target (default: the default host)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dict describing the OVA
|
|
||||||
"""
|
|
||||||
conn = self._conn(host)
|
|
||||||
local_ova, is_temp = self._resolve_ova_file(ova_path, conn)
|
|
||||||
try:
|
|
||||||
with tarfile.open(local_ova) as tar:
|
|
||||||
ovf_member = next(
|
|
||||||
(m for m in tar.getmembers() if m.name.lower().endswith(".ovf")),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
if not ovf_member:
|
|
||||||
raise ValueError("No .ovf descriptor found in OVA")
|
|
||||||
ovf_xml = tar.extractfile(ovf_member).read().decode("utf-8")
|
|
||||||
disk_files = [
|
|
||||||
m.name for m in tar.getmembers() if m.name.lower().endswith(".vmdk")
|
|
||||||
]
|
|
||||||
finally:
|
|
||||||
if is_temp:
|
|
||||||
Path(local_ova).unlink(missing_ok=True)
|
|
||||||
|
|
||||||
def lname(tag: str) -> str:
|
|
||||||
return tag.rsplit("}", 1)[-1]
|
|
||||||
|
|
||||||
root = ET.fromstring(ovf_xml)
|
|
||||||
product: str | None = None
|
|
||||||
os_type: str | None = None
|
|
||||||
hw_version: str | None = None
|
|
||||||
configs: list[dict[str, Any]] = []
|
|
||||||
networks: list[str] = []
|
|
||||||
disks: list[dict[str, Any]] = []
|
|
||||||
|
|
||||||
for el in root.iter():
|
|
||||||
tag = lname(el.tag)
|
|
||||||
if tag == "Product" and product is None:
|
|
||||||
product = (el.text or "").strip()
|
|
||||||
elif tag == "OperatingSystemSection":
|
|
||||||
for key, val in el.attrib.items():
|
|
||||||
if lname(key) == "osType":
|
|
||||||
os_type = val
|
|
||||||
elif tag == "VirtualSystemType" and hw_version is None:
|
|
||||||
hw_version = (el.text or "").strip()
|
|
||||||
elif tag == "Configuration":
|
|
||||||
cid = None
|
|
||||||
default = False
|
|
||||||
for key, val in el.attrib.items():
|
|
||||||
if lname(key) == "id":
|
|
||||||
cid = val
|
|
||||||
elif lname(key) == "default":
|
|
||||||
default = val == "true"
|
|
||||||
label = ""
|
|
||||||
for child in el:
|
|
||||||
if lname(child.tag) == "Label":
|
|
||||||
label = (child.text or "").strip()
|
|
||||||
configs.append({"id": cid, "label": label, "default": default})
|
|
||||||
elif tag == "Network":
|
|
||||||
for key, val in el.attrib.items():
|
|
||||||
if lname(key) == "name":
|
|
||||||
networks.append(val)
|
|
||||||
elif tag == "Disk":
|
|
||||||
disk: dict[str, Any] = {}
|
|
||||||
for key, val in el.attrib.items():
|
|
||||||
name = lname(key)
|
|
||||||
if name == "diskId":
|
|
||||||
disk["disk_id"] = val
|
|
||||||
elif name == "capacity":
|
|
||||||
disk["capacity"] = val
|
|
||||||
elif name == "capacityAllocationUnits":
|
|
||||||
disk["units"] = val
|
|
||||||
disks.append(disk)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"source": ova_path,
|
|
||||||
"product": product,
|
|
||||||
"os_type": os_type,
|
|
||||||
"hardware_version": hw_version,
|
|
||||||
"deployment_options": configs,
|
|
||||||
"networks": networks,
|
|
||||||
"disks": disks,
|
|
||||||
"disk_files": disk_files,
|
|
||||||
}
|
|
||||||
|
|
||||||
@mcp_tool(
|
@mcp_tool(
|
||||||
name="export_vm_ovf",
|
name="export_vm_ovf",
|
||||||
description="Export a VM to OVF format on a datastore",
|
description="Export a VM to OVF format on a datastore",
|
||||||
@ -729,7 +273,6 @@ 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.
|
||||||
|
|
||||||
@ -737,13 +280,11 @@ 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
|
||||||
"""
|
"""
|
||||||
conn = self._conn(host)
|
vm = self.conn.find_vm(vm_name)
|
||||||
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")
|
||||||
|
|
||||||
@ -753,7 +294,7 @@ class OVFManagementMixin(VSphereMixin):
|
|||||||
|
|
||||||
# Determine target datastore
|
# Determine target datastore
|
||||||
if datastore:
|
if datastore:
|
||||||
ds = conn.find_datastore(datastore)
|
ds = self.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
|
||||||
@ -771,7 +312,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 = conn.content.ovfManager
|
ovf_manager = self.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(
|
||||||
@ -805,9 +346,7 @@ 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(
|
self._write_datastore_file(ds_name, ovf_output_path, ovf_descriptor.ovfDescriptor)
|
||||||
ds_name, ovf_output_path, ovf_descriptor.ovfDescriptor, conn
|
|
||||||
)
|
|
||||||
|
|
||||||
exported_files.append(ovf_output_path)
|
exported_files.append(ovf_output_path)
|
||||||
|
|
||||||
@ -823,19 +362,17 @@ class OVFManagementMixin(VSphereMixin):
|
|||||||
"ovf_descriptor": ovf_filename,
|
"ovf_descriptor": ovf_filename,
|
||||||
}
|
}
|
||||||
|
|
||||||
def _write_datastore_file(
|
def _write_datastore_file(self, datastore: str, path: str, content: str) -> None:
|
||||||
self, datastore: str, path: str, content: str, conn
|
|
||||||
) -> None:
|
|
||||||
"""Write a text file to datastore."""
|
"""Write a text file to datastore."""
|
||||||
dc_name = conn.datacenter.name
|
dc_name = self.conn.datacenter.name
|
||||||
url = (
|
url = (
|
||||||
f"https://{conn.settings.vcenter_host}/folder/{path}"
|
f"https://{self.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 conn.settings.vcenter_insecure:
|
if self.conn.settings.vcenter_insecure:
|
||||||
context.check_hostname = False
|
context.check_hostname = False
|
||||||
context.verify_mode = ssl.CERT_NONE
|
context.verify_mode = ssl.CERT_NONE
|
||||||
|
|
||||||
@ -844,8 +381,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(conn.service_instance, "_stub"):
|
if hasattr(self.conn.service_instance, "_stub"):
|
||||||
cookie = conn.service_instance._stub.cookie
|
cookie = self.conn.service_instance._stub.cookie
|
||||||
if cookie:
|
if cookie:
|
||||||
request.add_header("Cookie", cookie)
|
request.add_header("Cookie", cookie)
|
||||||
|
|
||||||
@ -860,28 +397,25 @@ 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, conn)
|
ovf_descriptor = self._read_datastore_file(datastore, ovf_path)
|
||||||
|
|
||||||
# Parse network references
|
# Parse network references
|
||||||
ovf_manager = conn.content.ovfManager
|
ovf_manager = self.conn.content.ovfManager
|
||||||
|
|
||||||
# Get resource pool for parsing
|
# Get resource pool for parsing
|
||||||
host = None
|
host = None
|
||||||
for h in conn.datacenter.hostFolder.childEntity:
|
for h in self.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
|
||||||
@ -890,7 +424,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 = conn.find_datastore(datastore)
|
ds = self.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()
|
||||||
|
|||||||
@ -2,28 +2,28 @@
|
|||||||
|
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from fastmcp.contrib.mcp_mixin import mcp_tool
|
from fastmcp.contrib.mcp_mixin import MCPMixin, 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:
|
||||||
pass
|
from mcvsphere.connection import VMwareConnection
|
||||||
|
|
||||||
|
|
||||||
class PowerOpsMixin(VSphereMixin):
|
class PowerOpsMixin(MCPMixin):
|
||||||
"""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",
|
||||||
annotations=ToolAnnotations(destructiveHint=False, idempotentHint=True),
|
annotations=ToolAnnotations(destructiveHint=False, idempotentHint=True),
|
||||||
)
|
)
|
||||||
def power_on(self, name: str, host: str | None = None) -> str:
|
def power_on(self, name: str) -> str:
|
||||||
"""Power on a virtual machine."""
|
"""Power on a virtual machine."""
|
||||||
conn = self._conn(host)
|
vm = self.conn.find_vm(name)
|
||||||
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")
|
||||||
|
|
||||||
@ -31,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()
|
||||||
conn.wait_for_task(task)
|
self.conn.wait_for_task(task)
|
||||||
|
|
||||||
return f"VM '{name}' powered on"
|
return f"VM '{name}' powered on"
|
||||||
|
|
||||||
@ -40,10 +40,9 @@ 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, host: str | None = None) -> str:
|
def power_off(self, name: str) -> str:
|
||||||
"""Power off a virtual machine (hard shutdown)."""
|
"""Power off a virtual machine (hard shutdown)."""
|
||||||
conn = self._conn(host)
|
vm = self.conn.find_vm(name)
|
||||||
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")
|
||||||
|
|
||||||
@ -51,7 +50,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()
|
||||||
conn.wait_for_task(task)
|
self.conn.wait_for_task(task)
|
||||||
|
|
||||||
return f"VM '{name}' powered off"
|
return f"VM '{name}' powered off"
|
||||||
|
|
||||||
@ -60,10 +59,9 @@ 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, host: str | None = None) -> str:
|
def shutdown_guest(self, name: str) -> str:
|
||||||
"""Gracefully shutdown the guest OS."""
|
"""Gracefully shutdown the guest OS."""
|
||||||
conn = self._conn(host)
|
vm = self.conn.find_vm(name)
|
||||||
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")
|
||||||
|
|
||||||
@ -84,10 +82,9 @@ 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, host: str | None = None) -> str:
|
def reboot_guest(self, name: str) -> str:
|
||||||
"""Gracefully reboot the guest OS."""
|
"""Gracefully reboot the guest OS."""
|
||||||
conn = self._conn(host)
|
vm = self.conn.find_vm(name)
|
||||||
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")
|
||||||
|
|
||||||
@ -108,15 +105,14 @@ 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, host: str | None = None) -> str:
|
def reset_vm(self, name: str) -> str:
|
||||||
"""Reset (hard reboot) a virtual machine."""
|
"""Reset (hard reboot) a virtual machine."""
|
||||||
conn = self._conn(host)
|
vm = self.conn.find_vm(name)
|
||||||
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()
|
||||||
conn.wait_for_task(task)
|
self.conn.wait_for_task(task)
|
||||||
|
|
||||||
return f"VM '{name}' reset"
|
return f"VM '{name}' reset"
|
||||||
|
|
||||||
@ -125,10 +121,9 @@ 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, host: str | None = None) -> str:
|
def suspend_vm(self, name: str) -> str:
|
||||||
"""Suspend a virtual machine."""
|
"""Suspend a virtual machine."""
|
||||||
conn = self._conn(host)
|
vm = self.conn.find_vm(name)
|
||||||
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")
|
||||||
|
|
||||||
@ -139,7 +134,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()
|
||||||
conn.wait_for_task(task)
|
self.conn.wait_for_task(task)
|
||||||
|
|
||||||
return f"VM '{name}' suspended"
|
return f"VM '{name}' suspended"
|
||||||
|
|
||||||
@ -148,10 +143,9 @@ 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, host: str | None = None) -> str:
|
def standby_guest(self, name: str) -> str:
|
||||||
"""Put guest OS into standby mode."""
|
"""Put guest OS into standby mode."""
|
||||||
conn = self._conn(host)
|
vm = self.conn.find_vm(name)
|
||||||
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")
|
||||||
|
|
||||||
|
|||||||
@ -2,19 +2,20 @@
|
|||||||
|
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
from fastmcp.contrib.mcp_mixin import mcp_resource, mcp_tool
|
from fastmcp.contrib.mcp_mixin import MCPMixin, 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:
|
||||||
pass
|
from mcvsphere.connection import VMwareConnection
|
||||||
|
|
||||||
|
|
||||||
class ResourcesMixin(VSphereMixin):
|
class ResourcesMixin(MCPMixin):
|
||||||
"""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)
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
@ -26,13 +27,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(self.conn, datastore_name, "")
|
return self._browse_datastore_path(datastore_name, "")
|
||||||
|
|
||||||
def _browse_datastore_path(
|
def _browse_datastore_path(
|
||||||
self, conn, datastore_name: str, path: str
|
self, 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 = conn.find_datastore(datastore_name)
|
ds = self.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 +57,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)
|
||||||
conn.wait_for_task(task)
|
self.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 +86,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, conn, datastore: str, path: str, chunk_size: int = 1024 * 1024):
|
def _stream_from_esxi(self, 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 +96,13 @@ class ResourcesMixin(VSphereMixin):
|
|||||||
import urllib.request
|
import urllib.request
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
|
|
||||||
ds = conn.find_datastore(datastore)
|
ds = self.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 = conn.datacenter.name
|
dc_name = self.conn.datacenter.name
|
||||||
host = conn.settings.vcenter_host
|
host = self.conn.settings.vcenter_host
|
||||||
encoded_path = quote(path, safe="")
|
encoded_path = quote(path, safe="")
|
||||||
|
|
||||||
url = (
|
url = (
|
||||||
@ -111,12 +112,12 @@ class ResourcesMixin(VSphereMixin):
|
|||||||
|
|
||||||
# Create SSL context
|
# Create SSL context
|
||||||
context = ssl.create_default_context()
|
context = ssl.create_default_context()
|
||||||
if conn.settings.vcenter_insecure:
|
if self.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 = conn.si._stub
|
stub = self.conn.si._stub
|
||||||
cookie = stub.cookie
|
cookie = stub.cookie
|
||||||
|
|
||||||
request = urllib.request.Request(url, method="GET")
|
request = urllib.request.Request(url, method="GET")
|
||||||
@ -146,17 +147,15 @@ class ResourcesMixin(VSphereMixin):
|
|||||||
annotations=ToolAnnotations(readOnlyHint=True),
|
annotations=ToolAnnotations(readOnlyHint=True),
|
||||||
)
|
)
|
||||||
def browse_datastore_tool(
|
def browse_datastore_tool(
|
||||||
self, datastore: str, path: str = "", host: str | None = None
|
self, datastore: str, path: str = ""
|
||||||
) -> 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)
|
|
||||||
"""
|
"""
|
||||||
conn = self._conn(host)
|
return self._browse_datastore_path(datastore, path)
|
||||||
return self._browse_datastore_path(conn, datastore, path)
|
|
||||||
|
|
||||||
@mcp_tool(
|
@mcp_tool(
|
||||||
name="download_from_datastore",
|
name="download_from_datastore",
|
||||||
@ -169,7 +168,6 @@ 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.
|
||||||
|
|
||||||
@ -182,16 +180,14 @@ 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(conn, datastore, path)
|
stream = self._stream_from_esxi(datastore, path)
|
||||||
|
|
||||||
# First yield is total size (or None)
|
# First yield is total size (or None)
|
||||||
total_size = next(stream)
|
total_size = next(stream)
|
||||||
@ -265,7 +261,6 @@ 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.
|
||||||
|
|
||||||
@ -278,7 +273,6 @@ 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
|
||||||
@ -288,37 +282,35 @@ 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 = conn.find_datastore(datastore)
|
ds = self.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 = conn.datacenter.name
|
dc_name = self.conn.datacenter.name
|
||||||
vc_host = conn.settings.vcenter_host
|
host = self.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://{vc_host}/folder/{encoded_path}"
|
f"https://{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 conn.settings.vcenter_insecure:
|
if self.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 = conn.si._stub
|
stub = self.conn.si._stub
|
||||||
cookie = stub.cookie
|
cookie = stub.cookie
|
||||||
|
|
||||||
if local_path:
|
if local_path:
|
||||||
@ -407,21 +399,17 @@ 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(
|
def delete_datastore_file(self, datastore: str, path: str) -> str:
|
||||||
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
|
||||||
"""
|
"""
|
||||||
conn = self._conn(host)
|
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")
|
||||||
|
|
||||||
@ -429,11 +417,11 @@ class ResourcesMixin(VSphereMixin):
|
|||||||
ds_path = f"[{datastore}] {path}"
|
ds_path = f"[{datastore}] {path}"
|
||||||
|
|
||||||
# Use FileManager to delete
|
# Use FileManager to delete
|
||||||
file_manager = conn.content.fileManager
|
file_manager = self.conn.content.fileManager
|
||||||
dc = conn.datacenter
|
dc = self.conn.datacenter
|
||||||
|
|
||||||
task = file_manager.DeleteDatastoreFile_Task(name=ds_path, datacenter=dc)
|
task = file_manager.DeleteDatastoreFile_Task(name=ds_path, datacenter=dc)
|
||||||
conn.wait_for_task(task)
|
self.conn.wait_for_task(task)
|
||||||
|
|
||||||
return f"Deleted [{datastore}] {path}"
|
return f"Deleted [{datastore}] {path}"
|
||||||
|
|
||||||
@ -442,21 +430,17 @@ 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(
|
def create_datastore_folder(self, datastore: str, path: str) -> str:
|
||||||
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
|
||||||
"""
|
"""
|
||||||
conn = self._conn(host)
|
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")
|
||||||
|
|
||||||
@ -464,8 +448,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 = conn.content.fileManager
|
file_manager = self.conn.content.fileManager
|
||||||
file_manager.MakeDirectory(name=ds_path, datacenter=conn.datacenter)
|
file_manager.MakeDirectory(name=ds_path, datacenter=self.conn.datacenter)
|
||||||
|
|
||||||
return f"Created folder [{datastore}] {path}"
|
return f"Created folder [{datastore}] {path}"
|
||||||
|
|
||||||
@ -490,7 +474,6 @@ 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.
|
||||||
|
|
||||||
@ -499,13 +482,11 @@ 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
|
||||||
"""
|
"""
|
||||||
conn = self._conn(host)
|
ds = self.conn.find_datastore(source_datastore)
|
||||||
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")
|
||||||
|
|
||||||
@ -513,7 +494,7 @@ class ResourcesMixin(VSphereMixin):
|
|||||||
if not dest_datastore:
|
if not dest_datastore:
|
||||||
dest_datastore = source_datastore
|
dest_datastore = source_datastore
|
||||||
else:
|
else:
|
||||||
dest_ds = conn.find_datastore(dest_datastore)
|
dest_ds = self.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")
|
||||||
|
|
||||||
@ -525,8 +506,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 = conn.content.fileManager
|
file_manager = self.conn.content.fileManager
|
||||||
dc = conn.datacenter
|
dc = self.conn.datacenter
|
||||||
|
|
||||||
task = file_manager.MoveDatastoreFile_Task(
|
task = file_manager.MoveDatastoreFile_Task(
|
||||||
sourceName=source_ds_path,
|
sourceName=source_ds_path,
|
||||||
@ -535,7 +516,7 @@ class ResourcesMixin(VSphereMixin):
|
|||||||
destinationDatacenter=dc,
|
destinationDatacenter=dc,
|
||||||
force=False,
|
force=False,
|
||||||
)
|
)
|
||||||
conn.wait_for_task(task)
|
self.conn.wait_for_task(task)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"action": "moved",
|
"action": "moved",
|
||||||
@ -555,7 +536,6 @@ 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.
|
||||||
|
|
||||||
@ -565,13 +545,11 @@ 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
|
||||||
"""
|
"""
|
||||||
conn = self._conn(host)
|
ds = self.conn.find_datastore(source_datastore)
|
||||||
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")
|
||||||
|
|
||||||
@ -579,7 +557,7 @@ class ResourcesMixin(VSphereMixin):
|
|||||||
if not dest_datastore:
|
if not dest_datastore:
|
||||||
dest_datastore = source_datastore
|
dest_datastore = source_datastore
|
||||||
else:
|
else:
|
||||||
dest_ds = conn.find_datastore(dest_datastore)
|
dest_ds = self.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")
|
||||||
|
|
||||||
@ -591,8 +569,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 = conn.content.fileManager
|
file_manager = self.conn.content.fileManager
|
||||||
dc = conn.datacenter
|
dc = self.conn.datacenter
|
||||||
|
|
||||||
task = file_manager.CopyDatastoreFile_Task(
|
task = file_manager.CopyDatastoreFile_Task(
|
||||||
sourceName=source_ds_path,
|
sourceName=source_ds_path,
|
||||||
@ -601,7 +579,7 @@ class ResourcesMixin(VSphereMixin):
|
|||||||
destinationDatacenter=dc,
|
destinationDatacenter=dc,
|
||||||
force=force,
|
force=force,
|
||||||
)
|
)
|
||||||
conn.wait_for_task(task)
|
self.conn.wait_for_task(task)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"action": "copied",
|
"action": "copied",
|
||||||
@ -741,15 +719,9 @@ 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, host: str | None = None) -> dict[str, Any]:
|
def get_datastore_info(self, name: str) -> 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")
|
||||||
|
|
||||||
@ -781,15 +753,9 @@ 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, host: str | None = None) -> dict[str, Any]:
|
def get_network_info(self, name: str) -> 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")
|
||||||
|
|
||||||
@ -818,21 +784,14 @@ 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(
|
def get_resource_pool_info(self, name: str | None = None) -> dict[str, Any]:
|
||||||
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 = conn.content.viewManager.CreateContainerView(
|
container = self.conn.content.viewManager.CreateContainerView(
|
||||||
conn.content.rootFolder, [vim.ResourcePool], True
|
self.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)
|
||||||
@ -841,7 +800,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 = conn.resource_pool
|
pool = self.conn.resource_pool
|
||||||
|
|
||||||
runtime = pool.summary.runtime
|
runtime = pool.summary.runtime
|
||||||
config = pool.summary.config
|
config = pool.summary.config
|
||||||
@ -864,15 +823,10 @@ 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, host: str | None = None) -> list[dict[str, Any]]:
|
def list_templates(self) -> 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 conn.get_all_vms():
|
for vm in self.conn.get_all_vms():
|
||||||
if vm.config and vm.config.template:
|
if vm.config and vm.config.template:
|
||||||
templates.append(
|
templates.append(
|
||||||
{
|
{
|
||||||
@ -889,14 +843,9 @@ 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, host: str | None = None) -> dict[str, Any]:
|
def get_vcenter_info(self) -> 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,
|
||||||
|
|||||||
@ -5,17 +5,15 @@ import socket
|
|||||||
import time
|
import time
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
from fastmcp.contrib.mcp_mixin import mcp_tool
|
from fastmcp.contrib.mcp_mixin import MCPMixin, 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:
|
||||||
pass
|
from mcvsphere.connection import VMwareConnection
|
||||||
|
|
||||||
|
|
||||||
class SerialPortMixin(VSphereMixin):
|
class SerialPortMixin(MCPMixin):
|
||||||
"""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,
|
||||||
@ -28,6 +26,9 @@ class SerialPortMixin(VSphereMixin):
|
|||||||
- 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:
|
||||||
@ -66,18 +67,16 @@ 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, host: str | None = None) -> dict[str, Any]:
|
def get_serial_port(self, name: str) -> 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
|
||||||
"""
|
"""
|
||||||
conn = self._conn(host)
|
vm = self.conn.find_vm(name)
|
||||||
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")
|
||||||
|
|
||||||
@ -111,7 +110,6 @@ 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.
|
||||||
|
|
||||||
@ -121,13 +119,11 @@ 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
|
||||||
"""
|
"""
|
||||||
conn = self._conn(host)
|
vm = self.conn.find_vm(name)
|
||||||
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")
|
||||||
|
|
||||||
@ -146,8 +142,8 @@ class SerialPortMixin(VSphereMixin):
|
|||||||
|
|
||||||
# Find or assign port
|
# Find or assign port
|
||||||
if port is None:
|
if port is None:
|
||||||
vm_host = vm.runtime.host
|
host = vm.runtime.host
|
||||||
host_ip = vm_host.name if vm_host else conn.settings.vcenter_host
|
host_ip = host.name if host else self.conn.settings.vcenter_host
|
||||||
port = self._find_unused_port(host_ip)
|
port = self._find_unused_port(host_ip)
|
||||||
|
|
||||||
# Build service URI
|
# Build service URI
|
||||||
@ -182,11 +178,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)
|
||||||
conn.wait_for_task(task)
|
self.conn.wait_for_task(task)
|
||||||
|
|
||||||
# Get ESXi host info for connection string
|
# Get ESXi host info for connection string
|
||||||
vm_host = vm.runtime.host
|
host = vm.runtime.host
|
||||||
host_ip = vm_host.name if vm_host else conn.settings.vcenter_host
|
host_ip = host.name if host else self.conn.settings.vcenter_host
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"vm_name": name,
|
"vm_name": name,
|
||||||
@ -204,19 +200,17 @@ 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, host: str | None = None) -> dict[str, Any]:
|
def connect_serial_port(self, name: str, connected: bool = True) -> 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
|
||||||
"""
|
"""
|
||||||
conn = self._conn(host)
|
vm = self.conn.find_vm(name)
|
||||||
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")
|
||||||
|
|
||||||
@ -233,7 +227,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)
|
||||||
conn.wait_for_task(task)
|
self.conn.wait_for_task(task)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"vm_name": name,
|
"vm_name": name,
|
||||||
@ -246,20 +240,18 @@ 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, host: str | None = None) -> dict[str, Any]:
|
def clear_serial_port(self, name: str) -> 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
|
||||||
"""
|
"""
|
||||||
conn = self._conn(host)
|
vm = self.conn.find_vm(name)
|
||||||
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")
|
||||||
|
|
||||||
@ -268,11 +260,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, host=host)
|
self.connect_serial_port(name, connected=False)
|
||||||
time.sleep(1)
|
time.sleep(1)
|
||||||
|
|
||||||
# Reconnect
|
# Reconnect
|
||||||
self.connect_serial_port(name, connected=True, host=host)
|
self.connect_serial_port(name, connected=True)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"vm_name": name,
|
"vm_name": name,
|
||||||
@ -286,18 +278,16 @@ 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, host: str | None = None) -> str:
|
def remove_serial_port(self, name: str) -> 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
|
||||||
"""
|
"""
|
||||||
conn = self._conn(host)
|
vm = self.conn.find_vm(name)
|
||||||
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")
|
||||||
|
|
||||||
@ -317,6 +307,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)
|
||||||
conn.wait_for_task(task)
|
self.conn.wait_for_task(task)
|
||||||
|
|
||||||
return f"Serial port removed from VM '{name}'"
|
return f"Serial port removed from VM '{name}'"
|
||||||
|
|||||||
@ -2,19 +2,20 @@
|
|||||||
|
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
from fastmcp.contrib.mcp_mixin import mcp_tool
|
from fastmcp.contrib.mcp_mixin import MCPMixin, 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:
|
||||||
pass
|
from mcvsphere.connection import VMwareConnection
|
||||||
|
|
||||||
|
|
||||||
class SnapshotsMixin(VSphereMixin):
|
class SnapshotsMixin(MCPMixin):
|
||||||
"""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]]:
|
||||||
@ -55,15 +56,9 @@ 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, host: str | None = None) -> list[dict[str, Any]]:
|
def list_snapshots(self, name: str) -> 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")
|
||||||
|
|
||||||
@ -101,7 +96,6 @@ 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.
|
||||||
|
|
||||||
@ -111,10 +105,8 @@ 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)
|
|
||||||
"""
|
"""
|
||||||
conn = self._conn(host)
|
vm = self.conn.find_vm(name)
|
||||||
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")
|
||||||
|
|
||||||
@ -135,7 +127,7 @@ class SnapshotsMixin(VSphereMixin):
|
|||||||
memory=memory,
|
memory=memory,
|
||||||
quiesce=quiesce,
|
quiesce=quiesce,
|
||||||
)
|
)
|
||||||
conn.wait_for_task(task)
|
self.conn.wait_for_task(task)
|
||||||
|
|
||||||
return f"Snapshot '{snapshot_name}' created for VM '{name}'"
|
return f"Snapshot '{snapshot_name}' created for VM '{name}'"
|
||||||
|
|
||||||
@ -144,18 +136,9 @@ 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(
|
def revert_to_snapshot(self, name: str, snapshot_name: str) -> str:
|
||||||
self, name: str, snapshot_name: str, host: str | None = None
|
"""Revert VM to a specific snapshot."""
|
||||||
) -> str:
|
vm = self.conn.find_vm(name)
|
||||||
"""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")
|
||||||
|
|
||||||
@ -169,7 +152,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()
|
||||||
conn.wait_for_task(task)
|
self.conn.wait_for_task(task)
|
||||||
|
|
||||||
return f"VM '{name}' reverted to snapshot '{snapshot_name}'"
|
return f"VM '{name}' reverted to snapshot '{snapshot_name}'"
|
||||||
|
|
||||||
@ -178,15 +161,9 @@ 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, host: str | None = None) -> str:
|
def revert_to_current_snapshot(self, name: str) -> 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")
|
||||||
|
|
||||||
@ -194,7 +171,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()
|
||||||
conn.wait_for_task(task)
|
self.conn.wait_for_task(task)
|
||||||
|
|
||||||
return f"VM '{name}' reverted to current snapshot"
|
return f"VM '{name}' reverted to current snapshot"
|
||||||
|
|
||||||
@ -204,11 +181,7 @@ class SnapshotsMixin(VSphereMixin):
|
|||||||
annotations=ToolAnnotations(destructiveHint=True, idempotentHint=True),
|
annotations=ToolAnnotations(destructiveHint=True, idempotentHint=True),
|
||||||
)
|
)
|
||||||
def delete_snapshot(
|
def delete_snapshot(
|
||||||
self,
|
self, name: str, snapshot_name: str, remove_children: bool = False
|
||||||
name: str,
|
|
||||||
snapshot_name: str,
|
|
||||||
remove_children: bool = False,
|
|
||||||
host: str | None = None,
|
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Delete a VM snapshot.
|
"""Delete a VM snapshot.
|
||||||
|
|
||||||
@ -216,10 +189,8 @@ 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)
|
|
||||||
"""
|
"""
|
||||||
conn = self._conn(host)
|
vm = self.conn.find_vm(name)
|
||||||
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")
|
||||||
|
|
||||||
@ -233,7 +204,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)
|
||||||
conn.wait_for_task(task)
|
self.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:
|
||||||
@ -245,15 +216,9 @@ 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, host: str | None = None) -> str:
|
def delete_all_snapshots(self, name: str) -> 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")
|
||||||
|
|
||||||
@ -261,7 +226,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()
|
||||||
conn.wait_for_task(task)
|
self.conn.wait_for_task(task)
|
||||||
|
|
||||||
return f"All snapshots deleted from VM '{name}'"
|
return f"All snapshots deleted from VM '{name}'"
|
||||||
|
|
||||||
@ -276,19 +241,9 @@ 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")
|
||||||
|
|
||||||
|
|||||||
@ -3,31 +3,19 @@
|
|||||||
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 mcp_tool
|
from fastmcp.contrib.mcp_mixin import MCPMixin, 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:
|
||||||
pass
|
from mcvsphere.connection import VMwareConnection
|
||||||
|
|
||||||
|
|
||||||
class VCenterOpsMixin(VSphereMixin):
|
class VCenterOpsMixin(MCPMixin):
|
||||||
"""vCenter-specific operations (require vCenter, not just ESXi)."""
|
"""vCenter-specific operations (require vCenter, not just ESXi)."""
|
||||||
|
|
||||||
@mcp_tool(
|
def __init__(self, conn: "VMwareConnection"):
|
||||||
name="list_servers",
|
self.conn = conn
|
||||||
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)
|
||||||
@ -43,7 +31,6 @@ 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.
|
||||||
|
|
||||||
@ -54,17 +41,15 @@ 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
|
||||||
"""
|
"""
|
||||||
conn = self._conn(host)
|
vm = self.conn.find_vm(vm_name)
|
||||||
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 = conn.find_datastore(target_datastore)
|
ds = self.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")
|
||||||
|
|
||||||
@ -91,7 +76,7 @@ class VCenterOpsMixin(VSphereMixin):
|
|||||||
|
|
||||||
# Perform the relocation
|
# Perform the relocation
|
||||||
task = vm.RelocateVM_Task(spec=relocate_spec)
|
task = vm.RelocateVM_Task(spec=relocate_spec)
|
||||||
conn.wait_for_task(task)
|
self.conn.wait_for_task(task)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"vm": vm_name,
|
"vm": vm_name,
|
||||||
@ -111,7 +96,6 @@ 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.
|
||||||
|
|
||||||
@ -119,17 +103,15 @@ 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
|
||||||
"""
|
"""
|
||||||
conn = self._conn(host)
|
vm = self.conn.find_vm(vm_name)
|
||||||
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 = conn.find_datastore(target_datastore)
|
ds = self.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")
|
||||||
|
|
||||||
@ -163,7 +145,7 @@ class VCenterOpsMixin(VSphereMixin):
|
|||||||
|
|
||||||
# Perform the relocation
|
# Perform the relocation
|
||||||
task = vm.RelocateVM_Task(spec=relocate_spec)
|
task = vm.RelocateVM_Task(spec=relocate_spec)
|
||||||
conn.wait_for_task(task)
|
self.conn.wait_for_task(task)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"vm": vm_name,
|
"vm": vm_name,
|
||||||
@ -182,7 +164,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, host: str | None = None) -> dict[str, Any]:
|
def convert_to_template(self, vm_name: str) -> 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
|
||||||
@ -190,13 +172,11 @@ 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
|
||||||
"""
|
"""
|
||||||
conn = self._conn(host)
|
vm = self.conn.find_vm(vm_name)
|
||||||
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")
|
||||||
|
|
||||||
@ -227,20 +207,17 @@ 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
|
||||||
"""
|
"""
|
||||||
conn = self._conn(host)
|
vm = self.conn.find_vm(template_name)
|
||||||
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")
|
||||||
|
|
||||||
@ -253,20 +230,20 @@ class VCenterOpsMixin(VSphereMixin):
|
|||||||
|
|
||||||
# Get resource pool
|
# Get resource pool
|
||||||
if resource_pool:
|
if resource_pool:
|
||||||
pool = self._find_resource_pool(resource_pool, conn)
|
pool = self._find_resource_pool(resource_pool)
|
||||||
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 = conn.resource_pool
|
pool = self.conn.resource_pool
|
||||||
|
|
||||||
# Get a host from the resource pool
|
# Get a host from the resource pool
|
||||||
target_host = None
|
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:
|
||||||
target_host = hosts[0]
|
host = hosts[0]
|
||||||
|
|
||||||
vm.MarkAsVirtualMachine(pool=pool, host=target_host)
|
vm.MarkAsVirtualMachine(pool=pool, host=host)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"vm": template_name,
|
"vm": template_name,
|
||||||
@ -274,10 +251,10 @@ class VCenterOpsMixin(VSphereMixin):
|
|||||||
"is_template": False,
|
"is_template": False,
|
||||||
}
|
}
|
||||||
|
|
||||||
def _find_resource_pool(self, name: str, conn) -> vim.ResourcePool | None:
|
def _find_resource_pool(self, name: str) -> vim.ResourcePool | None:
|
||||||
"""Find a resource pool by name."""
|
"""Find a resource pool by name."""
|
||||||
container = conn.content.viewManager.CreateContainerView(
|
container = self.conn.content.viewManager.CreateContainerView(
|
||||||
conn.content.rootFolder, [vim.ResourcePool], True
|
self.conn.content.rootFolder, [vim.ResourcePool], True
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
for pool in container.view:
|
for pool in container.view:
|
||||||
@ -298,7 +275,6 @@ 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.
|
||||||
|
|
||||||
@ -307,13 +283,11 @@ 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
|
||||||
"""
|
"""
|
||||||
conn = self._conn(host)
|
template = self.conn.find_vm(template_name)
|
||||||
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")
|
||||||
|
|
||||||
@ -321,15 +295,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 conn.find_vm(new_vm_name):
|
if self.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 = conn.resource_pool
|
relocate_spec.pool = self.conn.resource_pool
|
||||||
|
|
||||||
if datastore:
|
if datastore:
|
||||||
ds = conn.find_datastore(datastore)
|
ds = self.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
|
||||||
@ -340,14 +314,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 = conn.datacenter.vmFolder
|
folder = self.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)
|
||||||
conn.wait_for_task(task)
|
self.conn.wait_for_task(task)
|
||||||
|
|
||||||
# Get the new VM info
|
# Get the new VM info
|
||||||
new_vm = conn.find_vm(new_vm_name)
|
new_vm = self.conn.find_vm(new_vm_name)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"vm": new_vm_name,
|
"vm": new_vm_name,
|
||||||
@ -366,16 +340,12 @@ 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, host: str | None = None) -> list[dict[str, Any]]:
|
def list_folders(self) -> 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 = ""):
|
||||||
@ -393,7 +363,7 @@ class VCenterOpsMixin(VSphereMixin):
|
|||||||
_collect_folders(child, current_path)
|
_collect_folders(child, current_path)
|
||||||
|
|
||||||
# Start from VM folder
|
# Start from VM folder
|
||||||
vm_folder = conn.datacenter.vmFolder
|
vm_folder = self.conn.datacenter.vmFolder
|
||||||
_collect_folders(vm_folder)
|
_collect_folders(vm_folder)
|
||||||
|
|
||||||
return folders
|
return folders
|
||||||
@ -407,25 +377,22 @@ 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, conn)
|
parent = self._find_folder_by_path(parent_path)
|
||||||
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 = conn.datacenter.vmFolder
|
parent = self.conn.datacenter.vmFolder
|
||||||
|
|
||||||
parent.CreateFolder(name=folder_name)
|
parent.CreateFolder(name=folder_name)
|
||||||
|
|
||||||
@ -436,11 +403,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, conn) -> vim.Folder | None:
|
def _find_folder_by_path(self, path: str) -> 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 = conn.datacenter.vmFolder
|
current = self.conn.datacenter.vmFolder
|
||||||
for part in parts:
|
for part in parts:
|
||||||
found = None
|
found = None
|
||||||
if hasattr(current, "childEntity"):
|
if hasattr(current, "childEntity"):
|
||||||
@ -463,24 +430,21 @@ 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
|
||||||
"""
|
"""
|
||||||
conn = self._conn(host)
|
vm = self.conn.find_vm(vm_name)
|
||||||
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, conn)
|
folder = self._find_folder_by_path(folder_path)
|
||||||
if not folder:
|
if not folder:
|
||||||
raise ValueError(f"Folder '{folder_path}' not found")
|
raise ValueError(f"Folder '{folder_path}' not found")
|
||||||
|
|
||||||
@ -489,7 +453,7 @@ class VCenterOpsMixin(VSphereMixin):
|
|||||||
|
|
||||||
# Move the VM
|
# Move the VM
|
||||||
task = folder.MoveIntoFolder_Task([vm])
|
task = folder.MoveIntoFolder_Task([vm])
|
||||||
conn.wait_for_task(task)
|
self.conn.wait_for_task(task)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"vm": vm_name,
|
"vm": vm_name,
|
||||||
@ -511,20 +475,17 @@ 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
|
||||||
"""
|
"""
|
||||||
conn = self._conn(host)
|
task_manager = self.conn.content.taskManager
|
||||||
task_manager = conn.content.taskManager
|
|
||||||
recent_tasks = task_manager.recentTask
|
recent_tasks = task_manager.recentTask
|
||||||
|
|
||||||
tasks = []
|
tasks = []
|
||||||
@ -570,7 +531,6 @@ 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.
|
||||||
|
|
||||||
@ -578,13 +538,11 @@ 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
|
||||||
"""
|
"""
|
||||||
conn = self._conn(host)
|
event_manager = self.conn.content.eventManager
|
||||||
event_manager = conn.content.eventManager
|
|
||||||
|
|
||||||
# Create filter spec
|
# Create filter spec
|
||||||
filter_spec = vim.event.EventFilterSpec()
|
filter_spec = vim.event.EventFilterSpec()
|
||||||
@ -635,19 +593,15 @@ 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, host: str | None = None) -> list[dict[str, Any]]:
|
def list_clusters(self) -> 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 conn.datacenter.hostFolder.childEntity:
|
for entity in self.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
|
||||||
@ -686,19 +640,16 @@ 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
|
||||||
"""
|
"""
|
||||||
conn = self._conn(host)
|
cluster = self._find_cluster(cluster_name)
|
||||||
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")
|
||||||
|
|
||||||
@ -738,9 +689,9 @@ class VCenterOpsMixin(VSphereMixin):
|
|||||||
|
|
||||||
return recommendations
|
return recommendations
|
||||||
|
|
||||||
def _find_cluster(self, name: str, conn) -> vim.ClusterComputeResource | None:
|
def _find_cluster(self, name: str) -> vim.ClusterComputeResource | None:
|
||||||
"""Find a cluster by name."""
|
"""Find a cluster by name."""
|
||||||
for entity in conn.datacenter.hostFolder.childEntity:
|
for entity in self.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
|
||||||
|
|||||||
@ -2,34 +2,29 @@
|
|||||||
|
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
from fastmcp.contrib.mcp_mixin import mcp_tool
|
from fastmcp.contrib.mcp_mixin import MCPMixin, 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:
|
||||||
pass
|
from mcvsphere.connection import VMwareConnection
|
||||||
|
|
||||||
|
|
||||||
class VMLifecycleMixin(VSphereMixin):
|
class VMLifecycleMixin(MCPMixin):
|
||||||
"""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, host: str | None = None) -> list[dict[str, Any]]:
|
def list_vms(self) -> 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 conn.get_all_vms():
|
for vm in self.conn.get_all_vms():
|
||||||
vms.append(
|
vms.append(
|
||||||
{
|
{
|
||||||
"name": vm.name,
|
"name": vm.name,
|
||||||
@ -46,15 +41,9 @@ class VMLifecycleMixin(VSphereMixin):
|
|||||||
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, host: str | None = None) -> dict[str, Any]:
|
def get_vm_info(self, name: str) -> 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")
|
||||||
|
|
||||||
@ -110,7 +99,7 @@ class VMLifecycleMixin(VSphereMixin):
|
|||||||
|
|
||||||
@mcp_tool(
|
@mcp_tool(
|
||||||
name="create_vm",
|
name="create_vm",
|
||||||
description="Create a new virtual machine with specified resources and a CD/DVD drive (optionally booting from an ISO)",
|
description="Create a new virtual machine with specified resources",
|
||||||
annotations=ToolAnnotations(destructiveHint=False, idempotentHint=False),
|
annotations=ToolAnnotations(destructiveHint=False, idempotentHint=False),
|
||||||
)
|
)
|
||||||
def create_vm(
|
def create_vm(
|
||||||
@ -122,42 +111,19 @@ class VMLifecycleMixin(VSphereMixin):
|
|||||||
datastore: str | None = None,
|
datastore: str | None = None,
|
||||||
network: str | None = None,
|
network: str | None = None,
|
||||||
guest_id: str = "otherGuest64",
|
guest_id: str = "otherGuest64",
|
||||||
iso_path: str | None = None,
|
|
||||||
iso_datastore: str | None = None,
|
|
||||||
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."""
|
||||||
|
|
||||||
The VM always gets a CD/DVD drive. If ``iso_path`` is given, the drive
|
|
||||||
is backed by that ISO and connected at power-on; otherwise it is an
|
|
||||||
empty client device (so ``attach_iso`` can mount media later).
|
|
||||||
|
|
||||||
Args:
|
|
||||||
name: VM name
|
|
||||||
cpu: vCPU count
|
|
||||||
memory_mb: Memory in MB
|
|
||||||
disk_gb: Primary disk size in GB
|
|
||||||
datastore: Datastore for the VM (default: largest available)
|
|
||||||
network: Port group for the NIC (default: configured network)
|
|
||||||
guest_id: vSphere guest OS identifier
|
|
||||||
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)
|
|
||||||
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 = conn.datastore
|
datastore_obj = self.conn.datastore
|
||||||
if datastore:
|
if datastore:
|
||||||
datastore_obj = conn.find_datastore(datastore)
|
datastore_obj = self.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 = conn.network
|
network_obj = self.conn.network
|
||||||
if network:
|
if network:
|
||||||
network_obj = conn.find_network(network)
|
network_obj = self.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")
|
||||||
|
|
||||||
@ -229,47 +195,13 @@ class VMLifecycleMixin(VSphereMixin):
|
|||||||
)
|
)
|
||||||
device_specs.append(nic_spec)
|
device_specs.append(nic_spec)
|
||||||
|
|
||||||
# Add a CD/DVD drive on the default IDE controller (vSphere auto-creates
|
|
||||||
# IDE controller key 200). Backed by an ISO if one was requested,
|
|
||||||
# otherwise an empty client device so media can be mounted later.
|
|
||||||
cdrom_spec = vim.vm.device.VirtualDeviceSpec()
|
|
||||||
cdrom_spec.operation = vim.vm.device.VirtualDeviceSpec.Operation.add
|
|
||||||
cdrom_spec.device = vim.vm.device.VirtualCdrom()
|
|
||||||
cdrom_spec.device.controllerKey = 200
|
|
||||||
cdrom_spec.device.unitNumber = 0
|
|
||||||
cdrom_spec.device.key = -2
|
|
||||||
connectable = vim.vm.device.VirtualDevice.ConnectInfo()
|
|
||||||
connectable.allowGuestControl = True
|
|
||||||
connectable.connected = False
|
|
||||||
|
|
||||||
if iso_path:
|
|
||||||
iso_ds = iso_datastore or datastore_obj.name
|
|
||||||
backing = vim.vm.device.VirtualCdrom.IsoBackingInfo()
|
|
||||||
backing.fileName = f"[{iso_ds}] {iso_path}"
|
|
||||||
connectable.startConnected = True
|
|
||||||
else:
|
|
||||||
backing = vim.vm.device.VirtualCdrom.RemotePassthroughBackingInfo()
|
|
||||||
backing.deviceName = ""
|
|
||||||
backing.exclusive = False
|
|
||||||
connectable.startConnected = False
|
|
||||||
|
|
||||||
cdrom_spec.device.backing = backing
|
|
||||||
cdrom_spec.device.connectable = connectable
|
|
||||||
device_specs.append(cdrom_spec)
|
|
||||||
|
|
||||||
# Boot from the CD/DVD first when installing from an ISO
|
|
||||||
if iso_path and boot_from_iso:
|
|
||||||
vm_spec.bootOptions = vim.vm.BootOptions(
|
|
||||||
bootOrder=[vim.vm.BootOptions.BootableCdromDevice()]
|
|
||||||
)
|
|
||||||
|
|
||||||
vm_spec.deviceChange = device_specs
|
vm_spec.deviceChange = device_specs
|
||||||
|
|
||||||
# Create VM
|
# Create VM
|
||||||
task = conn.datacenter.vmFolder.CreateVM_Task(
|
task = self.conn.datacenter.vmFolder.CreateVM_Task(
|
||||||
config=vm_spec, pool=conn.resource_pool
|
config=vm_spec, pool=self.conn.resource_pool
|
||||||
)
|
)
|
||||||
conn.wait_for_task(task)
|
self.conn.wait_for_task(task)
|
||||||
|
|
||||||
return f"VM '{name}' created successfully"
|
return f"VM '{name}' created successfully"
|
||||||
|
|
||||||
@ -284,33 +216,31 @@ 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."""
|
||||||
conn = self._conn(host)
|
template_vm = self.conn.find_vm(template_name)
|
||||||
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 = conn.datacenter.vmFolder
|
vm_folder = self.conn.datacenter.vmFolder
|
||||||
|
|
||||||
# Resolve datastore
|
# Resolve datastore
|
||||||
datastore_obj = conn.datastore
|
datastore_obj = self.conn.datastore
|
||||||
if datastore:
|
if datastore:
|
||||||
datastore_obj = conn.find_datastore(datastore)
|
datastore_obj = self.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 conn.resource_pool
|
resource_pool = template_vm.resourcePool or self.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)
|
||||||
conn.wait_for_task(task)
|
self.conn.wait_for_task(task)
|
||||||
|
|
||||||
return f"VM '{new_name}' cloned from '{template_name}'"
|
return f"VM '{new_name}' cloned from '{template_name}'"
|
||||||
|
|
||||||
@ -319,20 +249,19 @@ 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, host: str | None = None) -> str:
|
def delete_vm(self, name: str) -> str:
|
||||||
"""Delete a virtual machine permanently."""
|
"""Delete a virtual machine permanently."""
|
||||||
conn = self._conn(host)
|
vm = self.conn.find_vm(name)
|
||||||
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()
|
||||||
conn.wait_for_task(task)
|
self.conn.wait_for_task(task)
|
||||||
|
|
||||||
task = vm.Destroy_Task()
|
task = vm.Destroy_Task()
|
||||||
conn.wait_for_task(task)
|
self.conn.wait_for_task(task)
|
||||||
|
|
||||||
return f"VM '{name}' deleted"
|
return f"VM '{name}' deleted"
|
||||||
|
|
||||||
@ -347,11 +276,9 @@ 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."""
|
||||||
conn = self._conn(host)
|
vm = self.conn.find_vm(name)
|
||||||
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")
|
||||||
|
|
||||||
@ -374,7 +301,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)
|
||||||
conn.wait_for_task(task)
|
self.conn.wait_for_task(task)
|
||||||
|
|
||||||
return f"VM '{name}' reconfigured: {', '.join(changes)}"
|
return f"VM '{name}' reconfigured: {', '.join(changes)}"
|
||||||
|
|
||||||
@ -383,82 +310,13 @@ 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, host: str | None = None) -> str:
|
def rename_vm(self, name: str, new_name: str) -> str:
|
||||||
"""Rename a virtual machine."""
|
"""Rename a virtual machine."""
|
||||||
conn = self._conn(host)
|
vm = self.conn.find_vm(name)
|
||||||
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)
|
||||||
conn.wait_for_task(task)
|
self.conn.wait_for_task(task)
|
||||||
|
|
||||||
return f"VM renamed from '{name}' to '{new_name}'"
|
return f"VM renamed from '{name}' to '{new_name}'"
|
||||||
|
|
||||||
@mcp_tool(
|
|
||||||
name="set_boot_order",
|
|
||||||
description="Set a VM's BIOS boot device order (e.g. ['cdrom','disk'])",
|
|
||||||
annotations=ToolAnnotations(destructiveHint=True, idempotentHint=True),
|
|
||||||
)
|
|
||||||
def set_boot_order(
|
|
||||||
self, name: str, order: list[str], host: str | None = None
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""Set the VM's boot device order.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
name: VM name
|
|
||||||
order: Boot devices in priority order. Each entry is one of:
|
|
||||||
'cdrom' (CD/DVD), 'disk' (hard disk), 'ethernet'/'network' (PXE),
|
|
||||||
or 'floppy'. Devices not listed are tried after those listed.
|
|
||||||
host: Managed ESXi host to target (default: the default host)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dict with the applied boot order
|
|
||||||
"""
|
|
||||||
conn = self._conn(host)
|
|
||||||
vm = conn.find_vm(name)
|
|
||||||
if not vm:
|
|
||||||
raise ValueError(f"VM '{name}' not found")
|
|
||||||
|
|
||||||
disk_key = next(
|
|
||||||
(d.key for d in vm.config.hardware.device
|
|
||||||
if isinstance(d, vim.vm.device.VirtualDisk)),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
nic_key = next(
|
|
||||||
(d.key for d in vm.config.hardware.device
|
|
||||||
if isinstance(d, vim.vm.device.VirtualEthernetCard)),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
|
|
||||||
boot_devices = []
|
|
||||||
for item in order:
|
|
||||||
kind = item.strip().lower()
|
|
||||||
if kind in ("cdrom", "cd", "dvd"):
|
|
||||||
boot_devices.append(vim.vm.BootOptions.BootableCdromDevice())
|
|
||||||
elif kind in ("disk", "hdd", "hd"):
|
|
||||||
if disk_key is None:
|
|
||||||
raise ValueError("VM has no virtual disk to boot from")
|
|
||||||
boot_devices.append(
|
|
||||||
vim.vm.BootOptions.BootableDiskDevice(deviceKey=disk_key)
|
|
||||||
)
|
|
||||||
elif kind in ("ethernet", "nic", "network", "pxe"):
|
|
||||||
if nic_key is None:
|
|
||||||
raise ValueError("VM has no network adapter to boot from")
|
|
||||||
boot_devices.append(
|
|
||||||
vim.vm.BootOptions.BootableEthernetDevice(deviceKey=nic_key)
|
|
||||||
)
|
|
||||||
elif kind in ("floppy", "fd"):
|
|
||||||
boot_devices.append(vim.vm.BootOptions.BootableFloppyDevice())
|
|
||||||
else:
|
|
||||||
raise ValueError(
|
|
||||||
f"Unknown boot device '{item}'. Use cdrom/disk/ethernet/floppy."
|
|
||||||
)
|
|
||||||
|
|
||||||
config_spec = vim.vm.ConfigSpec(
|
|
||||||
bootOptions=vim.vm.BootOptions(bootOrder=boot_devices)
|
|
||||||
)
|
|
||||||
task = vm.ReconfigVM_Task(spec=config_spec)
|
|
||||||
conn.wait_for_task(task)
|
|
||||||
|
|
||||||
return {"vm": name, "boot_order": [d.strip().lower() for d in order]}
|
|
||||||
|
|||||||
@ -120,11 +120,10 @@ TOOL_PERMISSIONS: dict[str, PermissionLevel] = {
|
|||||||
"configure_ntp": PermissionLevel.HOST_ADMIN,
|
"configure_ntp": PermissionLevel.HOST_ADMIN,
|
||||||
"set_service_policy": PermissionLevel.HOST_ADMIN,
|
"set_service_policy": PermissionLevel.HOST_ADMIN,
|
||||||
# ═══════════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════════
|
||||||
# FULL_ADMIN - Everything including guest OS and service control (11 tools)
|
# FULL_ADMIN - Everything including guest OS and service control (10 tools)
|
||||||
# ═══════════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════════
|
||||||
"start_service": PermissionLevel.FULL_ADMIN,
|
"start_service": PermissionLevel.FULL_ADMIN,
|
||||||
"stop_service": PermissionLevel.FULL_ADMIN,
|
"stop_service": PermissionLevel.FULL_ADMIN,
|
||||||
"restart_service": PermissionLevel.FULL_ADMIN,
|
|
||||||
# Guest OS operations (requires guest credentials, high privilege)
|
# Guest OS operations (requires guest credentials, high privilege)
|
||||||
"run_command_in_guest": PermissionLevel.FULL_ADMIN,
|
"run_command_in_guest": PermissionLevel.FULL_ADMIN,
|
||||||
"list_guest_processes": PermissionLevel.FULL_ADMIN,
|
"list_guest_processes": PermissionLevel.FULL_ADMIN,
|
||||||
@ -192,10 +191,9 @@ def get_user_permissions(groups: list[str] | None) -> set[PermissionLevel]:
|
|||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Set of granted permission levels (union of all group permissions).
|
Set of granted permission levels (union of all group permissions).
|
||||||
Returns empty set if no recognized groups (deny all access).
|
|
||||||
"""
|
"""
|
||||||
if not groups:
|
if not groups:
|
||||||
return set() # No groups = no permissions (enforces RBAC)
|
return {PermissionLevel.READ_ONLY}
|
||||||
|
|
||||||
permissions: set[PermissionLevel] = set()
|
permissions: set[PermissionLevel] = set()
|
||||||
|
|
||||||
@ -203,7 +201,10 @@ def get_user_permissions(groups: list[str] | None) -> set[PermissionLevel]:
|
|||||||
if group in GROUP_PERMISSIONS:
|
if group in GROUP_PERMISSIONS:
|
||||||
permissions.update(GROUP_PERMISSIONS[group])
|
permissions.update(GROUP_PERMISSIONS[group])
|
||||||
|
|
||||||
# No fallback - unrecognized groups get no permissions
|
# Default to read-only if no recognized groups
|
||||||
|
if not permissions:
|
||||||
|
permissions.add(PermissionLevel.READ_ONLY)
|
||||||
|
|
||||||
return permissions
|
return permissions
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -8,8 +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_manager import ConnectionManager
|
from mcvsphere.connection import VMwareConnection
|
||||||
from mcvsphere.middleware import RBACMiddleware
|
|
||||||
from mcvsphere.mixins import (
|
from mcvsphere.mixins import (
|
||||||
ConsoleMixin,
|
ConsoleMixin,
|
||||||
DiskManagementMixin,
|
DiskManagementMixin,
|
||||||
@ -25,7 +24,6 @@ from mcvsphere.mixins import (
|
|||||||
VCenterOpsMixin,
|
VCenterOpsMixin,
|
||||||
VMLifecycleMixin,
|
VMLifecycleMixin,
|
||||||
)
|
)
|
||||||
from mcvsphere.servers import ServerConfig, load_servers
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -70,48 +68,25 @@ def create_server(settings: Settings | None = None) -> FastMCP:
|
|||||||
auth=auth,
|
auth=auth,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Add RBAC middleware when OAuth is enabled
|
# Create shared VMware connection
|
||||||
if settings.oauth_enabled:
|
logger.info("Connecting to VMware vCenter/ESXi...")
|
||||||
mcp.add_middleware(RBACMiddleware())
|
conn = VMwareConnection(settings)
|
||||||
logger.info("RBAC middleware enabled - permissions enforced via OAuth groups")
|
|
||||||
|
|
||||||
# Build the managed-server inventory (pluggable source: ESXI_HOST[_N] env
|
|
||||||
# now, an API later). Fall back to the single VCENTER_* server if no
|
|
||||||
# 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(manager),
|
VMLifecycleMixin(conn),
|
||||||
PowerOpsMixin(manager),
|
PowerOpsMixin(conn),
|
||||||
SnapshotsMixin(manager),
|
SnapshotsMixin(conn),
|
||||||
MonitoringMixin(manager),
|
MonitoringMixin(conn),
|
||||||
GuestOpsMixin(manager),
|
GuestOpsMixin(conn),
|
||||||
ResourcesMixin(manager),
|
ResourcesMixin(conn),
|
||||||
DiskManagementMixin(manager),
|
DiskManagementMixin(conn),
|
||||||
NICManagementMixin(manager),
|
NICManagementMixin(conn),
|
||||||
OVFManagementMixin(manager),
|
OVFManagementMixin(conn),
|
||||||
HostManagementMixin(manager),
|
HostManagementMixin(conn),
|
||||||
VCenterOpsMixin(manager),
|
VCenterOpsMixin(conn),
|
||||||
ConsoleMixin(manager),
|
ConsoleMixin(conn),
|
||||||
SerialPortMixin(manager),
|
SerialPortMixin(conn),
|
||||||
]
|
]
|
||||||
|
|
||||||
tool_count = 0
|
tool_count = 0
|
||||||
@ -162,9 +137,8 @@ def run_server(config_path: Path | None = None) -> None:
|
|||||||
)
|
)
|
||||||
if settings.oauth_enabled:
|
if settings.oauth_enabled:
|
||||||
print(f"OAuth: ENABLED via {settings.oauth_issuer_url}", file=sys.stderr)
|
print(f"OAuth: ENABLED via {settings.oauth_issuer_url}", file=sys.stderr)
|
||||||
print("RBAC: ENABLED - permissions enforced via groups", file=sys.stderr)
|
|
||||||
else:
|
else:
|
||||||
print("OAuth: disabled (single-user mode)", file=sys.stderr)
|
print("OAuth: disabled", file=sys.stderr)
|
||||||
print("─" * 40, file=sys.stderr)
|
print("─" * 40, file=sys.stderr)
|
||||||
|
|
||||||
# Create and run server
|
# Create and run server
|
||||||
|
|||||||
@ -1,112 +0,0 @@
|
|||||||
"""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