#
tokens: 12176/50000 2/45 files (page 2/2)
lines: on (toggle) GitHub
raw markdown copy reset
This is page 2 of 2. Use http://codebase.md/rekklesna/proxmoxmcp-plus?lines=true&page={x} to view the full context.

# Directory Structure

```
├── .gitignore
├── docker-compose.yml
├── Dockerfile
├── LICENSE
├── proxmox-config
│   └── config.example.json
├── pyproject.toml
├── README.md
├── requirements-dev.in
├── requirements.in
├── setup.py
├── src
│   └── proxmox_mcp
│       ├── __init__.py
│       ├── config
│       │   ├── __init__.py
│       │   ├── loader.py
│       │   └── models.py
│       ├── core
│       │   ├── __init__.py
│       │   ├── logging.py
│       │   └── proxmox.py
│       ├── formatting
│       │   ├── __init__.py
│       │   ├── colors.py
│       │   ├── components.py
│       │   ├── formatters.py
│       │   ├── templates.py
│       │   └── theme.py
│       ├── server.py
│       ├── tools
│       │   ├── __init__.py
│       │   ├── base.py
│       │   ├── cluster.py
│       │   ├── console
│       │   │   ├── __init__.py
│       │   │   └── manager.py
│       │   ├── containers.py
│       │   ├── definitions.py
│       │   ├── node.py
│       │   ├── storage.py
│       │   └── vm.py
│       └── utils
│           ├── __init__.py
│           ├── auth.py
│           └── logging.py
├── start_openapi.sh
├── start_server.sh
├── test_scripts
│   ├── README.md
│   ├── test_common.py
│   ├── test_create_vm.py
│   ├── test_openapi.py
│   ├── test_vm_power.py
│   └── test_vm_start.py
└── tests
    ├── __init__.py
    ├── test_server.py
    └── test_vm_console.py
```

# Files

--------------------------------------------------------------------------------
/src/proxmox_mcp/tools/vm.py:
--------------------------------------------------------------------------------

