diff --git a/proxmox_tools_kybinka.py b/proxmox_tools_kybinka.py index 1fd89ef..990d03a 100644 --- a/proxmox_tools_kybinka.py +++ b/proxmox_tools_kybinka.py @@ -1 +1,867 @@ -[{"id":"proxmox_tools_kybinka","user_id":"a9b3bc2c-071d-44d2-b6a3-7188033e0a66","name":"Proxmox Tools (Kybinka)","meta":{"description":"Инструменты для управления Proxmox VE (сервер Kybinka, 192.168.1.10): LXC (создание, старт, стоп, удаление), VM (клонирование из шаблона, создание из ISO, управление), мониторинг (ноды, хранилище, ресурсы, задачи).","manifest":{"title":"Proxmox Tools (Kybinka)","author":"(your name)","description":"Инструменты для управления Proxmox VE (сервер Kybinka, 192.168.1.10): LXC (создание, старт, стоп, удаление), VM (клонирование из шаблона, создание из ISO, управление), мониторинг (ноды, хранилище, ресурсы, задачи).","version":"1.0.0"},"has_user_valves":false},"access_grants":[],"updated_at":1785573012,"created_at":1785572990,"user":null,"write_access":true,"content":"\"\"\"\ntitle: Proxmox Tools (Kybinka)\nauthor: (your name)\ndescription: Инструменты для управления Proxmox VE (сервер Kybinka, 192.168.1.10): LXC (создание, старт, стоп, удаление), VM (клонирование из шаблона, создание из ISO, управление), мониторинг (ноды, хранилище, ресурсы, задачи).\nversion: 1.0.0\n\"\"\"\n\nimport time\nimport requests\nimport urllib3\nfrom pydantic import BaseModel, Field\nfrom typing import Optional\n\n\nclass Tools:\n class Valves(BaseModel):\n proxmox_host: str = Field(\n default=\"https://192.168.1.10:8006\",\n description=\"Базовый URL API Proxmox (например, https://192.168.1.10:8006)\",\n )\n proxmox_user: str = Field(\n default=\"root@pam\",\n description=\"Пользователь API Proxmox (например, root@pam)\",\n )\n proxmox_token_name: str = Field(\n default=\"open-webui\", description=\"Имя/API-токена Proxmox\"\n )\n proxmox_token_value: str = Field(\n default=\"\",\n description=\"Секрет API-токена Proxmox (задаётся в интерфейсе Valves, никогда не коммитьте в код)\",\n )\n verify_ssl: bool = Field(\n default=False, description=\"Проверять TLS-сертификат хоста Proxmox\"\n )\n default_node: str = Field(\n default=\"kybinka\", description=\"Имя ноды Proxmox по умолчанию\"\n )\n default_storage: str = Field(\n default=\"local\", description=\"Хранилище по умолчанию для rootfs/шаблонов LXC\",\n )\n default_bridge: str = Field(\n default=\"vmbr0\", description=\"Сетевой мост по умолчанию\"\n )\n request_timeout: int = Field(\n default=30, description=\"Таймаут HTTP-запроса в секундах\"\n )\n\n def __init__(self):\n self.valves = self.Valves()\n # Гасим спам \"InsecureRequestWarning\" при verify_ssl=False\n urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)\n\n # ============================================================\n # ВНУТРЕННИЕ ХЕЛПЕРЫ (не отдаются LLM)\n # ============================================================\n def _base_url(self) -> str:\n \"\"\"Базовый URL API Proxmox.\"\"\"\n return self.valves.proxmox_host.rstrip(\"/\") + \"/api2/json\"\n\n def _auth_header(self) -> dict:\n \"\"\"Заголовок авторизации по API-токену.\"\"\"\n return {\n \"Authorization\": (\n f\"PVEAPIToken={self.valves.proxmox_user}!\"\n f\"{self.valves.proxmox_token_name}={self.valves.proxmox_token_value}\"\n )\n }\n\n def _api(\n self,\n method: str,\n path: str,\n params: Optional[dict] = None,\n data: Optional[dict] = None,\n ):\n \"\"\"Выполнить запрос к API Proxmox.\"\"\"\n url = self._base_url() + path\n resp = requests.request(\n method=method,\n url=url,\n headers=self._auth_header(),\n params=params,\n json=data,\n verify=self.valves.verify_ssl,\n timeout=self.valves.request_timeout,\n )\n resp.raise_for_status()\n return resp.json().get(\"data\", {})\n\n @staticmethod\n def _fmt(b) -> str:\n \"\"\"Отформатировать байты в человекочитаемый вид.\"\"\"\n if b is None:\n return \"N/A\"\n b = float(b)\n for u in [\"B\", \"KB\", \"MB\", \"GB\", \"TB\"]:\n if b < 1024:\n return f\"{b:.1f} {u}\"\n b /= 1024\n return f\"{b:.1f} PB\"\n\n @classmethod\n def _pct(cls, u, t) -> str:\n \"\"\"Отформатировать used/total как '1.2 GB / 4.0 GB (30.0%)'.\"\"\"\n if u is None or t is None or t == 0:\n return \"N/A\"\n return f\"{cls._fmt(u)} / {cls._fmt(t)} ({u/t*100:.1f}%)\"\n\n @staticmethod\n def _uptime(s) -> str:\n \"\"\"Отформатировать uptime в секундах как '1d 2h 3m 4s'.\"\"\"\n if s is None:\n return \"N/A\"\n d, r = divmod(int(s), 86400)\n h, r = divmod(r, 3600)\n m, s2 = divmod(r, 60)\n parts = []\n if d:\n parts.append(f\"{d}d\")\n if h:\n parts.append(f\"{h}h\")\n if m:\n parts.append(f\"{m}m\")\n parts.append(f\"{s2}s\")\n return \" \".join(parts)\n\n def _wait_for_task(self, upid: str, node: str, timeout: int = 60):\n \"\"\"Дождаться завершения задачи Proxmox (по UPID) на указанной ноде.\"\"\"\n if not upid or not isinstance(upid, str) or not upid.startswith(\"UPID:\"):\n return True, \"no UPID to wait\"\n start = time.time()\n while time.time() - start < timeout:\n try:\n tasks = self._api(\"GET\", f\"/nodes/{node}/tasks\")\n for t in tasks:\n if t.get(\"upid\") == upid:\n status = t.get(\"status\", \"\")\n if status == \"stopped\":\n return (\n (True, \"OK\")\n if t.get(\"exitstatus\") == \"OK\"\n else (False, str(t))\n )\n break\n except Exception:\n pass\n time.sleep(2)\n return False, \"timeout waiting for task\"\n\n # ============================================================\n # ИНСТРУМЕНТЫ (отдаются LLM)\n # ============================================================\n def kybinka_connection(self) -> str:\n \"\"\"Проверить подключение к API Proxmox VE (Kybinka) и вернуть версию.\"\"\"\n try:\n v = self._api(\"GET\", \"/version\")\n return (\n \"OK Proxmox VE \"\n + v.get(\"version\", \"?\")\n + f\" | node: {self.valves.default_node} | host: {self.valves.proxmox_host}\"\n )\n except Exception as e:\n return \"ERR \" + str(e)\n\n def kybinka_nodes(self) -> str:\n \"\"\"Список всех нод кластера с загрузкой CPU/RAM и аптаймом.\"\"\"\n try:\n items = self._api(\"GET\", \"/nodes\")\n out = [\"Ноды:\"]\n for n in items:\n out.append(f\"- {n.get('node', '?')} ({n.get('status', '?')})\")\n out.append(\n f\" CPU: {n.get('cpu', 0)*100:.1f}% | RAM: {self._pct(n.get('mem'), n.get('maxmem'))}\"\n )\n out.append(f\" Uptime: {self._uptime(n.get('uptime'))}\")\n return \"\\n\".join(out)\n except Exception as e:\n return \"ERR \" + str(e)\n\n def kybinka_node_status(self, node: Optional[str] = None) -> str:\n \"\"\"Подробный статус ноды Proxmox: модель CPU, нагрузка, RAM, swap, диск, аптайм, ядро и версия PVE.\n Args:\n node: Имя ноды. По умолчанию — нода из настроек.\n \"\"\"\n node = node or self.valves.default_node\n try:\n s = self._api(\"GET\", f\"/nodes/{node}/status\")\n out = [f\"Нода {node}:\"]\n ci = s.get(\"cpuinfo\", {})\n out.append(f\"CPU: {ci.get('model', '?')} ({s.get('cpus', 0)} cores)\")\n out.append(f\"Load: {s.get('cpu', 0)*100:.1f}%\")\n la = s.get(\"loadavg\", [])\n if la:\n out.append(f\"Loadavg: {' / '.join(f'{float(v):.2f}' for v in la)}\")\n m = s.get(\"memory\", {})\n out.append(f\"RAM: {self._pct(m.get('used'), m.get('total'))}\")\n sw = s.get(\"swap\", {})\n if sw.get(\"total\", 0):\n out.append(f\"SWAP: {self._pct(sw.get('used'), sw.get('total'))}\")\n r = s.get(\"rootfs\", {})\n if r:\n out.append(f\"Disk: {self._pct(r.get('used'), r.get('total'))}\")\n out.append(f\"Uptime: {self._uptime(s.get('uptime'))}\")\n out.append(f\"Kernel: {s.get('kversion', '?')}\")\n if s.get(\"pveversion\"):\n out.append(f\"PVE: {s['pveversion']}\")\n return \"\\n\".join(out)\n except Exception as e:\n return \"ERR \" + str(e)\n\n def kybinka_cluster(self) -> str:\n \"\"\"Статус кластера: кворум и участники.\"\"\"\n try:\n items = self._api(\"GET\", \"/cluster/status\")\n out = [\"Кластер:\"]\n has_cluster_info = False\n for item in items:\n t = item.get(\"type\", \"\")\n if t == \"cluster\":\n has_cluster_info = True\n out.append(\n f\"Кластер: {item.get('name', '?')} | Кворум: {'OK' if item.get('quorate') else 'FAIL'}\"\n )\n elif t == \"node\":\n out.append(\n f\" {item.get('name', '?')} - {item.get('status', '?')} ({item.get('ip', '-')})\"\n )\n if not has_cluster_info:\n node_"] \ No newline at end of file +""" +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)