Files
open-webui/proxmox_tools_kybinka.py
T

868 lines
38 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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)