```python
  1 | """
  2 | VM-related tools for Proxmox MCP.
  3 | 
  4 | This module provides tools for managing and interacting with Proxmox VMs:
  5 | - Listing all VMs across the cluster with their status
  6 | - Retrieving detailed VM information including:
  7 |   * Resource allocation (CPU, memory)
  8 |   * Runtime status
  9 |   * Node placement
 10 | - Executing commands within VMs via QEMU guest agent
 11 | - Handling VM console operations
 12 | - VM power management (start, stop, shutdown, reset)
 13 | - VM creation with customizable specifications
 14 | 
 15 | The tools implement fallback mechanisms for scenarios where
 16 | detailed VM information might be temporarily unavailable.
 17 | """
 18 | from typing import List, Optional
 19 | from mcp.types import TextContent as Content
 20 | from .base import ProxmoxTool
 21 | from .definitions import GET_VMS_DESC, EXECUTE_VM_COMMAND_DESC
 22 | from .console.manager import VMConsoleManager
 23 | 
 24 | class VMTools(ProxmoxTool):
 25 |     """Tools for managing Proxmox VMs.
 26 |     
 27 |     Provides functionality for:
 28 |     - Retrieving cluster-wide VM information
 29 |     - Getting detailed VM status and configuration
 30 |     - Executing commands within VMs
 31 |     - Managing VM console operations
 32 |     - VM power management (start, stop, shutdown, reset)
 33 |     - VM creation with customizable specifications
 34 |     
 35 |     Implements fallback mechanisms for scenarios where detailed
 36 |     VM information might be temporarily unavailable. Integrates
 37 |     with QEMU guest agent for VM command execution.
 38 |     """
 39 | 
 40 |     def __init__(self, proxmox_api):
 41 |         """Initialize VM tools.
 42 | 
 43 |         Args:
 44 |             proxmox_api: Initialized ProxmoxAPI instance
 45 |         """
 46 |         super().__init__(proxmox_api)
 47 |         self.console_manager = VMConsoleManager(proxmox_api)
 48 | 
 49 |     def get_vms(self) -> List[Content]:
 50 |         """List all virtual machines across the cluster with detailed status.
 51 | 
 52 |         Retrieves comprehensive information for each VM including:
 53 |         - Basic identification (ID, name)
 54 |         - Runtime status (running, stopped)
 55 |         - Resource allocation and usage:
 56 |           * CPU cores
 57 |           * Memory allocation and usage
 58 |         - Node placement
 59 |         
 60 |         Implements a fallback mechanism that returns basic information
 61 |         if detailed configuration retrieval fails for any VM.
 62 | 
 63 |         Returns:
 64 |             List of Content objects containing formatted VM information:
 65 |             {
 66 |                 "vmid": "100",
 67 |                 "name": "vm-name",
 68 |                 "status": "running/stopped",
 69 |                 "node": "node-name",
 70 |                 "cpus": core_count,
 71 |                 "memory": {
 72 |                     "used": bytes,
 73 |                     "total": bytes
 74 |                 }
 75 |             }
 76 | 
 77 |         Raises:
 78 |             RuntimeError: If the cluster-wide VM query fails
 79 |         """
 80 |         try:
 81 |             result = []
 82 |             for node in self.proxmox.nodes.get():
 83 |                 node_name = node["node"]
 84 |                 vms = self.proxmox.nodes(node_name).qemu.get()
 85 |                 for vm in vms:
 86 |                     vmid = vm["vmid"]
 87 |                     # Get VM config for CPU cores
 88 |                     try:
 89 |                         config = self.proxmox.nodes(node_name).qemu(vmid).config.get()
 90 |                         result.append({
 91 |                             "vmid": vmid,
 92 |                             "name": vm["name"],
 93 |                             "status": vm["status"],
 94 |                             "node": node_name,
 95 |                             "cpus": config.get("cores", "N/A"),
 96 |                             "memory": {
 97 |                                 "used": vm.get("mem", 0),
 98 |                                 "total": vm.get("maxmem", 0)
 99 |                             }
100 |                         })
101 |                     except Exception:
102 |                         # Fallback if can't get config
103 |                         result.append({
104 |                             "vmid": vmid,
105 |                             "name": vm["name"],
106 |                             "status": vm["status"],
107 |                             "node": node_name,
108 |                             "cpus": "N/A",
109 |                             "memory": {
110 |                                 "used": vm.get("mem", 0),
111 |                                 "total": vm.get("maxmem", 0)
112 |                             }
113 |                         })
114 |             return self._format_response(result, "vms")
115 |         except Exception as e:
116 |             self._handle_error("get VMs", e)
117 | 
118 |     def create_vm(self, node: str, vmid: str, name: str, cpus: int, memory: int, 
119 |                   disk_size: int, storage: Optional[str] = None, ostype: Optional[str] = None) -> List[Content]:
120 |         """Create a new virtual machine with specified configuration.
121 |         
122 |         Args:
123 |             node: Host node name (e.g., 'pve')
124 |             vmid: New VM ID number (e.g., '200')
125 |             name: VM name (e.g., 'my-new-vm')
126 |             cpus: Number of CPU cores (e.g., 1, 2, 4)
127 |             memory: Memory size in MB (e.g., 2048 for 2GB)
128 |             disk_size: Disk size in GB (e.g., 10, 20, 50)
129 |             storage: Storage name (e.g., 'local-lvm', 'vm-storage'). If None, will auto-detect
130 |             ostype: OS type (e.g., 'l26' for Linux, 'win10' for Windows). Default: 'l26'
131 |             
132 |         Returns:
133 |             List of Content objects containing creation result
134 |             
135 |         Raises:
136 |             ValueError: If VM ID already exists or invalid parameters
137 |             RuntimeError: If VM creation fails
138 |         """
139 |         try:
140 |             # Check if VM ID already exists
141 |             try:
142 |                 existing_vm = self.proxmox.nodes(node).qemu(vmid).config.get()
143 |                 raise ValueError(f"VM {vmid} already exists on node {node}")
144 |             except Exception as e:
145 |                 if "does not exist" not in str(e).lower():
146 |                     raise e
147 |             
148 |             # Get storage information
149 |             storage_list = self.proxmox.nodes(node).storage.get()
150 |             storage_info = {}
151 |             for s in storage_list:
152 |                 storage_info[s["storage"]] = s
153 |             
154 |             # Auto-detect storage if not specified
155 |             if storage is None:
156 |                 # Prefer local-lvm for VM images first
157 |                 for s in storage_list:
158 |                     if s["storage"] == "local-lvm" and "images" in s.get("content", ""):
159 |                         storage = s["storage"]
160 |                         break
161 |                 if storage is None:
162 |                     # Then try vm-storage 
163 |                     for s in storage_list:
164 |                         if s["storage"] == "vm-storage" and "images" in s.get("content", ""):
165 |                             storage = s["storage"]
166 |                             break
167 |                 if storage is None:
168 |                     # Fallback to any storage that supports images
169 |                     for s in storage_list:
170 |                         if "images" in s.get("content", ""):
171 |                             storage = s["storage"]
172 |                             break
173 |                     if storage is None:
174 |                         raise ValueError("No suitable storage found for VM images")
175 |             
176 |             # Validate storage exists and supports images
177 |             if storage not in storage_info:
178 |                 raise ValueError(f"Storage '{storage}' not found on node {node}")
179 |             
180 |             if "images" not in storage_info[storage].get("content", ""):
181 |                 raise ValueError(f"Storage '{storage}' does not support VM images")
182 |             
183 |             # Determine appropriate disk format based on storage type
184 |             storage_type = storage_info[storage]["type"]
185 |             
186 |             if storage_type in ["lvm", "lvmthin"]:
187 |                 # LVM storages use raw format and no cloudinit
188 |                 disk_format = "raw"
189 |                 vm_config_storage = {
190 |                     "scsi0": f"{storage}:{disk_size},format={disk_format}",
191 |                 }
192 |             elif storage_type in ["dir", "nfs", "cifs"]:
193 |                 # File-based storages can use qcow2
194 |                 disk_format = "qcow2"
195 |                 vm_config_storage = {
196 |                     "scsi0": f"{storage}:{disk_size},format={disk_format}",
197 |                     "ide2": f"{storage}:cloudinit",
198 |                 }
199 |             else:
200 |                 # Default to raw for unknown storage types
201 |                 disk_format = "raw"
202 |                 vm_config_storage = {
203 |                     "scsi0": f"{storage}:{disk_size},format={disk_format}",
204 |                 }
205 |             
206 |             # Set default OS type
207 |             if ostype is None:
208 |                 ostype = "l26"  # Linux 2.6+ kernel
209 |             
210 |             # Prepare VM configuration
211 |             vm_config = {
212 |                 "vmid": vmid,
213 |                 "name": name,
214 |                 "cores": cpus,
215 |                 "memory": memory,
216 |                 "ostype": ostype,
217 |                 "scsihw": "virtio-scsi-pci",
218 |                 "boot": "order=scsi0",
219 |                 "agent": "1",  # Enable QEMU guest agent
220 |                 "vga": "std",
221 |                 "net0": "virtio,bridge=vmbr0",
222 |             }
223 |             
224 |             # Add storage configuration
225 |             vm_config.update(vm_config_storage)
226 |             
227 |             # Create the VM
228 |             task_result = self.proxmox.nodes(node).qemu.create(**vm_config)
229 |             
230 |             cloudinit_note = ""
231 |             if storage_type in ["lvm", "lvmthin"]:
232 |                 cloudinit_note = "\n  ⚠️  Note: LVM storage doesn't support cloud-init image"
233 |             
234 |             result_text = f"""🎉 VM {vmid} created successfully!
235 | 
236 | 📋 VM Configuration:
237 |   • Name: {name}
238 |   • Node: {node}
239 |   • VM ID: {vmid}
240 |   • CPU Cores: {cpus}
241 |   • Memory: {memory} MB ({memory/1024:.1f} GB)
242 |   • Disk: {disk_size} GB ({storage}, {disk_format} format)
243 |   • Storage Type: {storage_type}
244 |   • OS Type: {ostype}
245 |   • Network: virtio (bridge=vmbr0)
246 |   • QEMU Agent: Enabled{cloudinit_note}
247 | 
248 | 🔧 Task ID: {task_result}
249 | 
250 | 💡 Next steps:
251 |   1. Upload an ISO to install the operating system
252 |   2. Start the VM using start_vm tool
253 |   3. Access the console to complete OS installation"""
254 |             
255 |             return [Content(type="text", text=result_text)]
256 |             
257 |         except ValueError as e:
258 |             raise e
259 |         except Exception as e:
260 |             self._handle_error(f"create VM {vmid}", e)
261 | 
262 |     def start_vm(self, node: str, vmid: str) -> List[Content]:
263 |         """Start a virtual machine.
264 |         
265 |         Args:
266 |             node: Host node name (e.g., 'pve1', 'proxmox-node2')
267 |             vmid: VM ID number (e.g., '100', '101')
268 |             
269 |         Returns:
270 |             List of Content objects containing operation result
271 |             
272 |         Raises:
273 |             ValueError: If VM is not found
274 |             RuntimeError: If start operation fails
275 |         """
276 |         try:
277 |             # Check if VM exists and get current status
278 |             vm_status = self.proxmox.nodes(node).qemu(vmid).status.current.get()
279 |             current_status = vm_status.get("status")
280 |             
281 |             if current_status == "running":
282 |                 result_text = f"🟢 VM {vmid} is already running"
283 |             else:
284 |                 # Start the VM
285 |                 task_result = self.proxmox.nodes(node).qemu(vmid).status.start.post()
286 |                 result_text = f"🚀 VM {vmid} start initiated successfully\nTask ID: {task_result}"
287 |                 
288 |             return [Content(type="text", text=result_text)]
289 |             
290 |         except Exception as e:
291 |             if "does not exist" in str(e).lower() or "not found" in str(e).lower():
292 |                 raise ValueError(f"VM {vmid} not found on node {node}")
293 |             self._handle_error(f"start VM {vmid}", e)
294 | 
295 |     def stop_vm(self, node: str, vmid: str) -> List[Content]:
296 |         """Stop a virtual machine (force stop).
297 |         
298 |         Args:
299 |             node: Host node name (e.g., 'pve1', 'proxmox-node2') 
300 |             vmid: VM ID number (e.g., '100', '101')
301 |             
302 |         Returns:
303 |             List of Content objects containing operation result
304 |             
305 |         Raises:
306 |             ValueError: If VM is not found
307 |             RuntimeError: If stop operation fails
308 |         """
309 |         try:
310 |             # Check if VM exists and get current status
311 |             vm_status = self.proxmox.nodes(node).qemu(vmid).status.current.get()
312 |             current_status = vm_status.get("status")
313 |             
314 |             if current_status == "stopped":
315 |                 result_text = f"🔴 VM {vmid} is already stopped"
316 |             else:
317 |                 # Stop the VM
318 |                 task_result = self.proxmox.nodes(node).qemu(vmid).status.stop.post()
319 |                 result_text = f"🛑 VM {vmid} stop initiated successfully\nTask ID: {task_result}"
320 |                 
321 |             return [Content(type="text", text=result_text)]
322 |             
323 |         except Exception as e:
324 |             if "does not exist" in str(e).lower() or "not found" in str(e).lower():
325 |                 raise ValueError(f"VM {vmid} not found on node {node}")
326 |             self._handle_error(f"stop VM {vmid}", e)
327 | 
328 |     def shutdown_vm(self, node: str, vmid: str) -> List[Content]:
329 |         """Shutdown a virtual machine gracefully.
330 |         
331 |         Args:
332 |             node: Host node name (e.g., 'pve1', 'proxmox-node2')
333 |             vmid: VM ID number (e.g., '100', '101')
334 |             
335 |         Returns:
336 |             List of Content objects containing operation result
337 |             
338 |         Raises:
339 |             ValueError: If VM is not found
340 |             RuntimeError: If shutdown operation fails
341 |         """
342 |         try:
343 |             # Check if VM exists and get current status
344 |             vm_status = self.proxmox.nodes(node).qemu(vmid).status.current.get()
345 |             current_status = vm_status.get("status")
346 |             
347 |             if current_status == "stopped":
348 |                 result_text = f"🔴 VM {vmid} is already stopped"
349 |             else:
350 |                 # Shutdown the VM gracefully
351 |                 task_result = self.proxmox.nodes(node).qemu(vmid).status.shutdown.post()
352 |                 result_text = f"💤 VM {vmid} graceful shutdown initiated\nTask ID: {task_result}"
353 |                 
354 |             return [Content(type="text", text=result_text)]
355 |             
356 |         except Exception as e:
357 |             if "does not exist" in str(e).lower() or "not found" in str(e).lower():
358 |                 raise ValueError(f"VM {vmid} not found on node {node}")
359 |             self._handle_error(f"shutdown VM {vmid}", e)
360 | 
361 |     def reset_vm(self, node: str, vmid: str) -> List[Content]:
362 |         """Reset (restart) a virtual machine.
363 |         
364 |         Args:
365 |             node: Host node name (e.g., 'pve1', 'proxmox-node2')
366 |             vmid: VM ID number (e.g., '100', '101')
367 |             
368 |         Returns:
369 |             List of Content objects containing operation result
370 |             
371 |         Raises:
372 |             ValueError: If VM is not found
373 |             RuntimeError: If reset operation fails
374 |         """
375 |         try:
376 |             # Check if VM exists and get current status
377 |             vm_status = self.proxmox.nodes(node).qemu(vmid).status.current.get()
378 |             current_status = vm_status.get("status")
379 |             
380 |             if current_status == "stopped":
381 |                 result_text = f"⚠️ Cannot reset VM {vmid}: VM is currently stopped\nUse start_vm to start it first"
382 |             else:
383 |                 # Reset the VM
384 |                 task_result = self.proxmox.nodes(node).qemu(vmid).status.reset.post()
385 |                 result_text = f"🔄 VM {vmid} reset initiated successfully\nTask ID: {task_result}"
386 |                 
387 |             return [Content(type="text", text=result_text)]
388 |             
389 |         except Exception as e:
390 |             if "does not exist" in str(e).lower() or "not found" in str(e).lower():
391 |                 raise ValueError(f"VM {vmid} not found on node {node}")
392 |             self._handle_error(f"reset VM {vmid}", e)
393 | 
394 |     async def execute_command(self, node: str, vmid: str, command: str) -> List[Content]:
395 |         """Execute a command in a VM via QEMU guest agent.
396 | 
397 |         Uses the QEMU guest agent to execute commands within a running VM.
398 |         Requires:
399 |         - VM must be running
400 |         - QEMU guest agent must be installed and running in the VM
401 |         - Command execution permissions must be enabled
402 | 
403 |         Args:
404 |             node: Host node name (e.g., 'pve1', 'proxmox-node2')
405 |             vmid: VM ID number (e.g., '100', '101')
406 |             command: Shell command to run (e.g., 'uname -a', 'systemctl status nginx')
407 | 
408 |         Returns:
409 |             List of Content objects containing formatted command output:
410 |             {
411 |                 "success": true/false,
412 |                 "output": "command output",
413 |                 "error": "error message if any"
414 |             }
415 | 
416 |         Raises:
417 |             ValueError: If VM is not found, not running, or guest agent is not available
418 |             RuntimeError: If command execution fails due to permissions or other issues
419 |         """
420 |         try:
421 |             result = await self.console_manager.execute_command(node, vmid, command)
422 |             # Use the command output formatter from ProxmoxFormatters
423 |             from ..formatting import ProxmoxFormatters
424 |             formatted = ProxmoxFormatters.format_command_output(
425 |                 success=result["success"],
426 |                 command=command,
427 |                 output=result["output"],
428 |                 error=result.get("error")
429 |             )
430 |             return [Content(type="text", text=formatted)]
431 |         except Exception as e:
432 |             self._handle_error(f"execute command on VM {vmid}", e)
433 | 
434 |     def delete_vm(self, node: str, vmid: str, force: bool = False) -> List[Content]:
435 |         """Delete/remove a virtual machine completely.
436 |         
437 |         This will permanently delete the VM and all its associated data including:
438 |         - VM configuration
439 |         - Virtual disks
440 |         - Snapshots
441 |         
442 |         WARNING: This operation cannot be undone!
443 |         
444 |         Args:
445 |             node: Host node name (e.g., 'pve1', 'proxmox-node2')
446 |             vmid: VM ID number (e.g., '100', '101')
447 |             force: Force deletion even if VM is running (will stop first)
448 |             
449 |         Returns:
450 |             List of Content objects containing deletion result
451 |             
452 |         Raises:
453 |             ValueError: If VM is not found or is running and force=False
454 |             RuntimeError: If deletion fails
455 |         """
456 |         try:
457 |             # Check if VM exists and get current status
458 |             try:
459 |                 vm_status = self.proxmox.nodes(node).qemu(vmid).status.current.get()
460 |                 current_status = vm_status.get("status")
461 |                 vm_name = vm_status.get("name", f"VM-{vmid}")
462 |             except Exception as e:
463 |                 if "does not exist" in str(e).lower() or "not found" in str(e).lower():
464 |                     raise ValueError(f"VM {vmid} not found on node {node}")
465 |                 raise e
466 |             
467 |             # Check if VM is running
468 |             if current_status == "running":
469 |                 if not force:
470 |                     raise ValueError(f"VM {vmid} ({vm_name}) is currently running. "
471 |                                    f"Please stop it first or use force=True to stop and delete.")
472 |                 else:
473 |                     # Force stop the VM first
474 |                     self.proxmox.nodes(node).qemu(vmid).status.stop.post()
475 |                     result_text = f"🛑 Stopping VM {vmid} ({vm_name}) before deletion...\n"
476 |             else:
477 |                 result_text = f"🗑️ Deleting VM {vmid} ({vm_name})...\n"
478 |             
479 |             # Delete the VM
480 |             task_result = self.proxmox.nodes(node).qemu(vmid).delete()
481 |             
482 |             result_text += f"""🗑️ VM {vmid} ({vm_name}) deletion initiated successfully!
483 | 
484 | ⚠️ WARNING: This operation will permanently remove:
485 |   • VM configuration
486 |   • All virtual disks
487 |   • All snapshots
488 |   • Cannot be undone!
489 | 
490 | 🔧 Task ID: {task_result}
491 | 
492 | ✅ VM {vmid} ({vm_name}) is being deleted from node {node}"""
493 |             
494 |             return [Content(type="text", text=result_text)]
495 |             
496 |         except ValueError as e:
497 |             raise e
498 |         except Exception as e:
499 |             self._handle_error(f"delete VM {vmid}", e)
500 | 
```

