From 89e44599cde7c6e3635ef612c2dfdc702e18a799 Mon Sep 17 00:00:00 2001 From: host Date: Sun, 9 Aug 2026 17:26:56 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9F=D0=BE=D0=B4=D0=B3=D0=BE=D1=82=D0=BE?= =?UTF-8?q?=D0=B2=D0=BB=D0=B5=D0=BD=D0=B0=20=D0=BF=D0=BE=D0=B4=D0=B4=D0=B5?= =?UTF-8?q?=D1=80=D0=B6=D0=BA=D0=B0=20=D0=BE=D1=81=D1=82=D0=B0=D0=BD=D0=BE?= =?UTF-8?q?=D0=B2=D0=BA=D0=B8=20=D0=BF=D0=B5=D1=80=D0=B5=D0=B4=20=D1=83?= =?UTF-8?q?=D0=B4=D0=B0=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=D0=BC=20=D0=B8=20LXC?= =?UTF-8?q?-=D1=88=D0=B0=D0=B1=D0=BB=D0=BE=D0=BD=D0=BE=D0=B2=20=D0=BF?= =?UTF-8?q?=D0=BE=20VMID?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/proxmox_client.py | 179 +++++++++++++++++++--------------- 1 file changed, 99 insertions(+), 80 deletions(-) diff --git a/backend/app/proxmox_client.py b/backend/app/proxmox_client.py index cc16519..a9b87f7 100644 --- a/backend/app/proxmox_client.py +++ b/backend/app/proxmox_client.py @@ -1,8 +1,4 @@ -"""Обёртка над Proxmox REST API. - -Все методы — синхронные (proxmoxer работает через requests). Используйте -внутри FastAPI через run_in_threadpool, если эндпоинт async. -""" +"""Обёртка над Proxmox REST API.""" import logging import random @@ -30,34 +26,52 @@ def _client() -> ProxmoxAPI: def gen_password(length: int = 14) -> str: - """Генерирует криптостойкий пароль из букв и цифр.""" + """Генерирует пароль из букв и цифр.""" alphabet = string.ascii_letters + string.digits return "".join(random.choice(alphabet) for _ in range(length)) def get_next_vmid() -> int: - """Берёт следующий свободный VMID у самого Proxmox.""" - px = _client() - return int(px.cluster.nextid.get()) + """Берёт следующий свободный VMID у Proxmox.""" + return int(_client().cluster.nextid.get()) def clone_vm(source_vmid: int, new_vmid: int, name: str, node: str = None, storage: str = None) -> None: """Клонирует VM-шаблон в новую VM.""" node = node or settings.pve_node px = _client() - upid = px.nodes(node).qemu(source_vmid).clone.post( + params = {"newid": new_vmid, "name": name, "full": 1} + if storage: + params["storage"] = storage + upid = px.nodes(node).qemu(source_vmid).clone.post(**params) + _wait_task(px, node, upid) + + +def clone_lxc(source_vmid: int, new_vmid: int, name: str, cores: int, memory_mb: int, password: str, node: str = None) -> str: + """Клонирует LXC-шаблон по VMID и задаёт ресурсы и пароль.""" + node = node or settings.pve_node + px = _client() + upid = px.nodes(node).lxc(source_vmid).clone.post( newid=new_vmid, - name=name, + hostname=name, full=1, ) _wait_task(px, node, upid) + px.nodes(node).lxc(new_vmid).config.put( + hostname=name, + cores=cores, + memory=memory_mb, + swap=memory_mb, + ) + # У LXC отдельный endpoint для смены пароля root. + px.nodes(node).lxc(new_vmid).passwd.post(password=password) + return password def resize_vm(vmid: int, cores: int, memory_mb: int, node: str = None) -> None: """Изменяет количество vCPU и RAM у VM.""" node = node or settings.pve_node - px = _client() - px.nodes(node).qemu(vmid).config.put(cores=cores, memory=memory_mb) + _client().nodes(node).qemu(vmid).config.put(cores=cores, memory=memory_mb) def create_lxc( @@ -70,7 +84,7 @@ def create_lxc( storage: str = "local-lvm", node: str = None, ) -> str: - """Создаёт LXC-контейнер из шаблона. Возвращает сгенерированный root-пароль.""" + """Создаёт LXC из архива vzdump-шаблона.""" node = node or settings.pve_node px = _client() password = gen_password() @@ -91,7 +105,7 @@ def create_lxc( def _wait_task(px: ProxmoxAPI, node: str, upid: str, timeout: int = 1200, poll: float = 2.0) -> None: - """Ждёт завершения асинхронной задачи Proxmox с экспоненциальной задержкой.""" + """Ждёт завершения асинхронной задачи Proxmox.""" start = time.time() delay = poll while time.time() - start < timeout: @@ -109,18 +123,17 @@ _DISK_KEYS = ("scsi0", "virtio0", "ide0", "sata0") def _disk_size_from_config(cfg: dict) -> int: - """Извлекает размер диска (ГБ) из конфигурации VM, иначе 10.""" - for k in _DISK_KEYS: - v = cfg.get(k, "") - if ",size=" in v: - m = re.search(r"size=(\d+)G", v) - if m: - return int(m.group(1)) + """Извлекает размер диска VM в ГБ.""" + for key in _DISK_KEYS: + value = cfg.get(key, "") + match = re.search(r"size=(\d+)G", value) + if match: + return int(match.group(1)) return 10 def list_vm_templates(node: str = None) -> list: - """Возвращает список VM-шаблонов (template=1) с параметрами.""" + """Возвращает список VM-шаблонов.""" node = node or settings.pve_node px = _client() result = [] @@ -138,49 +151,50 @@ def list_vm_templates(node: str = None) -> list: def list_lxc_templates(node: str = None) -> list: - """Возвращает список LXC-шаблонов из всех хранилищ.""" + """Возвращает архивные LXC-шаблоны из доступных хранилищ.""" node = node or settings.pve_node px = _client() result = [] - for st in px.nodes(node).storage.get(): - if "vztmpl" not in st.get("content", ""): + for storage in px.nodes(node).storage.get(): + if "vztmpl" not in storage.get("content", ""): continue try: - for item in px.nodes(node).storage(st["storage"]).content.get(): - if item.get("content") == "vztmpl": - name = item["volid"].split("/")[-1] - for ext in (".tar.zst", ".tar.gz", ".tar.xz"): - name = name.replace(ext, "") - result.append({ - "volid": item["volid"], - "name": name, - "storage": st["storage"], - "size_mb": round(item.get("size", 0) / (1024 ** 2), 1), - }) + for item in px.nodes(node).storage(storage["storage"]).content.get(): + if item.get("content") != "vztmpl": + continue + name = item["volid"].split("/")[-1] + for extension in (".tar.zst", ".tar.gz", ".tar.xz"): + name = name.replace(extension, "") + result.append({ + "volid": item["volid"], + "name": name, + "storage": storage["storage"], + "size_mb": round(item.get("size", 0) / (1024 ** 2), 1), + }) except Exception as exc: - logger.warning("Не удалось прочитать шаблоны с хранилища %s: %s", st["storage"], exc) + logger.warning("Не удалось прочитать шаблоны с хранилища %s: %s", storage["storage"], exc) return result def resize_disk(vmid: int, disk_gb: int, node: str = None) -> None: - """Увеличивает диск VM до нужного размера (только в большую сторону).""" + """Увеличивает диск VM до указанного размера.""" node = node or settings.pve_node px = _client() config = px.nodes(node).qemu(vmid).config.get() for key in _DISK_KEYS: - val = config.get(key, "") - if not val: + value = config.get(key, "") + if not value: continue - m = re.search(r"size=(\d+)G", val) - current = int(m.group(1)) if m else 0 + match = re.search(r"size=(\d+)G", value) + current = int(match.group(1)) if match else 0 if disk_gb > current: px.nodes(node).qemu(vmid).resize.put(disk=key, size=f"+{disk_gb - current}G") return - logger.warning("У VM %s не найден ни один диск для resize", vmid) + logger.warning("У VM %s не найден диск для resize", vmid) def configure_cloud_init(vmid: int, ciuser: str, cipassword: str, node: str = None) -> None: - """Задаёт cloud-init user/password и переключает сеть на DHCP.""" + """Задаёт cloud-init user/password и сеть DHCP.""" node = node or settings.pve_node _client().nodes(node).qemu(vmid).config.put( ciuser=ciuser, @@ -190,48 +204,37 @@ def configure_cloud_init(vmid: int, ciuser: str, cipassword: str, node: str = No def get_instance_ip(vmid: int, node: str = None) -> str: - """Возвращает основной IPv4 инстанса. - - Для VM используется QEMU Guest Agent. Если агент недоступен — возвращает - строку-плейсхолдер, чтобы UI мог отличить «не знаю» от «ошибка». - """ + """Возвращает основной IPv4 инстанса.""" node = node or settings.pve_node px = _client() - # 1. QEMU Guest Agent (требует установленного qemu-guest-agent в гостевой ОС). try: - ifaces = ( - px.nodes(node).qemu(vmid).agent.get("network-get-interfaces").get("result", []) - ) + ifaces = px.nodes(node).qemu(vmid).agent.get("network-get-interfaces").get("result", []) for iface in ifaces: if iface.get("name") == "lo": continue - for addr in iface.get("ip-addresses", []) or []: - ip = addr.get("ip-address", "") - if addr.get("ip-address-type") == "ipv4" and not ip.startswith("127."): + for address in iface.get("ip-addresses", []) or []: + ip = address.get("ip-address", "") + if address.get("ip-address-type") == "ipv4" and not ip.startswith("127."): return ip except Exception as exc: logger.debug("VM %s: guest-agent недоступен (%s)", vmid, exc) - - # 2. LXC — адрес можно увидеть через конфиг + интерфейсы внутри. try: - ifaces = px.nodes(node).lxc(vmid).interfaces.get() or [] - for iface in ifaces: - for ip in iface.get("ip-addresses", []) or []: + for iface in px.nodes(node).lxc(vmid).interfaces.get() or []: + for ip_data in iface.get("ip-addresses", []) or []: + ip = ip_data.get("ip-address", "") if not ip.startswith("127."): return ip except Exception as exc: logger.debug("LXC %s: интерфейсы недоступны (%s)", vmid, exc) - - # 3. Fallback: понятная заглушка вместо молчаливого «неизвестен». return "не определён (агент недоступен)" def get_live_stats(vmid: int, node: str = None) -> dict: - """Live-показатели (cpu%, mem_used, mem_total, uptime, status) для VM и LXC.""" + """Возвращает текущие показатели VM или LXC.""" node = node or settings.pve_node px = _client() - def _shape(status: dict) -> dict: + def shape(status: dict) -> dict: return { "cpu": round(status.get("cpu", 0) * 100, 1), "mem_used": status.get("mem", 0), @@ -242,21 +245,18 @@ def get_live_stats(vmid: int, node: str = None) -> dict: for endpoint in (px.nodes(node).qemu(vmid).status.current, px.nodes(node).lxc(vmid).status.current): try: - return _shape(endpoint.get()) + return shape(endpoint.get()) except Exception: continue return {"cpu": 0, "mem_used": 0, "mem_total": 0, "uptime": 0, "status": "unknown"} -# Алиас для обратной совместимости со старым кодом. get_live_stats_lxc = get_live_stats - - _VALID_ACTIONS = {"start", "stop", "shutdown", "reboot"} def guest_action(guest_type: str, vmid: int, action: str, node: str = None) -> None: - """Выполняет действие над VM/LXC: start | stop | shutdown | reboot.""" + """Выполняет действие над VM/LXC.""" if action not in _VALID_ACTIONS: raise ValueError(f"Неизвестное действие: {action}") node = node or settings.pve_node @@ -265,23 +265,42 @@ def guest_action(guest_type: str, vmid: int, action: str, node: str = None) -> N getattr(endpoint.status, action).post() +def _wait_guest_stopped(px: ProxmoxAPI, guest_type: str, vmid: int, node: str, timeout: int = 180) -> None: + """Ожидает, пока VM или LXC перейдёт в состояние stopped.""" + endpoint = px.nodes(node).qemu(vmid) if guest_type == "vm" else px.nodes(node).lxc(vmid) + deadline = time.time() + timeout + while time.time() < deadline: + status = endpoint.status.current.get().get("status") + if status == "stopped": + return + time.sleep(2) + raise TimeoutError(f"Инстанс {vmid} не остановился за {timeout} секунд") + + def delete_guest(guest_type: str, vmid: int, node: str = None) -> None: - """Удаляет VM или LXC, дожидаясь завершения задачи в Proxmox.""" + """Останавливает работающий инстанс и удаляет его.""" node = node or settings.pve_node px = _client() - if guest_type == "vm": - upid = px.nodes(node).qemu(vmid).delete() - else: - upid = px.nodes(node).lxc(vmid).delete() - # Proxmox API возвращает upid для отслеживания статуса задачи. - # Ждём завершения, чтобы инстанс действительно был удалён к моменту - # возврата ответа клиенту. + endpoint = px.nodes(node).qemu(vmid) if guest_type == "vm" else px.nodes(node).lxc(vmid) + status = endpoint.status.current.get().get("status") + + if status == "running": + try: + shutdown_upid = endpoint.status.shutdown.post(timeout=60) + if shutdown_upid: + _wait_task(px, node, shutdown_upid, timeout=120) + except Exception as exc: + logger.warning("Мягкая остановка %s %s не удалась: %s", guest_type, vmid, exc) + endpoint.status.stop.post() + _wait_guest_stopped(px, guest_type, vmid, node) + + upid = endpoint.delete() if upid: _wait_task(px, node, upid) def get_status(guest_type: str, vmid: int, node: str = None) -> dict: - """Текущий статус VM/LXC (running, stopped, …).""" + """Возвращает текущий статус VM/LXC.""" node = node or settings.pve_node px = _client() if guest_type == "vm":