868 lines
38 KiB
Python
868 lines
38 KiB
Python
"""
|
||
title: Proxmox Tools (Kybinka)
|
||
author: (your name)
|
||
description: Инструменты для управления Proxmox VE (сервер Kybinka, 192.168.1.10): LXC (создание, старт, стоп, удаление), VM (клонирование из шаблона, создание из ISO, управление), мониторинг (ноды, хранилище, ресурсы, задачи).
|
||
version: 1.0.0
|
||
"""
|
||
import time
|
||
import requests
|
||
import urllib3
|
||
from pydantic import BaseModel, Field
|
||
from typing import Optional
|
||
|
||
|
||
class Tools:
|
||
class Valves(BaseModel):
|
||
proxmox_host: str = Field(
|
||
default="https://192.168.1.10:8006",
|
||
description="Базовый URL API Proxmox (например, https://192.168.1.10:8006)",
|
||
)
|
||
proxmox_user: str = Field(
|
||
default="root@pam",
|
||
description="Пользователь API Proxmox (например, root@pam)",
|
||
)
|
||
proxmox_token_name: str = Field(
|
||
default="open-webui", description="Имя/API-токена Proxmox"
|
||
)
|
||
proxmox_token_value: str = Field(
|
||
default="",
|
||
description="Секрет API-токена Proxmox (задаётся в интерфейсе Valves, никогда не коммитьте в код)",
|
||
)
|
||
verify_ssl: bool = Field(
|
||
default=False, description="Проверять TLS-сертификат хоста Proxmox"
|
||
)
|
||
default_node: str = Field(
|
||
default="kybinka", description="Имя ноды Proxmox по умолчанию"
|
||
)
|
||
default_storage: str = Field(
|
||
default="local",
|
||
description="Хранилище по умолчанию для rootfs/шаблонов LXC",
|
||
)
|
||
default_bridge: str = Field(
|
||
default="vmbr0", description="Сетевой мост по умолчанию"
|
||
)
|
||
request_timeout: int = Field(
|
||
default=30, description="Таймаут HTTP-запроса в секундах"
|
||
)
|
||
|
||
def __init__(self):
|
||
self.valves = self.Valves()
|
||
# Гасим спам "InsecureRequestWarning" при verify_ssl=False
|
||
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||
|
||
# ============================================================
|
||
# ВНУТРЕННИЕ ХЕЛПЕРЫ (не отдаются LLM)
|
||
# ============================================================
|
||
def _base_url(self) -> str:
|
||
"""Базовый URL API Proxmox."""
|
||
return self.valves.proxmox_host.rstrip("/") + "/api2/json"
|
||
|
||
def _auth_header(self) -> dict:
|
||
"""Заголовок авторизации по API-токену."""
|
||
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,
|
||
):
|
||
"""Выполнить запрос к API Proxmox."""
|
||
url = self._base_url() + path
|
||
resp = requests.request(
|
||
method=method,
|
||
url=url,
|
||
headers=self._auth_header(),
|
||
params=params,
|
||
json=data,
|
||
verify=self.valves.verify_ssl,
|
||
timeout=self.valves.request_timeout,
|
||
)
|
||
resp.raise_for_status()
|
||
return resp.json().get("data", {})
|
||
|
||
@staticmethod
|
||
def _fmt(b) -> str:
|
||
"""Отформатировать байты в человекочитаемый вид."""
|
||
if b is None:
|
||
return "N/A"
|
||
b = float(b)
|
||
for u in ["B", "KB", "MB", "GB", "TB"]:
|
||
if b < 1024:
|
||
return f"{b:.1f} {u}"
|
||
b /= 1024
|
||
return f"{b:.1f} PB"
|
||
|
||
@classmethod
|
||
def _pct(cls, u, t) -> str:
|
||
"""Отформатировать used/total как '1.2 GB / 4.0 GB (30.0%)'."""
|
||
if u is None or t is None or t == 0:
|
||
return "N/A"
|
||
return f"{cls._fmt(u)} / {cls._fmt(t)} ({u/t*100:.1f}%)"
|
||
|
||
@staticmethod
|
||
def _uptime(s) -> str:
|
||
"""Отформатировать uptime в секундах как '1d 2h 3m 4s'."""
|
||
if s is None:
|
||
return "N/A"
|
||
d, r = divmod(int(s), 86400)
|
||
h, r = divmod(r, 3600)
|
||
m, s2 = divmod(r, 60)
|
||
parts = []
|
||
if d:
|
||
parts.append(f"{d}d")
|
||
if h:
|
||
parts.append(f"{h}h")
|
||
if m:
|
||
parts.append(f"{m}m")
|
||
parts.append(f"{s2}s")
|
||
return " ".join(parts)
|
||
|
||
def _wait_for_task(self, upid: str, node: str, timeout: int = 60):
|
||
"""Дождаться завершения задачи Proxmox (по UPID) на указанной ноде."""
|
||
if not upid or not isinstance(upid, str) or not upid.startswith("UPID:"):
|
||
return True, "no UPID to wait"
|
||
start = time.time()
|
||
while time.time() - start < timeout:
|
||
try:
|
||
tasks = self._api("GET", f"/nodes/{node}/tasks")
|
||
for t in tasks:
|
||
if t.get("upid") == upid:
|
||
status = t.get("status", "")
|
||
if status == "stopped":
|
||
return (
|
||
(True, "OK")
|
||
if t.get("exitstatus") == "OK"
|
||
else (False, str(t))
|
||
)
|
||
break
|
||
except Exception:
|
||
pass
|
||
time.sleep(2)
|
||
return False, "timeout waiting for task"
|
||
|
||
# ============================================================
|
||
# ИНСТРУМЕНТЫ (отдаются LLM)
|
||
# ============================================================
|
||
def kybinka_connection(self) -> str:
|
||
"""Проверить подключение к API Proxmox VE (Kybinka) и вернуть версию."""
|
||
try:
|
||
v = self._api("GET", "/version")
|
||
return (
|
||
"OK Proxmox VE "
|
||
+ v.get("version", "?")
|
||
+ f" | node: {self.valves.default_node} | host: {self.valves.proxmox_host}"
|
||
)
|
||
except Exception as e:
|
||
return "ERR " + str(e)
|
||
|
||
def kybinka_nodes(self) -> str:
|
||
"""Список всех нод кластера с загрузкой CPU/RAM и аптаймом."""
|
||
try:
|
||
items = self._api("GET", "/nodes")
|
||
out = ["Ноды:"]
|
||
for n in items:
|
||
out.append(f"- {n.get('node', '?')} ({n.get('status', '?')})")
|
||
out.append(
|
||
f" CPU: {n.get('cpu', 0)*100:.1f}% | RAM: {self._pct(n.get('mem'), n.get('maxmem'))}"
|
||
)
|
||
out.append(f" Uptime: {self._uptime(n.get('uptime'))}")
|
||
return "\n".join(out)
|
||
except Exception as e:
|
||
return "ERR " + str(e)
|
||
|
||
def kybinka_node_status(self, node: Optional[str] = None) -> str:
|
||
"""Подробный статус ноды Proxmox: модель CPU, нагрузка, RAM, swap, диск, аптайм, ядро и версия PVE.
|
||
Args:
|
||
node: Имя ноды. По умолчанию — нода из настроек.
|
||
"""
|
||
node = node or self.valves.default_node
|
||
try:
|
||
s = self._api("GET", f"/nodes/{node}/status")
|
||
out = [f"Нода {node}:"]
|
||
ci = s.get("cpuinfo", {})
|
||
out.append(f"CPU: {ci.get('model', '?')} ({s.get('cpus', 0)} cores)")
|
||
out.append(f"Load: {s.get('cpu', 0)*100:.1f}%")
|
||
la = s.get("loadavg", [])
|
||
if la:
|
||
out.append(f"Loadavg: {' / '.join(f'{float(v):.2f}' for v in la)}")
|
||
m = s.get("memory", {})
|
||
out.append(f"RAM: {self._pct(m.get('used'), m.get('total'))}")
|
||
sw = s.get("swap", {})
|
||
if sw.get("total", 0):
|
||
out.append(f"SWAP: {self._pct(sw.get('used'), sw.get('total'))}")
|
||
r = s.get("rootfs", {})
|
||
if r:
|
||
out.append(f"Disk: {self._pct(r.get('used'), r.get('total'))}")
|
||
out.append(f"Uptime: {self._uptime(s.get('uptime'))}")
|
||
out.append(f"Kernel: {s.get('kversion', '?')}")
|
||
if s.get("pveversion"):
|
||
out.append(f"PVE: {s['pveversion']}")
|
||
return "\n".join(out)
|
||
except Exception as e:
|
||
return "ERR " + str(e)
|
||
|
||
def kybinka_cluster(self) -> str:
|
||
"""Статус кластера: кворум и участники."""
|
||
try:
|
||
items = self._api("GET", "/cluster/status")
|
||
out = ["Кластер:"]
|
||
has_cluster_info = False
|
||
for item in items:
|
||
t = item.get("type", "")
|
||
if t == "cluster":
|
||
has_cluster_info = True
|
||
out.append(
|
||
f"Кластер: {item.get('name', '?')} | Кворум: {'OK' if item.get('quorate') else 'FAIL'}"
|
||
)
|
||
elif t == "node":
|
||
out.append(
|
||
f" {item.get('name', '?')} - {item.get('status', '?')} ({item.get('ip', '-')})"
|
||
)
|
||
if not has_cluster_info:
|
||
node_count = sum(1 for i in items if i.get("type") == "node")
|
||
out.append("Кластер: single-node | Кворум: OK (N/A)")
|
||
out.append(f" Ноды: {node_count}")
|
||
return "\n".join(out)
|
||
except Exception as e:
|
||
return "ERR " + str(e)
|
||
|
||
def kybinka_resources(self, ftype: str = "") -> str:
|
||
"""Список всех ресурсов кластера (ноды, QEMU VM, LXC, хранилища), опционально с фильтром по типу.
|
||
Args:
|
||
ftype: Фильтр: "node", "qemu", "lxc" или "storage". Пусто — все типы.
|
||
"""
|
||
try:
|
||
p = {"type": ftype} if ftype else {}
|
||
items = self._api("GET", "/cluster/resources", params=p)
|
||
out = ["Ресурсы:"]
|
||
g = {}
|
||
for r in items:
|
||
g.setdefault(r.get("type", "?"), []).append(r)
|
||
labels = {
|
||
"node": "Ноды",
|
||
"qemu": "QEMU",
|
||
"lxc": "LXC",
|
||
"storage": "Хранилища",
|
||
}
|
||
for t, lst in g.items():
|
||
out.append(f"{labels.get(t, t)}: {len(lst)}")
|
||
for r in lst[:25]:
|
||
n = r.get("name") or r.get("id") or "?"
|
||
st = r.get("status", "?")
|
||
line = f" {n} - {st}"
|
||
if t in ("qemu", "lxc"):
|
||
line += f" (ID:{r.get('vmid', '?')})"
|
||
if r.get("maxmem"):
|
||
line += f" RAM:{self._pct(r.get('mem'), r.get('maxmem'))}"
|
||
if r.get("cpu"):
|
||
line += f" CPU:{r.get('cpu')*100:.1f}%"
|
||
out.append(line)
|
||
out.append("")
|
||
return "\n".join(out)
|
||
except Exception as e:
|
||
return "ERR " + str(e)
|
||
|
||
def kybinka_storage(self) -> str:
|
||
"""Список всех настроенных хранилищ с типами контента и занятостью."""
|
||
try:
|
||
items = self._api("GET", "/storage")
|
||
if not items:
|
||
return "Нет хранилищ"
|
||
out = ["Хранилища:"]
|
||
for s in items:
|
||
name = s.get("storage", "?")
|
||
out.append(f"- {name} ({s.get('type', '?')})")
|
||
out.append(f" Контент: {s.get('content', '?')}")
|
||
if s.get("total"):
|
||
out.append(
|
||
f" Использовано: {self._pct(s.get('used'), s.get('total'))}"
|
||
)
|
||
out.append(f" Свободно: {self._fmt(s.get('avail'))}")
|
||
out.append("")
|
||
return "\n".join(out)
|
||
except Exception as e:
|
||
return "ERR " + str(e)
|
||
|
||
def kybinka_tasks(self, limit: int = 10, node: Optional[str] = None) -> str:
|
||
"""Последние задачи на ноде.
|
||
Args:
|
||
limit: Максимум задач.
|
||
node: Имя ноды. По умолчанию — нода из настроек.
|
||
"""
|
||
node = node or self.valves.default_node
|
||
try:
|
||
items = self._api("GET", f"/nodes/{node}/tasks", params={"limit": limit})
|
||
if not items:
|
||
return f"Нет задач на {node}"
|
||
out = [f"Задачи на {node}:"]
|
||
for t in items:
|
||
start = time.ctime(t.get("starttime", 0)) if t.get("starttime") else "?"
|
||
out.append(
|
||
f"- {t.get('type', '?')} | {t.get('status', '?')} | {t.get('user', '?')} | {start}"
|
||
)
|
||
return "\n".join(out)
|
||
except Exception as e:
|
||
return "ERR " + str(e)
|
||
|
||
def kybinka_lxc_list(self, node: Optional[str] = None) -> str:
|
||
"""Список LXC-контейнеров на ноде со статусом, CPU, RAM, диском и аптаймом.
|
||
Args:
|
||
node: Имя ноды. По умолчанию — нода из настроек.
|
||
"""
|
||
node = node or self.valves.default_node
|
||
try:
|
||
items = self._api("GET", f"/nodes/{node}/lxc")
|
||
if not items:
|
||
return f"Нет LXC на {node}"
|
||
out = [f"LXC на {node}:"]
|
||
for c in items:
|
||
out.append(
|
||
f"- {c.get('name', '?')} (ID:{c.get('vmid', '?')}) - {c.get('status', '?')}"
|
||
)
|
||
if c.get("cpu"):
|
||
out.append(f" CPU:{c['cpu']*100:.1f}%")
|
||
if c.get("maxmem"):
|
||
out.append(f" RAM:{self._pct(c.get('mem'), c.get('maxmem'))}")
|
||
if c.get("maxdisk"):
|
||
out.append(f" Диск:{self._pct(c.get('disk'), c.get('maxdisk'))}")
|
||
if c.get("uptime"):
|
||
out.append(f" Uptime:{self._uptime(c['uptime'])}")
|
||
out.append("")
|
||
return "\n".join(out)
|
||
except Exception as e:
|
||
return "ERR " + str(e)
|
||
|
||
def kybinka_lxc_detail(self, vmid: int, node: Optional[str] = None) -> str:
|
||
"""Подробная информация о LXC-контейнере: ОС, CPU, RAM, диск, сеть, использование и аптайм.
|
||
Args:
|
||
vmid: Числовой ID LXC-контейнера.
|
||
node: Имя ноды. По умолчанию — нода из настроек.
|
||
"""
|
||
node = node or self.valves.default_node
|
||
try:
|
||
s = self._api("GET", f"/nodes/{node}/lxc/{vmid}/status/current")
|
||
c = self._api("GET", f"/nodes/{node}/lxc/{vmid}/config")
|
||
name = s.get("name", c.get("hostname", "?"))
|
||
out = [f"LXC #{vmid}: {name} - {s.get('status', '?')}"]
|
||
out.append(
|
||
f"OS: {c.get('ostype', '?')} | CPU: {c.get('cores', '?')} | RAM: {c.get('memory', '?')} МБ"
|
||
)
|
||
out.append(f"Диск: {c.get('rootfs', '?')} | SWAP: {c.get('swap', '?')} МБ")
|
||
out.append(f"Hostname: {c.get('hostname', '?')}")
|
||
nets = [f" {k}: {v}" for k, v in c.items() if k.startswith("net")]
|
||
if nets:
|
||
out.append("Сеть:\n" + "\n".join(nets))
|
||
if s.get("maxmem"):
|
||
out.append(f"RAM use: {self._pct(s.get('mem'), s.get('maxmem'))}")
|
||
if s.get("cpu"):
|
||
out.append(f"CPU: {s['cpu']*100:.1f}%")
|
||
if s.get("uptime"):
|
||
out.append(f"Uptime: {self._uptime(s['uptime'])}")
|
||
return "\n".join(out)
|
||
except Exception as e:
|
||
return "ERR " + str(e)
|
||
|
||
def kybinka_lxc_action(
|
||
self, vmid: int, action: str, node: Optional[str] = None
|
||
) -> str:
|
||
"""Выполнить жизненный цикл LXC-контейнера.
|
||
Args:
|
||
vmid: Числовой ID LXC-контейнера.
|
||
action: Одно из: "start", "stop", "shutdown", "restart", "suspend", "resume", "delete".
|
||
node: Имя ноды. По умолчанию — нода из настроек.
|
||
"""
|
||
node = node or self.valves.default_node
|
||
try:
|
||
acts = {
|
||
"start": "start",
|
||
"stop": "stop",
|
||
"shutdown": "shutdown",
|
||
"restart": "reboot",
|
||
"suspend": "suspend",
|
||
"resume": "resume",
|
||
"delete": "del",
|
||
}
|
||
if action not in acts:
|
||
return f"Неизвестное действие: {action}. Допустимо: {', '.join(acts.keys())}"
|
||
if action == "delete":
|
||
result = self._api("DELETE", f"/nodes/{node}/lxc/{vmid}")
|
||
else:
|
||
result = self._api(
|
||
"POST", f"/nodes/{node}/lxc/{vmid}/status/{acts[action]}"
|
||
)
|
||
return f"OK LXC #{vmid} {action}\n{result}"
|
||
except Exception as e:
|
||
return "ERR " + str(e)
|
||
|
||
def kybinka_templates(
|
||
self, storage: Optional[str] = None, node: Optional[str] = None
|
||
) -> str:
|
||
"""Список доступных шаблонов LXC в хранилище.
|
||
Args:
|
||
storage: Хранилище. По умолчанию — из настроек.
|
||
node: Имя ноды. По умолчанию — нода из настроек.
|
||
"""
|
||
node = node or self.valves.default_node
|
||
storage = storage or self.valves.default_storage
|
||
try:
|
||
content = self._api("GET", f"/nodes/{node}/storage/{storage}/content")
|
||
tmpl = [x for x in content if x.get("content") == "vztmpl"]
|
||
if not tmpl:
|
||
return f"Нет шаблонов в {storage}"
|
||
out = [f"Шаблоны в {storage}:"]
|
||
for t in tmpl:
|
||
out.append(f" {t.get('volid', '?')} - {self._fmt(t.get('size'))}")
|
||
return "\n".join(out)
|
||
except Exception as e:
|
||
return "ERR " + str(e)
|
||
|
||
def kybinka_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:
|
||
"""Создать новый LXC-контейнер из шаблона.
|
||
Args:
|
||
vmid: Новый числовой ID контейнера (не должен существовать).
|
||
hostname: Hostname контейнера.
|
||
ostemplate: ID шаблона, например "local:vztmpl/debian-12-standard_12.7-1_amd64.tar.zst".
|
||
password: Пароль root (обязателен, если не задан sshkey).
|
||
sshkey: Публичный SSH-ключ (обязателен, если не задан password).
|
||
storage: Хранилище для rootfs. По умолчанию — из настроек.
|
||
cores: Количество ядер CPU.
|
||
memory: RAM в МБ.
|
||
swap: Swap в МБ.
|
||
disk: Размер корневого диска, например "8G".
|
||
bridge: Сетевой мост. По умолчанию — из настроек.
|
||
ip: IP-адрес (без CIDR) или "dhcp".
|
||
netmask: Префикс маски, если IP задан вручную.
|
||
gw: Шлюз, если IP задан вручную.
|
||
dns: IP DNS-сервера.
|
||
domain: Домен поиска.
|
||
node: Имя ноды. По умолчанию — нода из настроек.
|
||
unpriv: Создать непривилегированный контейнер.
|
||
start_now: Запустить контейнер сразу после создания.
|
||
wait: Дождаться завершения задачи создания.
|
||
"""
|
||
node = node or self.valves.default_node
|
||
storage = storage or self.valves.default_storage
|
||
bridge = bridge or self.valves.default_bridge
|
||
try:
|
||
if not hostname:
|
||
return "Ошибка: hostname обязателен"
|
||
if not password and not sshkey:
|
||
return "Ошибка: требуется password или sshkey"
|
||
exist = [str(x.get("vmid")) for x in self._api("GET", f"/nodes/{node}/lxc")]
|
||
exist += [
|
||
str(x.get("vmid")) for x in self._api("GET", f"/nodes/{node}/qemu")
|
||
]
|
||
if str(vmid) in exist:
|
||
return f"Ошибка: VMID {vmid} уже существует"
|
||
params = {
|
||
"vmid": vmid,
|
||
"hostname": hostname,
|
||
"ostemplate": ostemplate,
|
||
"storage": storage,
|
||
"cores": cores,
|
||
"memory": memory,
|
||
"swap": swap,
|
||
"unprivileged": 1 if unpriv else 0,
|
||
}
|
||
params["rootfs"] = disk if ":" in disk else f"{storage}:{disk}"
|
||
net = f"name=eth0,bridge={bridge}"
|
||
if ip and ip.lower() != "dhcp":
|
||
net += f",ip={ip}/{netmask}"
|
||
if gw:
|
||
net += f",gw={gw}"
|
||
else:
|
||
net += ",ip=dhcp"
|
||
params["net0"] = net
|
||
if password:
|
||
params["password"] = password
|
||
if sshkey:
|
||
params["ssh-public-keys"] = sshkey
|
||
if dns:
|
||
params["nameserver"] = dns
|
||
if domain:
|
||
params["searchdomain"] = domain
|
||
if start_now:
|
||
params["start"] = 1
|
||
result = self._api("POST", f"/nodes/{node}/lxc", data=params)
|
||
out = [
|
||
f"OK LXC #{vmid} создаётся!",
|
||
f" Имя: {hostname}",
|
||
f" Шаблон: {ostemplate}",
|
||
f" CPU: {cores} ядер, RAM: {memory} МБ, Диск: {disk}",
|
||
]
|
||
out.append(
|
||
f" Сеть: {bridge}, IP: {'DHCP' if ip == 'dhcp' else f'{ip}/{netmask}'}"
|
||
)
|
||
upid = result if isinstance(result, str) else ""
|
||
if upid:
|
||
out.append(f"UPID: {upid}")
|
||
if wait and upid:
|
||
out.append("Ждём завершения создания...")
|
||
ok, msg = self._wait_for_task(upid, node=node, timeout=120)
|
||
out.append(
|
||
" Создание завершено"
|
||
if ok
|
||
else f" WARN Создание могло не завершиться: {msg}"
|
||
)
|
||
return "\n".join(out)
|
||
except Exception as e:
|
||
return "ERR " + str(e)
|
||
|
||
def kybinka_vm_list(self, node: Optional[str] = None) -> str:
|
||
"""Список QEMU VM на ноде со статусом, CPU, RAM и аптаймом.
|
||
Args:
|
||
node: Имя ноды. По умолчанию — нода из настроек.
|
||
"""
|
||
node = node or self.valves.default_node
|
||
try:
|
||
items = self._api("GET", f"/nodes/{node}/qemu")
|
||
if not items:
|
||
return f"Нет VM на {node}"
|
||
out = [f"VM на {node}:"]
|
||
for v in items:
|
||
out.append(
|
||
f"- {v.get('name', '?')} (ID:{v.get('vmid', '?')}) - {v.get('status', '?')}"
|
||
)
|
||
if v.get("cpu"):
|
||
out.append(f" CPU:{v['cpu']*100:.1f}%")
|
||
if v.get("maxmem"):
|
||
out.append(f" RAM:{self._pct(v.get('mem'), v.get('maxmem'))}")
|
||
if v.get("uptime"):
|
||
out.append(f" Uptime:{self._uptime(v['uptime'])}")
|
||
out.append("")
|
||
return "\n".join(out)
|
||
except Exception as e:
|
||
return "ERR " + str(e)
|
||
|
||
def kybinka_vm_detail(self, vmid: int, node: Optional[str] = None) -> str:
|
||
"""Подробный статус QEMU VM: RAM, CPU, диск и аптайм.
|
||
Args:
|
||
vmid: Числовой ID VM.
|
||
node: Имя ноды. По умолчанию — нода из настроек.
|
||
"""
|
||
node = node or self.valves.default_node
|
||
try:
|
||
s = self._api("GET", f"/nodes/{node}/qemu/{vmid}/status/current")
|
||
out = [
|
||
f"VM #{vmid}: {s.get('name', '?')}",
|
||
f"Статус: {s.get('status', '?')}",
|
||
]
|
||
if s.get("maxmem"):
|
||
out.append(f"RAM: {self._pct(s.get('mem'), s.get('maxmem'))}")
|
||
if s.get("cpu"):
|
||
out.append(f"CPU: {s['cpu']*100:.1f}%")
|
||
if s.get("maxdisk") and s.get("maxdisk", 0) > 0:
|
||
out.append(f"Диск: {self._pct(s.get('disk'), s.get('maxdisk'))}")
|
||
if s.get("uptime"):
|
||
out.append(f"Uptime: {self._uptime(s['uptime'])}")
|
||
return "\n".join(out)
|
||
except Exception as e:
|
||
return "ERR " + str(e)
|
||
|
||
def kybinka_vm_action(
|
||
self, vmid: int, action: str, node: Optional[str] = None
|
||
) -> str:
|
||
"""Выполнить жизненный цикл QEMU VM.
|
||
Args:
|
||
vmid: Числовой ID VM.
|
||
action: Одно из: "start", "stop", "shutdown", "restart", "suspend", "resume", "delete".
|
||
node: Имя ноды. По умолчанию — нода из настроек.
|
||
"""
|
||
node = node or self.valves.default_node
|
||
try:
|
||
acts = {
|
||
"start": "start",
|
||
"stop": "stop",
|
||
"shutdown": "shutdown",
|
||
"restart": "reboot",
|
||
"suspend": "suspend",
|
||
"resume": "resume",
|
||
"delete": "del",
|
||
}
|
||
if action not in acts:
|
||
return f"Неизвестное действие: {action}. Допустимо: {', '.join(acts.keys())}"
|
||
if action == "delete":
|
||
result = self._api("DELETE", f"/nodes/{node}/qemu/{vmid}")
|
||
else:
|
||
result = self._api(
|
||
"POST", f"/nodes/{node}/qemu/{vmid}/status/{acts[action]}"
|
||
)
|
||
return f"OK VM #{vmid} {action}\n{result}"
|
||
except Exception as e:
|
||
return "ERR " + str(e)
|
||
|
||
def kybinka_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:
|
||
"""Создать новую VM клонированием из шаблона (cloud-init).
|
||
Args:
|
||
vmid: Новый числовой ID клона (не должен существовать).
|
||
name: Имя новой VM.
|
||
template_vmid: VMID шаблона-источника.
|
||
full: True — полный клон, False — связанный клон.
|
||
memory: RAM в МБ после клонирования. Не задано — берётся из шаблона.
|
||
cores: Ядра CPU после клонирования. Не задано — берётся из шаблона.
|
||
storage: Целевое хранилище клона. Не задано — из шаблона.
|
||
target_node: Целевая нода, если отличается от исходной.
|
||
pool: Пул ресурсов.
|
||
start_now: Запустить VM сразу после клонирования.
|
||
ciuser: Cloud-init пользователь.
|
||
cipassword: Cloud-init пароль.
|
||
sshkeys: Cloud-init публичные SSH-ключи.
|
||
node: Нода, где лежит шаблон. По умолчанию — нода из настроек.
|
||
"""
|
||
node = node or self.valves.default_node
|
||
try:
|
||
if not vmid:
|
||
return "Ошибка: требуется vmid"
|
||
if not name:
|
||
return "Ошибка: требуется name"
|
||
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 self._api("GET", f"/nodes/{node}/lxc")
|
||
]
|
||
if str(vmid) in exist_vm + exist_lxc:
|
||
return f"Ошибка: VMID {vmid} уже существует"
|
||
params = {"newid": vmid, "name": name, "full": 1 if full else 0}
|
||
if storage:
|
||
params["storage"] = storage
|
||
if target_node:
|
||
params["target"] = target_node
|
||
if pool:
|
||
params["pool"] = pool
|
||
result = self._api(
|
||
"POST", f"/nodes/{node}/qemu/{template_vmid}/clone", data=params
|
||
)
|
||
out = [
|
||
f"OK VM #{vmid}: {name} клонируется из шаблона #{template_vmid}!",
|
||
f" Тип: {'полный' if full else 'связанный'} клон",
|
||
]
|
||
if storage:
|
||
out.append(f" Хранилище: {storage}")
|
||
upid = result if isinstance(result, str) else ""
|
||
if upid:
|
||
out.append(f"UPID: {upid}")
|
||
out.append("Ждём завершения клонирования...")
|
||
clone_node = target_node or node
|
||
ok, msg = self._wait_for_task(upid, node=clone_node, timeout=120)
|
||
out.append(
|
||
" Клонирование завершено"
|
||
if ok
|
||
else f" WARN Клонирование могло не завершиться: {msg}"
|
||
)
|
||
config_params = {}
|
||
if memory is not None:
|
||
config_params["memory"] = memory
|
||
if cores is not None:
|
||
config_params["cores"] = cores
|
||
if ciuser:
|
||
config_params["ciuser"] = ciuser
|
||
if cipassword:
|
||
config_params["cipassword"] = cipassword
|
||
if sshkeys:
|
||
config_params["sshkeys"] = sshkeys
|
||
if config_params:
|
||
out.append("Применяем настройки...")
|
||
try:
|
||
self._api(
|
||
"PUT",
|
||
f"/nodes/{clone_node}/qemu/{vmid}/config",
|
||
data=config_params,
|
||
)
|
||
if memory is not None:
|
||
out.append(f" RAM -> {memory} МБ")
|
||
if cores is not None:
|
||
out.append(f" CPU -> {cores} ядер")
|
||
if ciuser:
|
||
out.append(f" Cloud-init пользователь: {ciuser}")
|
||
if cipassword:
|
||
out.append(" Cloud-init пароль: (задан)")
|
||
if sshkeys:
|
||
out.append(" Cloud-init SSH-ключи: (заданы)")
|
||
out.append(" Настройки применены")
|
||
except Exception as e2:
|
||
out.append(f" WARN Не удалось применить настройки: {e2}")
|
||
if start_now:
|
||
out.append("Запускаем VM...")
|
||
try:
|
||
self._api("POST", f"/nodes/{clone_node}/qemu/{vmid}/status/start")
|
||
out.append(f" VM #{vmid} запущена!")
|
||
except Exception as e3:
|
||
out.append(f" WARN Не удалось запустить: {e3}")
|
||
else:
|
||
out.append(f" VM #{vmid} создана (не запущена)")
|
||
return "\n".join(out)
|
||
except Exception as e:
|
||
return "ERR " + str(e)
|
||
|
||
def kybinka_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:
|
||
"""Создать новую VM. Два режима: клон из шаблона (по умолчанию, template_vmid задан) или создание с ISO (template_vmid=0/null).
|
||
Args:
|
||
vmid: Новый числовой ID VM (не должен существовать).
|
||
name: Имя VM.
|
||
template_vmid: VMID шаблона для клонирования. 0 или null — создание с ISO.
|
||
memory: RAM в МБ.
|
||
cores: Ядра CPU.
|
||
disk_size: Размер диска, например "32G". Только для режима ISO.
|
||
disk_storage: Хранилище для диска.
|
||
iso: ID ISO для загрузки, только для режима ISO, например "local:iso/debian-12.iso".
|
||
bridge: Сетевой мост. По умолчанию — из настроек.
|
||
ip: IP-адрес или "dhcp". Только для режима ISO.
|
||
netmask: Префикс маски. Только для режима ISO.
|
||
gw: Шлюз. Только для режима ISO.
|
||
ostype: Тип гостевой ОС, например "l26" для Linux 2.6+/3.x/4.x/5.x/6.x.
|
||
agent: Включить QEMU guest agent (1/0). Только для режима ISO.
|
||
ciuser: Cloud-init пользователь (только режим клона).
|
||
cipassword: Cloud-init пароль (только режим клона).
|
||
sshkeys: Cloud-init SSH-ключи (только режим клона).
|
||
node: Имя ноды. По умолчанию — нода из настроек.
|
||
start_now: Запустить VM сразу после создания.
|
||
wait: Ждать завершения задачи создания (только для режима ISO; клон всегда ждёт).
|
||
"""
|
||
node = node or self.valves.default_node
|
||
bridge = bridge or self.valves.default_bridge
|
||
try:
|
||
if template_vmid:
|
||
return self.kybinka_vm_create_from_template(
|
||
vmid=vmid,
|
||
name=name,
|
||
template_vmid=template_vmid,
|
||
full=True,
|
||
memory=memory,
|
||
cores=cores,
|
||
storage=disk_storage,
|
||
start_now=start_now,
|
||
ciuser=ciuser,
|
||
cipassword=cipassword,
|
||
sshkeys=sshkeys,
|
||
node=node,
|
||
)
|
||
if not name or not vmid:
|
||
return "Ошибка: требуются name и vmid"
|
||
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 self._api("GET", f"/nodes/{node}/lxc")
|
||
]
|
||
if str(vmid) in exist_vm + exist_lxc:
|
||
return f"Ошибка: VMID {vmid} уже существует"
|
||
params = {
|
||
"vmid": vmid,
|
||
"name": name,
|
||
"memory": memory,
|
||
"cores": cores,
|
||
"sockets": 1,
|
||
"ostype": ostype,
|
||
"agent": str(agent),
|
||
}
|
||
params["virtio0"] = (
|
||
disk_size if ":" in disk_size else f"{disk_storage}:{disk_size}"
|
||
)
|
||
if iso:
|
||
params["ide2"] = f"{iso},media=cdrom"
|
||
params["boot"] = "order=ide2;virtio0"
|
||
net = f"name=eth0,bridge={bridge}"
|
||
if ip and ip.lower() != "dhcp":
|
||
net += f",ip={ip}/{netmask}"
|
||
if gw:
|
||
net += f",gw={gw}"
|
||
else:
|
||
net += ",ip=dhcp"
|
||
params["net0"] = net
|
||
if start_now:
|
||
params["start"] = 1
|
||
result = self._api("POST", f"/nodes/{node}/qemu", data=params)
|
||
out = [
|
||
f"OK VM #{vmid}: {name} создаётся!",
|
||
f" CPU: {cores} ядер, RAM: {memory} МБ",
|
||
f" Диск: {params['virtio0']}",
|
||
]
|
||
if iso:
|
||
out.append(f" ISO: {iso}")
|
||
out.append(
|
||
f" Сеть: {bridge}, IP: {'DHCP' if ip == 'dhcp' else f'{ip}/{netmask}'}"
|
||
)
|
||
out.append(f" QEMU Agent: {'вкл' if agent else 'выкл'}")
|
||
upid = result if isinstance(result, str) else ""
|
||
if upid:
|
||
out.append(f"UPID: {upid}")
|
||
if wait and upid:
|
||
out.append("Ждём завершения создания...")
|
||
ok, msg = self._wait_for_task(upid, node=node, timeout=120)
|
||
out.append(
|
||
" Создание завершено"
|
||
if ok
|
||
else f" WARN Создание могло не завершиться: {msg}"
|
||
)
|
||
return "\n".join(out)
|
||
except Exception as e:
|
||
return "ERR " + str(e)
|