--------------------------------------------------------------------------------
/src/proxmox_mcp/tools/containers.py:
--------------------------------------------------------------------------------

```python
  1 | from typing import List, Dict, Optional, Tuple, Any, Union
  2 | import json
  3 | from mcp.types import TextContent as Content
  4 | from .base import ProxmoxTool
  5 | 
  6 | 
  7 | def _b2h(n: Union[int, float, str]) -> str:
  8 |     """bytes -> human (binary units)."""
  9 |     try:
 10 |         n = float(n)
 11 |     except Exception:
 12 |         return "0.00 B"
 13 |     units = ("B", "KiB", "MiB", "GiB", "TiB", "PiB")
 14 |     i = 0
 15 |     while n >= 1024.0 and i < len(units) - 1:
 16 |         n /= 1024.0
 17 |         i += 1
 18 |     return f"{n:.2f} {units[i]}"
 19 | 
 20 |     # The rest of the helpers were preserved from your original file; no changes needed
 21 | 
 22 | 
 23 | def _get(d: Any, key: str, default: Any = None) -> Any:
 24 |     """dict.get with None guard."""
 25 |     if isinstance(d, dict):
 26 |         return d.get(key, default)
 27 |     return default
 28 | 
 29 | 
 30 | def _as_dict(maybe: Any) -> Dict:
 31 |     """Return dict; unwrap {'data': dict}; else {}."""
 32 |     if isinstance(maybe, dict):
 33 |         data = maybe.get("data")
 34 |         if isinstance(data, dict):
 35 |             return data
 36 |         return maybe
 37 |     return {}
 38 | 
 39 | 
 40 | def _as_list(maybe: Any) -> List:
 41 |     """Return list; unwrap {'data': list}; else []."""
 42 |     if isinstance(maybe, list):
 43 |         return maybe
 44 |     if isinstance(maybe, dict):
 45 |         data = maybe.get("data")
 46 |         if isinstance(data, list):
 47 |             return data
 48 |     return []
 49 | 
 50 | 
 51 | class ContainerTools(ProxmoxTool):
 52 |     """
 53 |     LXC container tools for Proxmox MCP.
 54 | 
 55 |     - Lists containers cluster-wide (or by node)
 56 |     - Live stats via /status/current
 57 |     - Limit fallback via /config (memory MiB, cores/cpulimit)
 58 |     - RRD fallback when live returns zeros
 59 |     - Pretty output rendered here; JSON path is raw & sanitized
 60 |     """
 61 | 
 62 |     # ---------- error / output ----------
 63 |     def _json_fmt(self, data: Any) -> List[Content]:
 64 |         """Return raw JSON string (never touch project formatters)."""
 65 |         return [Content(type="text", text=json.dumps(data, indent=2, sort_keys=True))]
 66 | 
 67 |     def _err(self, action: str, e: Exception) -> List[Content]:
 68 |         if hasattr(self, "handle_error"):
 69 |             return self.handle_error(e, action)  # type: ignore[attr-defined]
 70 |         if hasattr(self, "_handle_error"):
 71 |             return self._handle_error(action, e)  # type: ignore[attr-defined]
 72 |         return [Content(type="text", text=json.dumps({"error": str(e), "action": action}))]
 73 | 
 74 |     # ---------- helpers ----------
 75 |     def _list_ct_pairs(self, node: Optional[str]) -> List[Tuple[str, Dict]]:
 76 |         """Yield (node_name, ct_dict). Coerce odd shapes into dicts with vmid."""
 77 |         out: List[Tuple[str, Dict]] = []
 78 |         if node:
 79 |             raw = self.proxmox.nodes(node).lxc.get()
 80 |             for it in _as_list(raw):
 81 |                 if isinstance(it, dict):
 82 |                     out.append((node, it))
 83 |                 else:
 84 |                     try:
 85 |                         vmid = int(it)
 86 |                         out.append((node, {"vmid": vmid}))
 87 |                     except Exception:
 88 |                         continue
 89 |         else:
 90 |             nodes = _as_list(self.proxmox.nodes.get())
 91 |             for n in nodes:
 92 |                 nname = _get(n, "node")
 93 |                 if not nname:
 94 |                     continue
 95 |                 raw = self.proxmox.nodes(nname).lxc.get()
 96 |                 for it in _as_list(raw):
 97 |                     if isinstance(it, dict):
 98 |                         out.append((nname, it))
 99 |                     else:
100 |                         try:
101 |                             vmid = int(it)
102 |                             out.append((nname, {"vmid": vmid}))
103 |                         except Exception:
104 |                             continue
105 |         return out
106 | 
107 |     def _rrd_last(self, node: str, vmid: int) -> Tuple[Optional[float], Optional[int], Optional[int]]:
108 |         """Return (cpu_pct, mem_bytes, maxmem_bytes) from the most recent RRD sample."""
109 |         try:
110 |             rrd = _as_list(self.proxmox.nodes(node).lxc(vmid).rrddata.get(timeframe="hour", ds="cpu,mem,maxmem"))
111 |             if not rrd or not isinstance(rrd[-1], dict):
112 |                 return None, None, None
113 |             last = rrd[-1]
114 |             # Proxmox RRD cpu is fraction already (0..1). Convert to percent.
115 |             cpu_pct = float(_get(last, "cpu", 0.0) or 0.0) * 100.0
116 |             mem_bytes = int(_get(last, "mem", 0) or 0)
117 |             maxmem_bytes = int(_get(last, "maxmem", 0) or 0)
118 |             return cpu_pct, mem_bytes, maxmem_bytes
119 |         except Exception:
120 |             return None, None, None
121 | 
122 |     def _status_and_config(self, node: str, vmid: int) -> Tuple[Dict, Dict]:
123 |         """Return (status_current_dict, config_dict)."""
124 |         raw_status: Dict = {}
125 |         raw_config: Dict = {}
126 |         try:
127 |             raw_status = _as_dict(self.proxmox.nodes(node).lxc(vmid).status.current.get())
128 |         except Exception:
129 |             raw_status = {}
130 |         try:
131 |             raw_config = _as_dict(self.proxmox.nodes(node).lxc(vmid).config.get())
132 |         except Exception:
133 |             raw_config = {}
134 |         return raw_status, raw_config
135 | 
136 |     def _render_pretty(self, rows: List[Dict]) -> List[Content]:
137 |         lines: List[str] = ["📦 Containers", ""]
138 |         for r in rows:
139 |             name = r.get("name") or f"ct-{r.get('vmid')}"
140 |             vmid = r.get("vmid")
141 |             status = (r.get("status") or "").upper()
142 |             node = r.get("node") or "?"
143 |             cores = r.get("cores")
144 |             cpu_pct = r.get("cpu_pct", 0.0)
145 |             mem_bytes = int(r.get("mem_bytes") or 0)
146 |             maxmem_bytes = int(r.get("maxmem_bytes") or 0)
147 |             mem_pct = r.get("mem_pct")
148 |             unlimited = bool(r.get("unlimited_memory", False))
149 | 
150 |             lines.append(f"📦 {name} (ID: {vmid})")
151 |             lines.append(f"  • Status: {status}")
152 |             lines.append(f"  • Node: {node}")
153 |             lines.append(f"  • CPU: {cpu_pct:.1f}%")
154 |             lines.append(f"  • CPU Cores: {cores if cores is not None else 'N/A'}")
155 | 
156 |             if unlimited:
157 |                 lines.append(f"  • Memory: {_b2h(mem_bytes)} (unlimited)")
158 |             else:
159 |                 if maxmem_bytes > 0:
160 |                     pct_str = f" ({mem_pct:.1f}%)" if isinstance(mem_pct, (int, float)) else ""
161 |                     lines.append(f"  • Memory: {_b2h(mem_bytes)} / {_b2h(maxmem_bytes)}{pct_str}")
162 |                 else:
163 |                     lines.append(f"  • Memory: {_b2h(mem_bytes)} / 0.00 B")
164 |             lines.append("")
165 |         return [Content(type="text", text="\n".join(lines).rstrip())]
166 | 
167 |     # ---------- tool ----------
168 |     def get_containers(
169 |         self,
170 |         node: Optional[str] = None,
171 |         include_stats: bool = True,
172 |         include_raw: bool = False,
173 |         format_style: str = "pretty",
174 |     ) -> List[Content]:
175 |         """
176 |         List containers cluster-wide or by node.
177 | 
178 |         - `include_stats=True` fetches live CPU/mem from /status/current
179 |         - RRD fallback is used if live returns zeros
180 |         - `format_style='json'` returns raw JSON list (sanitized)
181 |         - `format_style='pretty'` renders a human-friendly table
182 |         """
183 |         try:
184 |             pairs = self._list_ct_pairs(node)
185 |             rows: List[Dict] = []
186 | 
187 |             for nname, ct in pairs:
188 |                 vmid_val = _get(ct, "vmid")
189 |                 vmid_int: Optional[int] = None
190 |                 try:
191 |                     if vmid_val is not None:
192 |                         vmid_int = int(vmid_val)
193 |                 except Exception:
194 |                     vmid_int = None
195 | 
196 |                 rec: Dict = {
197 |                     "vmid": str(vmid_val) if vmid_val is not None else None,
198 |                     "name": _get(ct, "name") or _get(ct, "hostname") or (f"ct-{vmid_val}" if vmid_val is not None else "ct-?"),
199 |                     "node": nname,
200 |                     "status": _get(ct, "status"),
201 |                 }
202 | 
203 |                 if include_stats and vmid_int is not None:
204 |                     raw_status, raw_config = self._status_and_config(nname, vmid_int)
205 | 
206 |                     cpu_frac = float(_get(raw_status, "cpu", 0.0) or 0.0)
207 |                     cpu_pct = round(cpu_frac * 100.0, 2)
208 |                     mem_bytes = int(_get(raw_status, "mem", 0) or 0)
209 |                     maxmem_bytes = int(_get(raw_status, "maxmem", 0) or 0)
210 | 
211 |                     memory_mib = 0
212 |                     cores: Optional[Union[int, float]] = None
213 |                     unlimited_memory = False
214 | 
215 |                     try:
216 |                         cfg_mem = _get(raw_config, "memory")
217 |                         if cfg_mem is None:
218 |                             cfg_mem = _get(raw_config, "ram")
219 |                         if cfg_mem is None:
220 |                             cfg_mem = _get(raw_config, "maxmem")
221 |                         if cfg_mem is None:
222 |                             cfg_mem = _get(raw_config, "memoryMiB")
223 |                         if cfg_mem is not None:
224 |                             try:
225 |                                 memory_mib = int(cfg_mem)
226 |                             except Exception:
227 |                                 memory_mib = 0
228 |                         else:
229 |                             memory_mib = 0
230 | 
231 |                         unlimited_memory = bool(_get(raw_config, "swap", 0) == 0 and memory_mib == 0)
232 | 
233 |                         cfg_cores = _get(raw_config, "cores")
234 |                         cfg_cpulimit = _get(raw_config, "cpulimit")
235 |                         if cfg_cores is not None:
236 |                             cores = int(cfg_cores)
237 |                         elif cfg_cpulimit is not None and float(cfg_cpulimit) > 0:
238 |                             cores = float(cfg_cpulimit)
239 |                     except Exception:
240 |                         cores = None
241 | 
242 |                     # --- NEW: fallbacks for stopped / missing maxmem ---
243 |                     status_str = str(_get(raw_status, "status") or _get(ct, "status") or "").lower()
244 |                     
245 |                     if status_str == "stopped":
246 |                         try:
247 |                             mem_bytes = 0
248 |                         except Exception:
249 |                             mem_bytes = 0
250 | 
251 |                     if (not maxmem_bytes or int(maxmem_bytes) == 0) and memory_mib and int(memory_mib) > 0:
252 |                         try:
253 |                             maxmem_bytes = int(memory_mib) * 1024 * 1024
254 |                         except Exception:
255 |                             maxmem_bytes = 0
256 | 
257 |                     # RRD fallback if zeros
258 |                     if (mem_bytes == 0) or (maxmem_bytes == 0) or (cpu_pct == 0.0):
259 |                         rrd_cpu, rrd_mem, rrd_maxmem = self._rrd_last(nname, vmid_int)
260 |                         if cpu_pct == 0.0 and rrd_cpu is not None:
261 |                             cpu_pct = rrd_cpu
262 |                         if mem_bytes == 0 and rrd_mem is not None:
263 |                             mem_bytes = rrd_mem
264 |                         if maxmem_bytes == 0 and rrd_maxmem:
265 |                             maxmem_bytes = rrd_maxmem
266 |                             if memory_mib == 0:
267 |                                 try:
268 |                                     memory_mib = int(round(maxmem_bytes / (1024 * 1024)))
269 |                                 except Exception:
270 |                                     memory_mib = 0
271 | 
272 |                     rec.update({
273 |                         "cores": cores,
274 |                         "memory": memory_mib,
275 |                         "cpu_pct": cpu_pct,
276 |                         "mem_bytes": mem_bytes,
277 |                         "maxmem_bytes": maxmem_bytes,
278 |                         "mem_pct": (
279 |                             round((mem_bytes / maxmem_bytes * 100.0), 2)
280 |                             if (maxmem_bytes and maxmem_bytes > 0)
281 |                             else None
282 |                         ),
283 |                         "unlimited_memory": unlimited_memory,
284 |                     })
285 | 
286 |                     # For PRETTY only: allow raw blobs to be attached if requested.
287 |                     if include_raw and format_style != "json":
288 |                         rec["raw_status"] = raw_status
289 |                         rec["raw_config"] = raw_config
290 | 
291 |                 rows.append(rec)
292 | 
293 |             if format_style == "json":
294 |                 # JSON path must be immune to any formatter assumptions; no raw payloads.
295 |                 return self._json_fmt(rows)
296 |             return self._render_pretty(rows)
297 | 
298 |         except Exception as e:
299 |             return self._err("Failed to list containers", e)
300 | 
301 |     # ---------- target resolution for control ops ----------
302 |     def _resolve_targets(self, selector: str) -> List[Tuple[str, int, str]]:
303 |         """
304 |         Turn a selector string into a list of (node, vmid, label).
305 |         Supports:
306 |           - '123' (vmid across cluster)
307 |           - 'pve1:123' (node:vmid)
308 |           - 'pve1/name' (node/name)
309 |           - 'name' (by name/hostname across the cluster)
310 |           - comma-separated list of any of the above
311 |         """
312 |         if not selector:
313 |             return []
314 |         tokens = [t.strip() for t in selector.split(",") if t.strip()]
315 |         inventory: List[Tuple[str, Dict[str, Any]]] = self._list_ct_pairs(node=None)
316 | 
317 |         resolved: List[Tuple[str, int, str]] = []
318 |         for tok in tokens:
319 |             if ":" in tok and "/" not in tok:
320 |                 node, vmid_s = tok.split(":", 1)
321 |                 try:
322 |                     vmid = int(vmid_s)
323 |                 except Exception:
324 |                     continue
325 |                 for n, ct in inventory:
326 |                     if n == node and int(_get(ct, "vmid", -1)) == vmid:
327 |                         label = _get(ct, "name") or _get(ct, "hostname") or f"ct-{vmid}"
328 |                         resolved.append((node, vmid, label))
329 |                         break
330 |                 continue
331 | 
332 |             if "/" in tok and ":" not in tok:
333 |                 node, name = tok.split("/", 1)
334 |                 name = name.strip()
335 |                 for n, ct in inventory:
336 |                     if n == node and (_get(ct, "name") == name or _get(ct, "hostname") == name):
337 |                         vmid = int(_get(ct, "vmid", -1))
338 |                         if vmid >= 0:
339 |                             resolved.append((node, vmid, name))
340 |                 continue
341 | 
342 |             if tok.isdigit():
343 |                 vmid = int(tok)
344 |                 for n, ct in inventory:
345 |                     if int(_get(ct, "vmid", -1)) == vmid:
346 |                         label = _get(ct, "name") or _get(ct, "hostname") or f"ct-{vmid}"
347 |                         resolved.append((n, vmid, label))
348 |                 continue
349 | 
350 |             name = tok
351 |             for n, ct in inventory:
352 |                 if _get(ct, "name") == name or _get(ct, "hostname") == name:
353 |                     vmid = int(_get(ct, "vmid", -1))
354 |                     if vmid >= 0:
355 |                         resolved.append((n, vmid, name))
356 | 
357 |         uniq = {}
358 |         for n, v, lbl in resolved:
359 |             uniq[(n, v)] = lbl
360 |         return [(n, v, uniq[(n, v)]) for (n, v) in uniq.keys()]
361 | 
362 |     def _render_action_result(self, title: str, results: List[Dict[str, Any]]) -> List[Content]:
363 |         """Pretty-print an action result; JSON stays raw."""
364 |         lines = [f"📦 {title}", ""]
365 |         for r in results:
366 |             status = "✅ OK" if r.get("ok") else "❌ FAIL"
367 |             node = r.get("node")
368 |             vmid = r.get("vmid")
369 |             name = r.get("name") or f"ct-{vmid}"
370 |             msg = r.get("message") or r.get("error") or ""
371 |             lines.append(f"{status} {name} (ID: {vmid}, node: {node}) {('- ' + str(msg)) if msg else ''}")
372 |         return [Content(type="text", text="\n".join(lines).rstrip())]
373 | 
374 |     # ---------- container control tools ----------
375 |     def start_container(self, selector: str, format_style: str = "pretty") -> List[Content]:
376 |         """
377 |         Start LXC containers matching `selector`.
378 |         selector examples: '123', 'pve1:123', 'pve1/name', 'name', 'pve1:101,pve2/web'
379 |         """
380 |         try:
381 |             targets = self._resolve_targets(selector)
382 |             if not targets:
383 |                 return self._err("No containers matched the selector", ValueError(selector))
384 | 
385 |             results: List[Dict[str, Any]] = []
386 |             for node, vmid, label in targets:
387 |                 try:
388 |                     resp = self.proxmox.nodes(node).lxc(vmid).status.start.post()
389 |                     results.append({"ok": True, "node": node, "vmid": vmid, "name": label, "message": resp})
390 |                 except Exception as e:
391 |                     results.append({"ok": False, "node": node, "vmid": vmid, "name": label, "error": str(e)})
392 | 
393 |             if format_style == "json":
394 |                 return self._json_fmt(results)
395 |             return self._render_action_result("Start Containers", results)
396 | 
397 |         except Exception as e:
398 |             return self._err("Failed to start container(s)", e)
399 | 
400 |     def stop_container(self, selector: str, graceful: bool = True, timeout_seconds: int = 10,
401 |                        format_style: str = "pretty") -> List[Content]:
402 |         """
403 |         Stop LXC containers.
404 |         graceful=True → POST .../status/shutdown (graceful stop)
405 |         graceful=False → POST .../status/stop (force stop)
406 |         """
407 |         try:
408 |             targets = self._resolve_targets(selector)
409 |             if not targets:
410 |                 return self._err("No containers matched the selector", ValueError(selector))
411 | 
412 |             results: List[Dict[str, Any]] = []
413 |             for node, vmid, label in targets:
414 |                 try:
415 |                     if graceful:
416 |                         resp = self.proxmox.nodes(node).lxc(vmid).status.shutdown.post(timeout=timeout_seconds)
417 |                     else:
418 |                         resp = self.proxmox.nodes(node).lxc(vmid).status.stop.post()
419 |                     results.append({"ok": True, "node": node, "vmid": vmid, "name": label, "message": resp})
420 |                 except Exception as e:
421 |                     results.append({"ok": False, "node": node, "vmid": vmid, "name": label, "error": str(e)})
422 | 
423 |             if format_style == "json":
424 |                 return self._json_fmt(results)
425 |             return self._render_action_result("Stop Containers", results)
426 | 
427 |         except Exception as e:
428 |             return self._err("Failed to stop container(s)", e)
429 | 
430 |     def restart_container(self, selector: str, timeout_seconds: int = 10,
431 |                           format_style: str = "pretty") -> List[Content]:
432 |         """
433 |         Restart LXC containers via POST .../status/reboot.
434 |         """
435 |         try:
436 |             targets = self._resolve_targets(selector)
437 |             if not targets:
438 |                 return self._err("No containers matched the selector", ValueError(selector))
439 | 
440 |             results: List[Dict[str, Any]] = []
441 |             for node, vmid, label in targets:
442 |                 try:
443 |                     resp = self.proxmox.nodes(node).lxc(vmid).status.reboot.post()
444 |                     results.append({"ok": True, "node": node, "vmid": vmid, "name": label, "message": resp})
445 |                 except Exception as e:
446 |                     results.append({"ok": False, "node": node, "vmid": vmid, "name": label, "error": str(e)})
447 | 
448 |             if format_style == "json":
449 |                 return self._json_fmt(results)
450 |             return self._render_action_result("Restart Containers", results)
451 | 
452 |         except Exception as e:
453 |             return self._err("Failed to restart container(s)", e)
454 | 
455 |     def update_container_resources(
456 |         self,
457 |         selector: str,
458 |         cores: Optional[int] = None,
459 |         memory: Optional[int] = None,
460 |         swap: Optional[int] = None,
461 |         disk_gb: Optional[int] = None,
462 |         disk: str = "rootfs",
463 |         format_style: str = "pretty",
464 |     ) -> List[Content]:
465 |         """Update container CPU/memory/swap limits and/or extend disk size.
466 | 
467 |         Parameters:
468 |             selector: Container selector (same grammar as start_container)
469 |             cores: New CPU core count
470 |             memory: New memory limit in MiB
471 |             swap: New swap limit in MiB
472 |             disk_gb: Additional disk size to add in GiB
473 |             disk: Disk identifier to resize (default 'rootfs')
474 |             format_style: Output format ('pretty' or 'json')
475 |         """
476 | 
477 |         try:
478 |             targets = self._resolve_targets(selector)
479 |             if not targets:
480 |                 return self._err("No containers matched the selector", ValueError(selector))
481 | 
482 |             results: List[Dict[str, Any]] = []
483 |             for node, vmid, label in targets:
484 |                 rec: Dict[str, Any] = {"ok": True, "node": node, "vmid": vmid, "name": label}
485 |                 changes: List[str] = []
486 | 
487 |                 try:
488 |                     update_params: Dict[str, Any] = {}
489 |                     if cores is not None:
490 |                         update_params["cores"] = cores
491 |                         changes.append(f"cores={cores}")
492 |                     if memory is not None:
493 |                         update_params["memory"] = memory
494 |                         changes.append(f"memory={memory}MiB")
495 |                     if swap is not None:
496 |                         update_params["swap"] = swap
497 |                         changes.append(f"swap={swap}MiB")
498 | 
499 |                     if update_params:
500 |                         self.proxmox.nodes(node).lxc(vmid).config.put(**update_params)
501 | 
502 |                     if disk_gb is not None:
503 |                         size_str = f"+{disk_gb}G"
504 |                         # Use PUT for disk resize - some Proxmox versions reject POST
505 |                         self.proxmox.nodes(node).lxc(vmid).resize.put(disk=disk, size=size_str)
506 |                         changes.append(f"{disk}+={disk_gb}G")
507 | 
508 |                     rec["message"] = ", ".join(changes) if changes else "no changes"
509 |                 except Exception as e:
510 |                     rec["ok"] = False
511 |                     rec["error"] = str(e)
512 | 
513 |                 results.append(rec)
514 | 
515 |             if format_style == "json":
516 |                 return self._json_fmt(results)
517 |             return self._render_action_result("Update Container Resources", results)
518 | 
519 |         except Exception as e:
520 |             return self._err("Failed to update container(s)", e)
521 | 
```
Page 2/2FirstPrevNextLast