""" 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)