Обновить proxmox_tools_pve1.py

This commit is contained in:
2026-07-24 02:04:37 +03:00
parent e99c9c8759
commit 2d4db5b84f
+360 -164
View File
@@ -1,56 +1,86 @@
#!/usr/bin/env python3
""" """
proxmox_tools_pve1.py - Proxmox API Tools (PVE1) title: Proxmox Tools (PVE1)
Description: Full toolset for Proxmox VE management (second server): author: (your name)
- LXC (create, start, stop, delete) description: Инструменты для управления Proxmox VE (сервер PVE1): LXC (создание, старт, стоп, удаление), VM (клонирование из шаблона, создание из ISO, управление), мониторинг (ноды, хранилище, ресурсы, задачи).
- VM (clone from template, create from ISO, manage) version: 1.0.0
- Monitoring (nodes, storage, resources, tasks)
Usage: Import and call functions directly.
import proxmox_tools_pve1 as pve1
print(pve1.pve1_connection())
""" """
import json
import re
import time import time
import requests import requests
import urllib3
# ============================================================ from pydantic import BaseModel, Field
# CONFIG - PVE1 (192.168.31.4) from typing import Optional
# ============================================================
PROXMOX_HOST = "https://192.168.31.4:8006"
PROXMOX_USER = "root@pam"
PROXMOX_TOKEN_NAME = "open-webui"
PROXMOX_TOKEN_VALUE = "be44caad-e658-4929-9b24-c12b21495760"
VERIFY_SSL = False
DEFAULT_NODE = "pve1"
DEFAULT_STORAGE = "local"
DEFAULT_BRIDGE = "vmbr0"
_base_url = PROXMOX_HOST.rstrip("/") + "/api2/json"
_auth_header = {
"Authorization": f"PVEAPIToken={PROXMOX_USER}!{PROXMOX_TOKEN_NAME}={PROXMOX_TOKEN_VALUE}"
}
def _api(method, path, params=None, data=None): class Tools:
class Valves(BaseModel):
proxmox_host: str = Field(
default="https://192.168.31.4:8006",
description="Base URL of the Proxmox API (e.g. https://192.168.31.4:8006)",
)
proxmox_user: str = Field(
default="root@pam", description="Proxmox API user (e.g. root@pam)"
)
proxmox_token_name: str = Field(
default="open-webui", description="Proxmox API token name/id"
)
proxmox_token_value: str = Field(
default="",
description="Proxmox API token secret (set this in the Valves UI, never commit it to code)",
)
verify_ssl: bool = Field(
default=False, description="Verify TLS certificate of the Proxmox host"
)
default_node: str = Field(
default="pve1", description="Default Proxmox node name"
)
default_storage: str = Field(
default="local", description="Default storage for LXC rootfs / templates"
)
default_bridge: str = Field(
default="vmbr0", description="Default network bridge"
)
request_timeout: int = Field(
default=30, description="HTTP request timeout in seconds"
)
def __init__(self):
self.valves = self.Valves()
# Silence "InsecureRequestWarning" spam when verify_ssl is False
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
# ============================================================
# INTERNAL HELPERS (not exposed to the LLM)
# ============================================================
def _base_url(self) -> str:
return self.valves.proxmox_host.rstrip("/") + "/api2/json"
def _auth_header(self) -> dict:
return {
"Authorization": (
f"PVEAPIToken={self.valves.proxmox_user}!"
f"{self.valves.proxmox_token_name}={self.valves.proxmox_token_value}"
)
}
def _api(self, method: str, path: str, params: Optional[dict] = None, data: Optional[dict] = None):
"""Execute a Proxmox API request.""" """Execute a Proxmox API request."""
url = _base_url + path url = self._base_url() + path
resp = requests.request( resp = requests.request(
method=method, method=method,
url=url, url=url,
headers=_auth_header, headers=self._auth_header(),
params=params, params=params,
json=data, json=data,
verify=VERIFY_SSL, verify=self.valves.verify_ssl,
timeout=30, timeout=self.valves.request_timeout,
) )
resp.raise_for_status() resp.raise_for_status()
return resp.json().get("data", {}) return resp.json().get("data", {})
@staticmethod
def _fmt(b): def _fmt(b) -> str:
"""Format bytes to human-readable.""" """Format bytes to human-readable."""
if b is None: if b is None:
return "N/A" return "N/A"
@@ -61,15 +91,15 @@ def _fmt(b):
b /= 1024 b /= 1024
return f"{b:.1f} PB" return f"{b:.1f} PB"
@classmethod
def _pct(u, t): def _pct(cls, u, t) -> str:
"""Format used/total as '1.2 GB / 4.0 GB (30.0%)'.""" """Format used/total as '1.2 GB / 4.0 GB (30.0%)'."""
if u is None or t is None or t == 0: if u is None or t is None or t == 0:
return "N/A" return "N/A"
return f"{_fmt(u)} / {_fmt(t)} ({u/t*100:.1f}%)" return f"{cls._fmt(u)} / {cls._fmt(t)} ({u/t*100:.1f}%)"
@staticmethod
def _uptime(s): def _uptime(s) -> str:
"""Format uptime seconds to '1d 2h 3m 4s'.""" """Format uptime seconds to '1d 2h 3m 4s'."""
if s is None: if s is None:
return "N/A" return "N/A"
@@ -86,16 +116,14 @@ def _uptime(s):
parts.append(f"{s2}s") parts.append(f"{s2}s")
return " ".join(parts) return " ".join(parts)
def _wait_for_task(self, upid: str, node: str, timeout: int = 60):
def _wait_for_task(upid, timeout=60): """Wait for a Proxmox task to complete by UPID, on the given node."""
"""Wait for a Proxmox task to complete by UPID."""
if not upid or not isinstance(upid, str) or not upid.startswith("UPID:"): if not upid or not isinstance(upid, str) or not upid.startswith("UPID:"):
return True, "no UPID to wait" return True, "no UPID to wait"
node = DEFAULT_NODE
start = time.time() start = time.time()
while time.time() - start < timeout: while time.time() - start < timeout:
try: try:
tasks = _api("GET", f"/nodes/{node}/tasks") tasks = self._api("GET", f"/nodes/{node}/tasks")
for t in tasks: for t in tasks:
if t.get("upid") == upid: if t.get("upid") == upid:
status = t.get("status", "") status = t.get("status", "")
@@ -107,38 +135,44 @@ def _wait_for_task(upid, timeout=60):
time.sleep(2) time.sleep(2)
return False, "timeout waiting for task" return False, "timeout waiting for task"
# ============================================================
# TOOLS (exposed to the LLM)
# ============================================================
# ============================================================ def pve1_connection(self) -> str:
# FUNCTIONS """Check the connection to the Proxmox VE (PVE1) API and report its version."""
# ============================================================
def pve1_connection():
"""Check Proxmox connection."""
try: try:
v = _api("GET", "/version") v = self._api("GET", "/version")
return "OK Proxmox VE " + v.get("version", "?") + " | node: pve1 | IP: 192.168.31.4" return (
"OK Proxmox VE "
+ v.get("version", "?")
+ f" | node: {self.valves.default_node} | host: {self.valves.proxmox_host}"
)
except Exception as e: except Exception as e:
return "ERR " + str(e) return "ERR " + str(e)
def pve1_nodes(self) -> str:
def pve1_nodes(): """List all cluster nodes with their CPU/RAM usage and uptime."""
"""Show all cluster nodes."""
try: try:
items = _api("GET", "/nodes") items = self._api("GET", "/nodes")
out = ["Nodes:"] out = ["Nodes:"]
for n in items: for n in items:
out.append(f"- {n.get('node', '?')} ({n.get('status', '?')})") out.append(f"- {n.get('node', '?')} ({n.get('status', '?')})")
out.append(f" CPU: {n.get('cpu', 0)*100:.1f}% | RAM: {_pct(n.get('mem'), n.get('maxmem'))}") out.append(f" CPU: {n.get('cpu', 0)*100:.1f}% | RAM: {self._pct(n.get('mem'), n.get('maxmem'))}")
out.append(f" Uptime: {_uptime(n.get('uptime'))}") out.append(f" Uptime: {self._uptime(n.get('uptime'))}")
return "\n".join(out) return "\n".join(out)
except Exception as e: except Exception as e:
return "ERR " + str(e) return "ERR " + str(e)
def pve1_node_status(self, node: Optional[str] = None) -> str:
"""Show detailed status of a Proxmox node: CPU model, load, RAM, swap, disk, uptime, kernel and PVE version.
def pve1_node_status(node=DEFAULT_NODE): Args:
"""Show detailed node status.""" node: Node name. Defaults to the configured default node if omitted.
"""
node = node or self.valves.default_node
try: try:
s = _api("GET", f"/nodes/{node}/status") s = self._api("GET", f"/nodes/{node}/status")
out = [f"Node {node}:"] out = [f"Node {node}:"]
ci = s.get("cpuinfo", {}) ci = s.get("cpuinfo", {})
out.append(f"CPU: {ci.get('model', '?')} ({s.get('cpus', 0)} cores)") out.append(f"CPU: {ci.get('model', '?')} ({s.get('cpus', 0)} cores)")
@@ -147,14 +181,14 @@ def pve1_node_status(node=DEFAULT_NODE):
if la: if la:
out.append(f"Loadavg: {' / '.join(f'{float(v):.2f}' for v in la)}") out.append(f"Loadavg: {' / '.join(f'{float(v):.2f}' for v in la)}")
m = s.get("memory", {}) m = s.get("memory", {})
out.append(f"RAM: {_pct(m.get('used'), m.get('total'))}") out.append(f"RAM: {self._pct(m.get('used'), m.get('total'))}")
sw = s.get("swap", {}) sw = s.get("swap", {})
if sw.get("total", 0): if sw.get("total", 0):
out.append(f"SWAP: {_pct(sw.get('used'), sw.get('total'))}") out.append(f"SWAP: {self._pct(sw.get('used'), sw.get('total'))}")
r = s.get("rootfs", {}) r = s.get("rootfs", {})
if r: if r:
out.append(f"Disk: {_pct(r.get('used'), r.get('total'))}") out.append(f"Disk: {self._pct(r.get('used'), r.get('total'))}")
out.append(f"Uptime: {_uptime(s.get('uptime'))}") out.append(f"Uptime: {self._uptime(s.get('uptime'))}")
out.append(f"Kernel: {s.get('kversion', '?')}") out.append(f"Kernel: {s.get('kversion', '?')}")
if s.get("pveversion"): if s.get("pveversion"):
out.append(f"PVE: {s['pveversion']}") out.append(f"PVE: {s['pveversion']}")
@@ -162,11 +196,10 @@ def pve1_node_status(node=DEFAULT_NODE):
except Exception as e: except Exception as e:
return "ERR " + str(e) return "ERR " + str(e)
def pve1_cluster(self) -> str:
def pve1_cluster(): """Show cluster status: quorum state and cluster members."""
"""Show cluster status (quorum, members)."""
try: try:
items = _api("GET", "/cluster/status") items = self._api("GET", "/cluster/status")
out = ["Cluster:"] out = ["Cluster:"]
has_cluster_info = False has_cluster_info = False
for item in items: for item in items:
@@ -184,12 +217,15 @@ def pve1_cluster():
except Exception as e: except Exception as e:
return "ERR " + str(e) return "ERR " + str(e)
def pve1_resources(self, ftype: str = "") -> str:
"""List all cluster resources (nodes, QEMU VMs, LXC containers, storage), optionally filtered by type.
def pve1_resources(ftype=""): Args:
"""Show all cluster resources.""" ftype: Optional filter: "node", "qemu", "lxc", or "storage". Empty string returns all types.
"""
try: try:
p = {"type": ftype} if ftype else {} p = {"type": ftype} if ftype else {}
items = _api("GET", "/cluster/resources", params=p) items = self._api("GET", "/cluster/resources", params=p)
out = ["Resources:"] out = ["Resources:"]
g = {} g = {}
for r in items: for r in items:
@@ -204,7 +240,7 @@ def pve1_resources(ftype=""):
if t in ("qemu", "lxc"): if t in ("qemu", "lxc"):
line += f" (ID:{r.get('vmid', '?')})" line += f" (ID:{r.get('vmid', '?')})"
if r.get("maxmem"): if r.get("maxmem"):
line += f" RAM:{_pct(r.get('mem'), r.get('maxmem'))}" line += f" RAM:{self._pct(r.get('mem'), r.get('maxmem'))}"
if r.get("cpu"): if r.get("cpu"):
line += f" CPU:{r.get('cpu')*100:.1f}%" line += f" CPU:{r.get('cpu')*100:.1f}%"
out.append(line) out.append(line)
@@ -213,11 +249,10 @@ def pve1_resources(ftype=""):
except Exception as e: except Exception as e:
return "ERR " + str(e) return "ERR " + str(e)
def pve1_storage(self) -> str:
def pve1_storage(): """List all configured storages with their content types and usage."""
"""Show all storages."""
try: try:
items = _api("GET", "/storage") items = self._api("GET", "/storage")
if not items: if not items:
return "No storage" return "No storage"
out = ["Storage:"] out = ["Storage:"]
@@ -226,18 +261,23 @@ def pve1_storage():
out.append(f"- {name} ({s.get('type', '?')})") out.append(f"- {name} ({s.get('type', '?')})")
out.append(f" Content: {s.get('content', '?')}") out.append(f" Content: {s.get('content', '?')}")
if s.get("total"): if s.get("total"):
out.append(f" Used: {_pct(s.get('used'), s.get('total'))}") out.append(f" Used: {self._pct(s.get('used'), s.get('total'))}")
out.append(f" Free: {_fmt(s.get('avail'))}") out.append(f" Free: {self._fmt(s.get('avail'))}")
out.append("") out.append("")
return "\n".join(out) return "\n".join(out)
except Exception as e: except Exception as e:
return "ERR " + str(e) return "ERR " + str(e)
def pve1_tasks(self, limit: int = 10, node: Optional[str] = None) -> str:
"""Show recent tasks on a node.
def pve1_tasks(limit=10, node=DEFAULT_NODE): Args:
"""Show recent tasks on node.""" limit: Maximum number of tasks to return.
node: Node name. Defaults to the configured default node if omitted.
"""
node = node or self.valves.default_node
try: try:
items = _api("GET", f"/nodes/{node}/tasks", params={"limit": limit}) items = self._api("GET", f"/nodes/{node}/tasks", params={"limit": limit})
if not items: if not items:
return f"No tasks on {node}" return f"No tasks on {node}"
out = [f"Tasks on {node}:"] out = [f"Tasks on {node}:"]
@@ -248,11 +288,15 @@ def pve1_tasks(limit=10, node=DEFAULT_NODE):
except Exception as e: except Exception as e:
return "ERR " + str(e) return "ERR " + str(e)
def pve1_lxc_list(self, node: Optional[str] = None) -> str:
"""List LXC containers on a node with status, CPU, RAM, disk and uptime.
def pve1_lxc_list(node=DEFAULT_NODE): Args:
"""Show LXC container list.""" node: Node name. Defaults to the configured default node if omitted.
"""
node = node or self.valves.default_node
try: try:
items = _api("GET", f"/nodes/{node}/lxc") items = self._api("GET", f"/nodes/{node}/lxc")
if not items: if not items:
return f"No LXC on {node}" return f"No LXC on {node}"
out = [f"LXC on {node}:"] out = [f"LXC on {node}:"]
@@ -261,22 +305,27 @@ def pve1_lxc_list(node=DEFAULT_NODE):
if c.get("cpu"): if c.get("cpu"):
out.append(f" CPU:{c['cpu']*100:.1f}%") out.append(f" CPU:{c['cpu']*100:.1f}%")
if c.get("maxmem"): if c.get("maxmem"):
out.append(f" RAM:{_pct(c.get('mem'), c.get('maxmem'))}") out.append(f" RAM:{self._pct(c.get('mem'), c.get('maxmem'))}")
if c.get("maxdisk"): if c.get("maxdisk"):
out.append(f" Disk:{_pct(c.get('disk'), c.get('maxdisk'))}") out.append(f" Disk:{self._pct(c.get('disk'), c.get('maxdisk'))}")
if c.get("uptime"): if c.get("uptime"):
out.append(f" Uptime:{_uptime(c['uptime'])}") out.append(f" Uptime:{self._uptime(c['uptime'])}")
out.append("") out.append("")
return "\n".join(out) return "\n".join(out)
except Exception as e: except Exception as e:
return "ERR " + str(e) return "ERR " + str(e)
def pve1_lxc_detail(self, vmid: int, node: Optional[str] = None) -> str:
"""Show detailed info about a specific LXC container: OS, CPU, RAM, disk, network, usage and uptime.
def pve1_lxc_detail(vmid, node=DEFAULT_NODE): Args:
"""Show detailed LXC info.""" vmid: The numeric ID of the LXC container.
node: Node name. Defaults to the configured default node if omitted.
"""
node = node or self.valves.default_node
try: try:
s = _api("GET", f"/nodes/{node}/lxc/{vmid}/status/current") s = self._api("GET", f"/nodes/{node}/lxc/{vmid}/status/current")
c = _api("GET", f"/nodes/{node}/lxc/{vmid}/config") c = self._api("GET", f"/nodes/{node}/lxc/{vmid}/config")
name = s.get("name", c.get("hostname", "?")) name = s.get("name", c.get("hostname", "?"))
out = [f"LXC #{vmid}: {name} - {s.get('status', '?')}"] out = [f"LXC #{vmid}: {name} - {s.get('status', '?')}"]
out.append(f"OS: {c.get('ostype', '?')} | CPU: {c.get('cores', '?')} | RAM: {c.get('memory', '?')}MB") out.append(f"OS: {c.get('ostype', '?')} | CPU: {c.get('cores', '?')} | RAM: {c.get('memory', '?')}MB")
@@ -286,59 +335,115 @@ def pve1_lxc_detail(vmid, node=DEFAULT_NODE):
if nets: if nets:
out.append("Network:\n" + "\n".join(nets)) out.append("Network:\n" + "\n".join(nets))
if s.get("maxmem"): if s.get("maxmem"):
out.append(f"RAM use: {_pct(s.get('mem'), s.get('maxmem'))}") out.append(f"RAM use: {self._pct(s.get('mem'), s.get('maxmem'))}")
if s.get("cpu"): if s.get("cpu"):
out.append(f"CPU: {s['cpu']*100:.1f}%") out.append(f"CPU: {s['cpu']*100:.1f}%")
if s.get("uptime"): if s.get("uptime"):
out.append(f"Uptime: {_uptime(s['uptime'])}") out.append(f"Uptime: {self._uptime(s['uptime'])}")
return "\n".join(out) return "\n".join(out)
except Exception as e: except Exception as e:
return "ERR " + str(e) return "ERR " + str(e)
def pve1_lxc_action(self, vmid: int, action: str, node: Optional[str] = None) -> str:
"""Perform a lifecycle action on an LXC container.
def pve1_lxc_action(vmid, action, node=DEFAULT_NODE): Args:
"""Manage LXC: start, stop, shutdown, restart, suspend, resume, delete.""" vmid: The numeric ID of the LXC container.
action: One of "start", "stop", "shutdown", "restart", "suspend", "resume", "delete".
node: Node name. Defaults to the configured default node if omitted.
"""
node = node or self.valves.default_node
try: try:
acts = {"start": "start", "stop": "stop", "shutdown": "shutdown", "restart": "reboot", acts = {"start": "start", "stop": "stop", "shutdown": "shutdown", "restart": "reboot",
"suspend": "suspend", "resume": "resume", "delete": "del"} "suspend": "suspend", "resume": "resume", "delete": "del"}
if action not in acts: if action not in acts:
return f"Unknown action: {action}. Allowed: {', '.join(acts.keys())}" return f"Unknown action: {action}. Allowed: {', '.join(acts.keys())}"
if action == "delete": if action == "delete":
result = _api("DELETE", f"/nodes/{node}/lxc/{vmid}") result = self._api("DELETE", f"/nodes/{node}/lxc/{vmid}")
else: else:
result = _api("POST", f"/nodes/{node}/lxc/{vmid}/status/{acts[action]}") result = self._api("POST", f"/nodes/{node}/lxc/{vmid}/status/{acts[action]}")
return f"OK LXC #{vmid} {action}\n{result}" return f"OK LXC #{vmid} {action}\n{result}"
except Exception as e: except Exception as e:
return "ERR " + str(e) return "ERR " + str(e)
def pve1_templates(self, storage: Optional[str] = None, node: Optional[str] = None) -> str:
"""List available LXC templates in a storage.
def pve1_templates(storage="local", node=DEFAULT_NODE): Args:
"""Show available LXC templates in storage.""" storage: Storage name. Defaults to the configured default storage if omitted.
node: Node name. Defaults to the configured default node if omitted.
"""
node = node or self.valves.default_node
storage = storage or self.valves.default_storage
try: try:
content = _api("GET", f"/nodes/{node}/storage/{storage}/content") content = self._api("GET", f"/nodes/{node}/storage/{storage}/content")
tmpl = [x for x in content if x.get("content") == "vztmpl"] tmpl = [x for x in content if x.get("content") == "vztmpl"]
if not tmpl: if not tmpl:
return f"No templates in {storage}" return f"No templates in {storage}"
out = [f"Templates in {storage}:"] out = [f"Templates in {storage}:"]
for t in tmpl: for t in tmpl:
out.append(f" {t.get('volid', '?')} - {_fmt(t.get('size'))}") out.append(f" {t.get('volid', '?')} - {self._fmt(t.get('size'))}")
return "\n".join(out) return "\n".join(out)
except Exception as e: except Exception as e:
return "ERR " + str(e) return "ERR " + str(e)
def pve1_lxc_create(
self,
vmid: int,
hostname: str,
ostemplate: str,
password: str = "",
sshkey: str = "",
storage: Optional[str] = None,
cores: int = 1,
memory: int = 512,
swap: int = 0,
disk: str = "8G",
bridge: Optional[str] = None,
ip: str = "dhcp",
netmask: int = 24,
gw: str = "",
dns: str = "",
domain: str = "",
node: Optional[str] = None,
unpriv: bool = True,
start_now: bool = True,
wait: bool = True,
) -> str:
"""Create a new LXC container from a template.
def pve1_lxc_create(vmid, hostname, ostemplate, password="", sshkey="", storage=DEFAULT_STORAGE, Args:
cores=1, memory=512, swap=0, disk="8G", bridge=DEFAULT_BRIDGE, vmid: New numeric ID for the container (must not already exist).
ip="dhcp", netmask=24, gw="", dns="", domain="", node=DEFAULT_NODE, hostname: Hostname for the container.
unpriv=True, start_now=True): ostemplate: Template volume id, e.g. "local:vztmpl/debian-12-standard_12.7-1_amd64.tar.zst".
"""Create a new LXC container.""" password: Root password (required if sshkey is not provided).
sshkey: SSH public key (required if password is not provided).
storage: Storage for the rootfs. Defaults to the configured default storage if omitted.
cores: Number of CPU cores.
memory: RAM in MB.
swap: Swap in MB.
disk: Root disk size, e.g. "8G".
bridge: Network bridge. Defaults to the configured default bridge if omitted.
ip: IP address in CIDR-less form, or "dhcp".
netmask: Netmask prefix length, used only if ip is not "dhcp".
gw: Gateway IP, used only if ip is not "dhcp".
dns: Nameserver IP.
domain: Search domain.
node: Node name. Defaults to the configured default node if omitted.
unpriv: Whether to create an unprivileged container.
start_now: Whether to start the container right after creation.
wait: Whether to wait for the creation task to finish before returning.
"""
node = node or self.valves.default_node
storage = storage or self.valves.default_storage
bridge = bridge or self.valves.default_bridge
try: try:
if not hostname: if not hostname:
return "Error: hostname required" return "Error: hostname required"
if not password and not sshkey: if not password and not sshkey:
return "Error: password or sshkey required" return "Error: password or sshkey required"
exist = [str(x.get("vmid")) for x in _api("GET", f"/nodes/{node}/lxc")] exist = [str(x.get("vmid")) for x in self._api("GET", f"/nodes/{node}/lxc")]
exist += [str(x.get("vmid")) for x in _api("GET", f"/nodes/{node}/qemu")] exist += [str(x.get("vmid")) for x in self._api("GET", f"/nodes/{node}/qemu")]
if str(vmid) in exist: if str(vmid) in exist:
return f"Error: VMID {vmid} already exists" return f"Error: VMID {vmid} already exists"
params = {"vmid": vmid, "hostname": hostname, "ostemplate": ostemplate, params = {"vmid": vmid, "hostname": hostname, "ostemplate": ostemplate,
@@ -363,21 +468,30 @@ def pve1_lxc_create(vmid, hostname, ostemplate, password="", sshkey="", storage=
params["searchdomain"] = domain params["searchdomain"] = domain
if start_now: if start_now:
params["start"] = 1 params["start"] = 1
result = _api("POST", f"/nodes/{node}/lxc", data=params) result = self._api("POST", f"/nodes/{node}/lxc", data=params)
out = [f"OK LXC #{vmid} creating!", f" Name: {hostname}", f" Template: {ostemplate}", out = [f"OK LXC #{vmid} creating!", f" Name: {hostname}", f" Template: {ostemplate}",
f" CPU: {cores} cores, RAM: {memory}MB, Disk: {disk}"] f" CPU: {cores} cores, RAM: {memory}MB, Disk: {disk}"]
out.append(f" Net: {bridge}, IP: {'DHCP' if ip == 'dhcp' else f'{ip}/{netmask}'}") out.append(f" Net: {bridge}, IP: {'DHCP' if ip == 'dhcp' else f'{ip}/{netmask}'}")
if isinstance(result, str) and result: upid = result if isinstance(result, str) else ""
out.append(f"UPID: {result}") if upid:
out.append(f"UPID: {upid}")
if wait and upid:
out.append("Waiting for creation to finish...")
ok, msg = self._wait_for_task(upid, node=node, timeout=120)
out.append(" Creation done" if ok else f" WARN Creation may not have finished: {msg}")
return "\n".join(out) return "\n".join(out)
except Exception as e: except Exception as e:
return "ERR " + str(e) return "ERR " + str(e)
def pve1_vm_list(self, node: Optional[str] = None) -> str:
"""List QEMU VMs on a node with status, CPU, RAM and uptime.
def pve1_vm_list(node=DEFAULT_NODE): Args:
"""Show all VM (QEMU) list.""" node: Node name. Defaults to the configured default node if omitted.
"""
node = node or self.valves.default_node
try: try:
items = _api("GET", f"/nodes/{node}/qemu") items = self._api("GET", f"/nodes/{node}/qemu")
if not items: if not items:
return f"No VMs on {node}" return f"No VMs on {node}"
out = [f"VMs on {node}:"] out = [f"VMs on {node}:"]
@@ -386,60 +500,102 @@ def pve1_vm_list(node=DEFAULT_NODE):
if v.get("cpu"): if v.get("cpu"):
out.append(f" CPU:{v['cpu']*100:.1f}%") out.append(f" CPU:{v['cpu']*100:.1f}%")
if v.get("maxmem"): if v.get("maxmem"):
out.append(f" RAM:{_pct(v.get('mem'), v.get('maxmem'))}") out.append(f" RAM:{self._pct(v.get('mem'), v.get('maxmem'))}")
if v.get("uptime"): if v.get("uptime"):
out.append(f" Uptime:{_uptime(v['uptime'])}") out.append(f" Uptime:{self._uptime(v['uptime'])}")
out.append("") out.append("")
return "\n".join(out) return "\n".join(out)
except Exception as e: except Exception as e:
return "ERR " + str(e) return "ERR " + str(e)
def pve1_vm_detail(self, vmid: int, node: Optional[str] = None) -> str:
"""Show detailed status of a specific QEMU VM: RAM, CPU, disk and uptime.
def pve1_vm_detail(vmid, node=DEFAULT_NODE): Args:
"""Show detailed VM info.""" vmid: The numeric ID of the VM.
node: Node name. Defaults to the configured default node if omitted.
"""
node = node or self.valves.default_node
try: try:
s = _api("GET", f"/nodes/{node}/qemu/{vmid}/status/current") s = self._api("GET", f"/nodes/{node}/qemu/{vmid}/status/current")
out = [f"VM #{vmid}: {s.get('name', '?')}", f"Status: {s.get('status', '?')}"] out = [f"VM #{vmid}: {s.get('name', '?')}", f"Status: {s.get('status', '?')}"]
if s.get("maxmem"): if s.get("maxmem"):
out.append(f"RAM: {_pct(s.get('mem'), s.get('maxmem'))}") out.append(f"RAM: {self._pct(s.get('mem'), s.get('maxmem'))}")
if s.get("cpu"): if s.get("cpu"):
out.append(f"CPU: {s['cpu']*100:.1f}%") out.append(f"CPU: {s['cpu']*100:.1f}%")
if s.get("maxdisk") and s.get("maxdisk", 0) > 0: if s.get("maxdisk") and s.get("maxdisk", 0) > 0:
out.append(f"Disk: {_pct(s.get('disk'), s.get('maxdisk'))}") out.append(f"Disk: {self._pct(s.get('disk'), s.get('maxdisk'))}")
if s.get("uptime"): if s.get("uptime"):
out.append(f"Uptime: {_uptime(s['uptime'])}") out.append(f"Uptime: {self._uptime(s['uptime'])}")
return "\n".join(out) return "\n".join(out)
except Exception as e: except Exception as e:
return "ERR " + str(e) return "ERR " + str(e)
def pve1_vm_action(self, vmid: int, action: str, node: Optional[str] = None) -> str:
"""Perform a lifecycle action on a QEMU VM.
def pve1_vm_action(vmid, action, node=DEFAULT_NODE): Args:
"""Manage VM: start, stop, shutdown, restart, suspend, resume, delete.""" vmid: The numeric ID of the VM.
action: One of "start", "stop", "shutdown", "restart", "suspend", "resume", "delete".
node: Node name. Defaults to the configured default node if omitted.
"""
node = node or self.valves.default_node
try: try:
acts = {"start": "start", "stop": "stop", "shutdown": "shutdown", "restart": "reboot", acts = {"start": "start", "stop": "stop", "shutdown": "shutdown", "restart": "reboot",
"suspend": "suspend", "resume": "resume", "delete": "del"} "suspend": "suspend", "resume": "resume", "delete": "del"}
if action not in acts: if action not in acts:
return f"Unknown action: {action}. Allowed: {', '.join(acts.keys())}" return f"Unknown action: {action}. Allowed: {', '.join(acts.keys())}"
if action == "delete": if action == "delete":
result = _api("DELETE", f"/nodes/{node}/qemu/{vmid}") result = self._api("DELETE", f"/nodes/{node}/qemu/{vmid}")
else: else:
result = _api("POST", f"/nodes/{node}/qemu/{vmid}/status/{acts[action]}") result = self._api("POST", f"/nodes/{node}/qemu/{vmid}/status/{acts[action]}")
return f"OK VM #{vmid} {action}\n{result}" return f"OK VM #{vmid} {action}\n{result}"
except Exception as e: except Exception as e:
return "ERR " + str(e) return "ERR " + str(e)
def pve1_vm_create_from_template(
self,
vmid: int,
name: str,
template_vmid: int = 999,
full: bool = True,
memory: Optional[int] = None,
cores: Optional[int] = None,
storage: Optional[str] = None,
target_node: Optional[str] = None,
pool: Optional[str] = None,
start_now: bool = True,
ciuser: str = "",
cipassword: str = "",
sshkeys: str = "",
node: Optional[str] = None,
) -> str:
"""Create a new VM by cloning it from an existing template VM (cloud-init).
def pve1_vm_create_from_template(vmid, name, template_vmid=999, full=True, memory=None, Args:
cores=None, storage=None, target_node=None, pool=None, vmid: New numeric ID for the cloned VM (must not already exist).
start_now=True, ciuser="", cipassword="", sshkeys="", node=DEFAULT_NODE): name: Name for the new VM.
"""Create a new VM by cloning from template. Default: VM #999 (cloud-init).""" template_vmid: VMID of the source template to clone from.
full: True for a full clone, False for a linked clone.
memory: RAM in MB to set after cloning. Leave unset to keep the template's value.
cores: CPU cores to set after cloning. Leave unset to keep the template's value.
storage: Target storage for the clone. Leave unset to use the template's storage.
target_node: Target node if cloning to a different node than the source.
pool: Resource pool to assign the clone to.
start_now: Whether to start the VM right after cloning.
ciuser: Cloud-init user to set.
cipassword: Cloud-init password to set.
sshkeys: Cloud-init SSH public keys to set.
node: Node name where the template lives. Defaults to the configured default node if omitted.
"""
node = node or self.valves.default_node
try: try:
if not vmid: if not vmid:
return "Error: vmid required" return "Error: vmid required"
if not name: if not name:
return "Error: name required" return "Error: name required"
exist_vm = [str(x.get("vmid")) for x in _api("GET", f"/nodes/{node}/qemu")] exist_vm = [str(x.get("vmid")) for x in self._api("GET", f"/nodes/{node}/qemu")]
exist_lxc = [str(x.get("vmid")) for x in _api("GET", f"/nodes/{node}/lxc")] exist_lxc = [str(x.get("vmid")) for x in self._api("GET", f"/nodes/{node}/lxc")]
if str(vmid) in exist_vm + exist_lxc: if str(vmid) in exist_vm + exist_lxc:
return f"Error: VMID {vmid} already exists" return f"Error: VMID {vmid} already exists"
params = {"newid": vmid, "name": name, "full": 1 if full else 0} params = {"newid": vmid, "name": name, "full": 1 if full else 0}
@@ -449,7 +605,7 @@ def pve1_vm_create_from_template(vmid, name, template_vmid=999, full=True, memor
params["target"] = target_node params["target"] = target_node
if pool: if pool:
params["pool"] = pool params["pool"] = pool
result = _api("POST", f"/nodes/{node}/qemu/{template_vmid}/clone", data=params) result = self._api("POST", f"/nodes/{node}/qemu/{template_vmid}/clone", data=params)
out = [f"OK VM #{vmid}: {name} cloning from template #{template_vmid}!", out = [f"OK VM #{vmid}: {name} cloning from template #{template_vmid}!",
f" Type: {'full' if full else 'linked'} clone"] f" Type: {'full' if full else 'linked'} clone"]
if storage: if storage:
@@ -458,8 +614,9 @@ def pve1_vm_create_from_template(vmid, name, template_vmid=999, full=True, memor
if upid: if upid:
out.append(f"UPID: {upid}") out.append(f"UPID: {upid}")
out.append("Waiting for clone to finish...") out.append("Waiting for clone to finish...")
ok, msg = _wait_for_task(upid, timeout=120) clone_node = target_node or node
out.append(f" {'Clone done' if ok else f'WARN Clone may not have finished: {msg}'}") ok, msg = self._wait_for_task(upid, node=clone_node, timeout=120)
out.append(" Clone done" if ok else f" WARN Clone may not have finished: {msg}")
config_params = {} config_params = {}
if memory is not None: if memory is not None:
config_params["memory"] = memory config_params["memory"] = memory
@@ -474,7 +631,7 @@ def pve1_vm_create_from_template(vmid, name, template_vmid=999, full=True, memor
if config_params: if config_params:
out.append("Applying settings...") out.append("Applying settings...")
try: try:
_api("PUT", f"/nodes/{node}/qemu/{vmid}/config", data=config_params) self._api("PUT", f"/nodes/{clone_node}/qemu/{vmid}/config", data=config_params)
if memory is not None: if memory is not None:
out.append(f" RAM -> {memory} MB") out.append(f" RAM -> {memory} MB")
if cores is not None: if cores is not None:
@@ -491,7 +648,7 @@ def pve1_vm_create_from_template(vmid, name, template_vmid=999, full=True, memor
if start_now: if start_now:
out.append("Starting VM...") out.append("Starting VM...")
try: try:
_api("POST", f"/nodes/{node}/qemu/{vmid}/status/start") self._api("POST", f"/nodes/{clone_node}/qemu/{vmid}/status/start")
out.append(f" VM #{vmid} started!") out.append(f" VM #{vmid} started!")
except Exception as e3: except Exception as e3:
out.append(f" WARN Could not start: {e3}") out.append(f" WARN Could not start: {e3}")
@@ -501,22 +658,67 @@ def pve1_vm_create_from_template(vmid, name, template_vmid=999, full=True, memor
except Exception as e: except Exception as e:
return "ERR " + str(e) return "ERR " + str(e)
def pve1_vm_create(
self,
vmid: int,
name: str,
template_vmid: Optional[int] = 999,
memory: int = 4096,
cores: int = 2,
disk_size: str = "32G",
disk_storage: str = "local-lvm",
iso: str = "",
bridge: Optional[str] = None,
ip: str = "dhcp",
netmask: int = 24,
gw: str = "",
ostype: str = "l26",
agent: int = 1,
ciuser: str = "",
cipassword: str = "",
sshkeys: str = "",
node: Optional[str] = None,
start_now: bool = True,
wait: bool = True,
) -> str:
"""Create a new VM. Two modes: clone from a template (default, template_vmid set), or create fresh from an ISO (set template_vmid to null/0).
def pve1_vm_create(vmid, name, template_vmid=999, memory=4096, cores=2, Args:
disk_size="32G", disk_storage="local-lvm", iso="", vmid: New numeric ID for the VM (must not already exist).
bridge=DEFAULT_BRIDGE, ip="dhcp", netmask=24, gw="", name: Name for the new VM.
ostype="l26", agent=1, ciuser="", cipassword="", sshkeys="", template_vmid: VMID of a template to clone from. Set to 0 or null to instead create from an ISO.
node=DEFAULT_NODE, start_now=True): memory: RAM in MB.
"""Universal VM creation. Mode 1: clone from template. Mode 2: from ISO (template_vmid=None).""" cores: CPU cores.
disk_size: Disk size, e.g. "32G". Only used in ISO mode.
disk_storage: Storage for the disk.
iso: ISO volume id to boot from, only used in ISO mode, e.g. "local:iso/debian-12.iso".
bridge: Network bridge. Defaults to the configured default bridge if omitted.
ip: IP address, or "dhcp". Only used in ISO mode.
netmask: Netmask prefix length. Only used in ISO mode.
gw: Gateway IP. Only used in ISO mode.
ostype: Guest OS type, e.g. "l26" for Linux 2.6+/3.x/4.x/5.x/6.x.
agent: Whether to enable the QEMU guest agent (1 or 0). Only used in ISO mode.
ciuser: Cloud-init user (clone mode only).
cipassword: Cloud-init password (clone mode only).
sshkeys: Cloud-init SSH public keys (clone mode only).
node: Node name. Defaults to the configured default node if omitted.
start_now: Whether to start the VM right after creation.
wait: Whether to wait for the creation/clone task to finish before returning (ISO mode only; clone mode always waits).
"""
node = node or self.valves.default_node
bridge = bridge or self.valves.default_bridge
try: try:
if template_vmid: if template_vmid:
return pve1_vm_create_from_template(vmid=vmid, name=name, template_vmid=template_vmid, return self.pve1_vm_create_from_template(
vmid=vmid, name=name, template_vmid=template_vmid,
full=True, memory=memory, cores=cores, storage=disk_storage, full=True, memory=memory, cores=cores, storage=disk_storage,
start_now=start_now, ciuser=ciuser, cipassword=cipassword, sshkeys=sshkeys, node=node) start_now=start_now, ciuser=ciuser, cipassword=cipassword,
sshkeys=sshkeys, node=node,
)
if not name or not vmid: if not name or not vmid:
return "Error: name and vmid required" return "Error: name and vmid required"
exist_vm = [str(x.get("vmid")) for x in _api("GET", f"/nodes/{node}/qemu")] exist_vm = [str(x.get("vmid")) for x in self._api("GET", f"/nodes/{node}/qemu")]
exist_lxc = [str(x.get("vmid")) for x in _api("GET", f"/nodes/{node}/lxc")] exist_lxc = [str(x.get("vmid")) for x in self._api("GET", f"/nodes/{node}/lxc")]
if str(vmid) in exist_vm + exist_lxc: if str(vmid) in exist_vm + exist_lxc:
return f"Error: VMID {vmid} already exists" return f"Error: VMID {vmid} already exists"
params = {"vmid": vmid, "name": name, "memory": memory, "cores": cores, params = {"vmid": vmid, "name": name, "memory": memory, "cores": cores,
@@ -535,26 +737,20 @@ def pve1_vm_create(vmid, name, template_vmid=999, memory=4096, cores=2,
params["net0"] = net params["net0"] = net
if start_now: if start_now:
params["start"] = 1 params["start"] = 1
result = _api("POST", f"/nodes/{node}/qemu", data=params) result = self._api("POST", f"/nodes/{node}/qemu", data=params)
out = [f"OK VM #{vmid}: {name} creating!", f" CPU: {cores} cores, RAM: {memory} MB", out = [f"OK VM #{vmid}: {name} creating!", f" CPU: {cores} cores, RAM: {memory} MB",
f" Disk: {params['virtio0']}"] f" Disk: {params['virtio0']}"]
if iso: if iso:
out.append(f" ISO: {iso}") out.append(f" ISO: {iso}")
out.append(f" Net: {bridge}, IP: {'DHCP' if ip == 'dhcp' else f'{ip}/{netmask}'}") out.append(f" Net: {bridge}, IP: {'DHCP' if ip == 'dhcp' else f'{ip}/{netmask}'}")
out.append(f" QEMU Agent: {'on' if agent else 'off'}") out.append(f" QEMU Agent: {'on' if agent else 'off'}")
if isinstance(result, str) and result: upid = result if isinstance(result, str) else ""
out.append(f"UPID: {result}") if upid:
out.append(f"UPID: {upid}")
if wait and upid:
out.append("Waiting for creation to finish...")
ok, msg = self._wait_for_task(upid, node=node, timeout=120)
out.append(" Creation done" if ok else f" WARN Creation may not have finished: {msg}")
return "\n".join(out) return "\n".join(out)
except Exception as e: except Exception as e:
return "ERR " + str(e) return "ERR " + str(e)
# ============================================================
# TEST
# ============================================================
if __name__ == "__main__":
print(pve1_connection())
print()
print(pve1_vm_list())
print()
print(pve1_storage())