This commit is contained in:
canvrno
2025-02-18 23:06:04 -07:00
parent 088588b3b3
commit 101fe332de
15 changed files with 548 additions and 220 deletions

View File

View File

@@ -0,0 +1,33 @@
"""
Configuration loading utilities for the Proxmox MCP server.
"""
import json
import os
from typing import Optional
from .models import Config
def load_config(config_path: Optional[str] = None) -> Config:
"""Load configuration from file.
Args:
config_path: Path to the configuration file
Returns:
Config object containing the loaded configuration
Raises:
ValueError: If config path is not provided or config is invalid
"""
if not config_path:
raise ValueError("PROXMOX_MCP_CONFIG environment variable must be set")
try:
with open(config_path) as f:
config_data = json.load(f)
if not config_data.get('proxmox', {}).get('host'):
raise ValueError("Proxmox host cannot be empty")
return Config(**config_data)
except json.JSONDecodeError as e:
raise ValueError(f"Invalid JSON in config file: {e}")
except Exception as e:
raise ValueError(f"Failed to load config: {e}")

View File

@@ -0,0 +1,34 @@
"""
Configuration models for the Proxmox MCP server.
"""
from typing import Optional, Annotated
from pydantic import BaseModel, Field
class NodeStatus(BaseModel):
node: Annotated[str, Field(description="Name/ID of node to query (e.g. 'pve1', 'proxmox-node2')")]
class VMCommand(BaseModel):
node: Annotated[str, Field(description="Host node name (e.g. 'pve1', 'proxmox-node2')")]
vmid: Annotated[str, Field(description="VM ID number (e.g. '100', '101')")]
command: Annotated[str, Field(description="Shell command to run (e.g. 'uname -a', 'systemctl status nginx')")]
class ProxmoxConfig(BaseModel):
host: str
port: int = 8006
verify_ssl: bool = True
service: str = "PVE"
class AuthConfig(BaseModel):
user: str
token_name: str
token_value: str
class LoggingConfig(BaseModel):
level: str = "INFO"
format: str = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
file: Optional[str] = None
class Config(BaseModel):
proxmox: ProxmoxConfig
auth: AuthConfig
logging: LoggingConfig

View File

View File

@@ -0,0 +1,55 @@
"""
Logging configuration for the Proxmox MCP server.
"""
import logging
import os
from typing import Optional
from ..config.models import LoggingConfig
def setup_logging(config: LoggingConfig) -> logging.Logger:
"""Configure logging based on settings.
Args:
config: Logging configuration
Returns:
Configured logger instance
"""
# Convert relative path to absolute
log_file = config.file
if log_file and not os.path.isabs(log_file):
log_file = os.path.join(os.getcwd(), log_file)
# Create handlers
handlers = []
if log_file:
file_handler = logging.FileHandler(log_file)
file_handler.setLevel(getattr(logging, config.level.upper()))
handlers.append(file_handler)
# Console handler for errors only
console_handler = logging.StreamHandler()
console_handler.setLevel(logging.ERROR)
handlers.append(console_handler)
# Configure formatters
formatter = logging.Formatter(config.format)
for handler in handlers:
handler.setFormatter(formatter)
# Configure root logger
root_logger = logging.getLogger()
root_logger.setLevel(getattr(logging, config.level.upper()))
# Remove any existing handlers
for handler in root_logger.handlers[:]:
root_logger.removeHandler(handler)
# Add new handlers
for handler in handlers:
root_logger.addHandler(handler)
# Create and return server logger
logger = logging.getLogger("proxmox-mcp")
return logger

View File

@@ -0,0 +1,71 @@
"""
Proxmox API setup and management.
"""
import logging
from typing import Dict, Any
from proxmoxer import ProxmoxAPI
from ..config.models import ProxmoxConfig, AuthConfig
class ProxmoxManager:
"""Manager class for Proxmox API operations."""
def __init__(self, proxmox_config: ProxmoxConfig, auth_config: AuthConfig):
"""Initialize the Proxmox API manager.
Args:
proxmox_config: Proxmox connection configuration
auth_config: Authentication configuration
"""
self.logger = logging.getLogger("proxmox-mcp.proxmox")
self.config = self._create_config(proxmox_config, auth_config)
self.api = self._setup_api()
def _create_config(self, proxmox_config: ProxmoxConfig, auth_config: AuthConfig) -> Dict[str, Any]:
"""Create a configuration dictionary for ProxmoxAPI.
Args:
proxmox_config: Proxmox connection configuration
auth_config: Authentication configuration
Returns:
Dictionary containing merged configuration
"""
return {
'host': proxmox_config.host,
'port': proxmox_config.port,
'user': auth_config.user,
'token_name': auth_config.token_name,
'token_value': auth_config.token_value,
'verify_ssl': proxmox_config.verify_ssl,
'service': proxmox_config.service
}
def _setup_api(self) -> ProxmoxAPI:
"""Initialize and test Proxmox API connection.
Returns:
Initialized ProxmoxAPI instance
Raises:
RuntimeError: If connection fails
"""
try:
self.logger.info(f"Connecting to Proxmox host: {self.config['host']}")
api = ProxmoxAPI(**self.config)
# Test connection
api.version.get()
self.logger.info("Successfully connected to Proxmox API")
return api
except Exception as e:
self.logger.error(f"Failed to connect to Proxmox: {e}")
raise RuntimeError(f"Failed to connect to Proxmox: {e}")
def get_api(self) -> ProxmoxAPI:
"""Get the initialized Proxmox API instance.
Returns:
ProxmoxAPI instance
"""
return self.api

View File

@@ -1,243 +1,96 @@
#!/usr/bin/env python3 """
import json Main server implementation for Proxmox MCP.
"""
import logging import logging
import os import os
import sys import sys
import signal import signal
from pathlib import Path from typing import Optional, List, Annotated
from typing import Dict, Any, Optional, List, Annotated
from pydantic import BaseModel, Field
from urllib.parse import urljoin
from mcp.server.fastmcp import FastMCP from mcp.server.fastmcp import FastMCP
from mcp.server.fastmcp.tools import Tool from mcp.server.fastmcp.tools import Tool
from mcp.server.fastmcp.tools.base import Tool as BaseTool from mcp.types import TextContent as Content
from mcp.types import CallToolResult as Response, TextContent as Content from pydantic import Field
from proxmoxer import ProxmoxAPI
# Import from the same directory from .config.loader import load_config
sys.path.append(os.path.dirname(os.path.abspath(__file__))) from .core.logging import setup_logging
from tools.vm_console import VMConsoleManager from .core.proxmox import ProxmoxManager
from .tools.node import NodeTools
class NodeStatus(BaseModel): from .tools.vm import VMTools
node: Annotated[str, Field(description="Name/ID of node to query (e.g. 'pve1', 'proxmox-node2')")] from .tools.storage import StorageTools
from .tools.cluster import ClusterTools
class VMCommand(BaseModel): from .tools.definitions import (
node: Annotated[str, Field(description="Host node name (e.g. 'pve1', 'proxmox-node2')")] GET_NODES_DESC,
vmid: Annotated[str, Field(description="VM ID number (e.g. '100', '101')")] GET_NODE_STATUS_DESC,
command: Annotated[str, Field(description="Shell command to run (e.g. 'uname -a', 'systemctl status nginx')")] GET_VMS_DESC,
EXECUTE_VM_COMMAND_DESC,
class ProxmoxConfig(BaseModel): GET_CONTAINERS_DESC,
host: str GET_STORAGE_DESC,
port: int = 8006 GET_CLUSTER_STATUS_DESC
verify_ssl: bool = True )
service: str = "PVE"
class AuthConfig(BaseModel):
user: str
token_name: str
token_value: str
class LoggingConfig(BaseModel):
level: str = "INFO"
format: str = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
file: Optional[str] = None
class Config(BaseModel):
proxmox: ProxmoxConfig
auth: AuthConfig
logging: LoggingConfig
class ProxmoxMCPServer: class ProxmoxMCPServer:
"""Main server class for Proxmox MCP."""
def __init__(self, config_path: Optional[str] = None): def __init__(self, config_path: Optional[str] = None):
self.config = self._load_config(config_path) """Initialize the server.
self._setup_logging()
self.proxmox = self._setup_proxmox() Args:
self.vm_console = VMConsoleManager(self.proxmox) config_path: Path to configuration file
"""
self.config = load_config(config_path)
self.logger = setup_logging(self.config.logging)
# Initialize core components
self.proxmox_manager = ProxmoxManager(self.config.proxmox, self.config.auth)
self.proxmox = self.proxmox_manager.get_api()
# Initialize tools
self.node_tools = NodeTools(self.proxmox)
self.vm_tools = VMTools(self.proxmox)
self.storage_tools = StorageTools(self.proxmox)
self.cluster_tools = ClusterTools(self.proxmox)
# Initialize MCP server
self.mcp = FastMCP("ProxmoxMCP") self.mcp = FastMCP("ProxmoxMCP")
self._setup_tools() self._setup_tools()
def _load_config(self, config_path: Optional[str]) -> Config:
"""Load configuration from file or environment variables."""
if not config_path:
raise ValueError("PROXMOX_MCP_CONFIG environment variable must be set")
try:
with open(config_path) as f:
config_data = json.load(f)
if not config_data.get('proxmox', {}).get('host'):
raise ValueError("Proxmox host cannot be empty")
return Config(**config_data)
except json.JSONDecodeError as e:
raise ValueError(f"Invalid JSON in config file: {e}")
except Exception as e:
raise ValueError(f"Failed to load config: {e}")
def _setup_logging(self) -> None:
"""Configure logging based on settings."""
# Convert relative path to absolute
log_file = self.config.logging.file
if log_file and not os.path.isabs(log_file):
log_file = os.path.join(os.getcwd(), log_file)
# Create handlers
file_handler = logging.FileHandler(log_file)
file_handler.setLevel(getattr(logging, self.config.logging.level.upper()))
# Console handler for errors only
console_handler = logging.StreamHandler()
console_handler.setLevel(logging.ERROR)
# Configure formatters
formatter = logging.Formatter(self.config.logging.format)
file_handler.setFormatter(formatter)
console_handler.setFormatter(formatter)
# Configure root logger
root_logger = logging.getLogger()
root_logger.setLevel(getattr(logging, self.config.logging.level.upper()))
root_logger.addHandler(file_handler)
root_logger.addHandler(console_handler)
self.logger = logging.getLogger("proxmox-mcp")
def _setup_proxmox(self) -> ProxmoxAPI:
"""Initialize Proxmox API connection."""
try:
self.logger.info(f"Connecting to Proxmox with config: {self.config.proxmox}")
# Store the working configuration for reuse
self.proxmox_config = {
'host': self.config.proxmox.host,
'user': self.config.auth.user,
'token_name': self.config.auth.token_name,
'token_value': self.config.auth.token_value,
'verify_ssl': self.config.proxmox.verify_ssl,
'service': 'PVE'
}
# Create and test CustomProxmoxAPI instance
api = ProxmoxAPI(**self.proxmox_config)
api.version.get() # Test connection
return api
except Exception as e:
self.logger.error(f"Failed to connect to Proxmox: {e}")
raise
def _setup_tools(self) -> None: def _setup_tools(self) -> None:
"""Register MCP tools.""" """Register MCP tools."""
# Node tools
@self.mcp.tool(description=GET_NODES_DESC)
def get_nodes():
return self.node_tools.get_nodes()
@self.mcp.tool( @self.mcp.tool(description=GET_NODE_STATUS_DESC)
description="List all nodes in the Proxmox cluster with their status, CPU, memory, and role information.\n\n" def get_node_status(
"Example:\n" node: Annotated[str, Field(description="Name/ID of node to query (e.g. 'pve1', 'proxmox-node2')")]
'{"node": "pve1", "status": "online", "cpu_usage": 0.15, "memory": {"used": "8GB", "total": "32GB"}}') ):
def get_nodes() -> List[Content]: return self.node_tools.get_node_status(node)
try:
result = self.proxmox.nodes.get()
nodes = [{"node": node["node"], "status": node["status"]} for node in result]
return [Content(type="text", text=json.dumps(nodes))]
except Exception as e:
self.logger.error(f"Failed to get nodes: {e}")
raise
@self.mcp.tool( # VM tools
description="Get detailed status information for a specific Proxmox node.\n\n" @self.mcp.tool(description=GET_VMS_DESC)
"Parameters:\n" def get_vms():
"node* - Name/ID of node to query (e.g. 'pve1')\n\n" return self.vm_tools.get_vms()
"Example:\n"
'{"cpu": {"usage": 0.15}, "memory": {"used": "8GB", "total": "32GB"}}')
def get_node_status(node: Annotated[str, Field(description="Name/ID of node to query (e.g. 'pve1')")]) -> List[Content]:
try:
result = self.proxmox.nodes(node).status.get()
return [Content(type="text", text=json.dumps(result))]
except Exception as e:
self.logger.error(f"Failed to get node status: {e}")
raise
@self.mcp.tool( @self.mcp.tool(description=EXECUTE_VM_COMMAND_DESC)
description="List all virtual machines across the cluster with their status and resource usage.\n\n"
"Example:\n"
'{"vmid": "100", "name": "ubuntu", "status": "running", "cpu": 2, "memory": 4096}')
def get_vms() -> List[Content]:
try:
result = []
for node in self.proxmox.nodes.get():
vms = self.proxmox.nodes(node["node"]).qemu.get()
result.extend([{
"vmid": vm["vmid"],
"name": vm["name"],
"status": vm["status"],
"node": node["node"]
} for vm in vms])
return [Content(type="text", text=json.dumps(result))]
except Exception as e:
self.logger.error(f"Failed to get VMs: {e}")
raise
@self.mcp.tool(
description="List all LXC containers across the cluster with their status and configuration.\n\n"
"Example:\n"
'{"vmid": "200", "name": "nginx", "status": "running", "template": "ubuntu-20.04"}')
def get_containers() -> List[Content]:
try:
result = []
for node in self.proxmox.nodes.get():
containers = self.proxmox.nodes(node["node"]).lxc.get()
result.extend([{
"vmid": container["vmid"],
"name": container["name"],
"status": container["status"],
"node": node["node"]
} for container in containers])
return [Content(type="text", text=json.dumps(result))]
except Exception as e:
self.logger.error(f"Failed to get containers: {e}")
raise
@self.mcp.tool(
description="List storage pools across the cluster with their usage and configuration.\n\n"
"Example:\n"
'{"storage": "local-lvm", "type": "lvm", "used": "500GB", "total": "1TB"}')
def get_storage() -> List[Content]:
try:
result = self.proxmox.storage.get()
storage = [{"storage": storage["storage"], "type": storage["type"]} for storage in result]
return [Content(type="text", text=json.dumps(storage))]
except Exception as e:
self.logger.error(f"Failed to get storage: {e}")
raise
@self.mcp.tool(
description="Get overall Proxmox cluster health and configuration status.\n\n"
"Example:\n"
'{"name": "proxmox", "quorum": "ok", "nodes": 3, "ha_status": "active"}')
def get_cluster_status() -> List[Content]:
try:
result = self.proxmox.cluster.status.get()
return [Content(type="text", text=json.dumps(result))]
except Exception as e:
self.logger.error(f"Failed to get cluster status: {e}")
raise
@self.mcp.tool(
description="Execute commands in a VM via QEMU guest agent.\n\n"
"Parameters:\n"
"node* - Host node name (e.g. 'pve1')\n"
"vmid* - VM ID number (e.g. '100')\n"
"command* - Shell command to run (e.g. 'uname -a')\n\n"
"Example:\n"
'{"success": true, "output": "Linux vm1 5.4.0", "exit_code": 0}')
async def execute_vm_command( async def execute_vm_command(
node: Annotated[str, Field(description="Host node name (e.g. 'pve1')")], node: Annotated[str, Field(description="Host node name (e.g. 'pve1', 'proxmox-node2')")],
vmid: Annotated[str, Field(description="VM ID number (e.g. '100')")], vmid: Annotated[str, Field(description="VM ID number (e.g. '100', '101')")],
command: Annotated[str, Field(description="Shell command to run (e.g. 'uname -a')")] command: Annotated[str, Field(description="Shell command to run (e.g. 'uname -a', 'systemctl status nginx')")]
) -> List[Content]: ):
try: return await self.vm_tools.execute_command(node, vmid, command)
result = await self.vm_console.execute_command(node, vmid, command)
return [Content(type="text", text=json.dumps(result))] # Storage tools
except Exception as e: @self.mcp.tool(description=GET_STORAGE_DESC)
self.logger.error(f"Failed to execute VM command: {e}") def get_storage():
raise return self.storage_tools.get_storage()
# Cluster tools
@self.mcp.tool(description=GET_CLUSTER_STATUS_DESC)
def get_cluster_status():
return self.cluster_tools.get_cluster_status()
def start(self) -> None: def start(self) -> None:
"""Start the MCP server.""" """Start the MCP server."""

View File

@@ -0,0 +1,54 @@
"""
Base classes and utilities for Proxmox MCP tools.
"""
import logging
from typing import Any, Dict, List, Optional
from mcp.types import TextContent as Content
from proxmoxer import ProxmoxAPI
class ProxmoxTool:
"""Base class for Proxmox MCP tools."""
def __init__(self, proxmox_api: ProxmoxAPI):
"""Initialize the tool.
Args:
proxmox_api: Initialized ProxmoxAPI instance
"""
self.proxmox = proxmox_api
self.logger = logging.getLogger(f"proxmox-mcp.{self.__class__.__name__.lower()}")
def _format_response(self, data: Any) -> List[Content]:
"""Format response data into MCP content.
Args:
data: Data to format
Returns:
List of Content objects
"""
import json
return [Content(type="text", text=json.dumps(data, indent=2))]
def _handle_error(self, operation: str, error: Exception) -> None:
"""Handle and log errors.
Args:
operation: Description of the operation that failed
error: The exception that occurred
Raises:
ValueError: For invalid input or state
RuntimeError: For other errors
"""
error_msg = str(error)
self.logger.error(f"Failed to {operation}: {error_msg}")
if "not found" in error_msg.lower():
raise ValueError(f"Resource not found: {error_msg}")
if "permission denied" in error_msg.lower():
raise ValueError(f"Permission denied: {error_msg}")
if "invalid" in error_msg.lower():
raise ValueError(f"Invalid input: {error_msg}")
raise RuntimeError(f"Failed to {operation}: {error_msg}")

View File

@@ -0,0 +1,31 @@
"""
Cluster-related tools for Proxmox MCP.
"""
from typing import List
from mcp.types import TextContent as Content
from .base import ProxmoxTool
from .definitions import GET_CLUSTER_STATUS_DESC
class ClusterTools(ProxmoxTool):
"""Tools for managing Proxmox cluster."""
def get_cluster_status(self) -> List[Content]:
"""Get overall Proxmox cluster health and configuration status.
Returns:
List of Content objects containing cluster status
Raises:
RuntimeError: If the operation fails
"""
try:
result = self.proxmox.cluster.status.get()
status = {
"name": result[0].get("name") if result else None,
"quorum": result[0].get("quorate"),
"nodes": len([node for node in result if node.get("type") == "node"]),
"resources": [res for res in result if res.get("type") == "resource"]
}
return self._format_response(status)
except Exception as e:
self._handle_error("get cluster status", e)

View File

@@ -0,0 +1,6 @@
"""
Console management package for Proxmox MCP.
"""
from .manager import VMConsoleManager
__all__ = ['VMConsoleManager']

View File

@@ -0,0 +1,51 @@
"""
Tool descriptions for Proxmox MCP tools.
"""
# Node tool descriptions
GET_NODES_DESC = """List all nodes in the Proxmox cluster with their status, CPU, memory, and role information.
Example:
{"node": "pve1", "status": "online", "cpu_usage": 0.15, "memory": {"used": "8GB", "total": "32GB"}}"""
GET_NODE_STATUS_DESC = """Get detailed status information for a specific Proxmox node.
Parameters:
node* - Name/ID of node to query (e.g. 'pve1')
Example:
{"cpu": {"usage": 0.15}, "memory": {"used": "8GB", "total": "32GB"}}"""
# VM tool descriptions
GET_VMS_DESC = """List all virtual machines across the cluster with their status and resource usage.
Example:
{"vmid": "100", "name": "ubuntu", "status": "running", "cpu": 2, "memory": 4096}"""
EXECUTE_VM_COMMAND_DESC = """Execute commands in a VM via QEMU guest agent.
Parameters:
node* - Host node name (e.g. 'pve1')
vmid* - VM ID number (e.g. '100')
command* - Shell command to run (e.g. 'uname -a')
Example:
{"success": true, "output": "Linux vm1 5.4.0", "exit_code": 0}"""
# Container tool descriptions
GET_CONTAINERS_DESC = """List all LXC containers across the cluster with their status and configuration.
Example:
{"vmid": "200", "name": "nginx", "status": "running", "template": "ubuntu-20.04"}"""
# Storage tool descriptions
GET_STORAGE_DESC = """List storage pools across the cluster with their usage and configuration.
Example:
{"storage": "local-lvm", "type": "lvm", "used": "500GB", "total": "1TB"}"""
# Cluster tool descriptions
GET_CLUSTER_STATUS_DESC = """Get overall Proxmox cluster health and configuration status.
Example:
{"name": "proxmox", "quorum": "ok", "nodes": 3, "ha_status": "active"}"""

View File

@@ -0,0 +1,45 @@
"""
Node-related tools for Proxmox MCP.
"""
from typing import List
from mcp.types import TextContent as Content
from .base import ProxmoxTool
from .definitions import GET_NODES_DESC, GET_NODE_STATUS_DESC
class NodeTools(ProxmoxTool):
"""Tools for managing Proxmox nodes."""
def get_nodes(self) -> List[Content]:
"""List all nodes in the Proxmox cluster.
Returns:
List of Content objects containing node information
Raises:
RuntimeError: If the operation fails
"""
try:
result = self.proxmox.nodes.get()
nodes = [{"node": node["node"], "status": node["status"]} for node in result]
return self._format_response(nodes)
except Exception as e:
self._handle_error("get nodes", e)
def get_node_status(self, node: str) -> List[Content]:
"""Get detailed status information for a specific node.
Args:
node: Name/ID of node to query
Returns:
List of Content objects containing node status
Raises:
ValueError: If node is not found
RuntimeError: If the operation fails
"""
try:
result = self.proxmox.nodes(node).status.get()
return self._format_response(result)
except Exception as e:
self._handle_error(f"get status for node {node}", e)

View File

@@ -0,0 +1,31 @@
"""
Storage-related tools for Proxmox MCP.
"""
from typing import List
from mcp.types import TextContent as Content
from .base import ProxmoxTool
from .definitions import GET_STORAGE_DESC
class StorageTools(ProxmoxTool):
"""Tools for managing Proxmox storage."""
def get_storage(self) -> List[Content]:
"""List storage pools across the cluster.
Returns:
List of Content objects containing storage information
Raises:
RuntimeError: If the operation fails
"""
try:
result = self.proxmox.storage.get()
storage = [{
"storage": storage["storage"],
"type": storage["type"],
"content": storage.get("content", []),
"enabled": storage.get("enabled", True)
} for storage in result]
return self._format_response(storage)
except Exception as e:
self._handle_error("get storage", e)

View File

@@ -0,0 +1,64 @@
"""
VM-related tools for Proxmox MCP.
"""
from typing import List
from mcp.types import TextContent as Content
from .base import ProxmoxTool
from .definitions import GET_VMS_DESC, EXECUTE_VM_COMMAND_DESC
from .console.manager import VMConsoleManager
class VMTools(ProxmoxTool):
"""Tools for managing Proxmox VMs."""
def __init__(self, proxmox_api):
"""Initialize VM tools.
Args:
proxmox_api: Initialized ProxmoxAPI instance
"""
super().__init__(proxmox_api)
self.console_manager = VMConsoleManager(proxmox_api)
def get_vms(self) -> List[Content]:
"""List all virtual machines across the cluster.
Returns:
List of Content objects containing VM information
Raises:
RuntimeError: If the operation fails
"""
try:
result = []
for node in self.proxmox.nodes.get():
vms = self.proxmox.nodes(node["node"]).qemu.get()
result.extend([{
"vmid": vm["vmid"],
"name": vm["name"],
"status": vm["status"],
"node": node["node"]
} for vm in vms])
return self._format_response(result)
except Exception as e:
self._handle_error("get VMs", e)
async def execute_command(self, node: str, vmid: str, command: str) -> List[Content]:
"""Execute a command in a VM via QEMU guest agent.
Args:
node: Host node name
vmid: VM ID number
command: Shell command to run
Returns:
List of Content objects containing command output
Raises:
ValueError: If VM is not found or not running
RuntimeError: If command execution fails
"""
try:
result = await self.console_manager.execute_command(node, vmid, command)
return self._format_response(result)
except Exception as e:
self._handle_error(f"execute command on VM {vmid}", e)