diff --git a/backend/app/proxmox_client.py b/backend/app/proxmox_client.py index 8b4fed6..5fdb505 100644 --- a/backend/app/proxmox_client.py +++ b/backend/app/proxmox_client.py @@ -1,4 +1,12 @@ +"""Обёртка над Proxmox REST API. + +Все методы — синхронные (proxmoxer работает через requests). Используйте +внутри FastAPI через run_in_threadpool, если эндпоинт async. +""" + +import logging import random +import re import string import time @@ -6,6 +14,8 @@ from proxmoxer import ProxmoxAPI from .config import settings +logger = logging.getLogger(__name__) + def _client() -> ProxmoxAPI: """Создаёт клиент Proxmox API, аутентифицированный по API-токену.""" @@ -20,17 +30,19 @@ 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 (гарантированно не занят).""" + """Берёт следующий свободный VMID у самого Proxmox (гарантированно не занят).""" px = _client() return int(px.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( @@ -42,6 +54,7 @@ def clone_vm(source_vmid: int, new_vmid: int, name: str, node: str = None, stora 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) @@ -77,172 +90,152 @@ def create_lxc( return password -def _wait_task(px: ProxmoxAPI, node: str, upid: str, timeout: int = 1200) -> None: - """Ждёт завершения асинхронной задачи Proxmox (клонирование, создание и т.п.).""" +def _wait_task(px: ProxmoxAPI, node: str, upid: str, timeout: int = 1200, poll: float = 2.0) -> None: + """Ждёт завершения асинхронной задачи Proxmox с экспоненциальной задержкой. + + Начинаем с poll=1s и удваиваем до 5s — снижает нагрузку на Proxmox + при долгом клонировании больших дисков. + """ start = time.time() + delay = poll while time.time() - start < timeout: status = px.nodes(node).tasks(upid).status.get() if status.get("status") == "stopped": if status.get("exitstatus") != "OK": raise RuntimeError(f"Задача Proxmox завершилась с ошибкой: {status}") return - time.sleep(2) + time.sleep(delay) + delay = min(delay * 1.5, 5.0) raise TimeoutError("Превышено время ожидания задачи Proxmox") -def list_vm_templates(node=None): +_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)) + return 10 + + +def list_vm_templates(node: str = None) -> list: + """Возвращает список VM-шаблонов (template=1) с параметрами.""" node = node or settings.pve_node px = _client() result = [] for vm in px.nodes(node).qemu.get(): if vm.get("template") == 1: cfg = px.nodes(node).qemu(vm["vmid"]).config.get() - disk = 10 - for k in ("scsi0","virtio0","ide0","sata0"): - v = cfg.get(k,"") - if ",size=" in v: - import re as _re - m = _re.search(r"size=(\d+)G", v) - if m: disk = int(m.group(1)); break result.append({ - "vmid": vm["vmid"], "name": vm.get("name",""), - "cores": int(cfg.get("cores",1)), "memory_mb": int(cfg.get("memory",1024)), - "disk_gb": disk, + "vmid": vm["vmid"], + "name": vm.get("name", ""), + "cores": int(cfg.get("cores", 1)), + "memory_mb": int(cfg.get("memory", 1024)), + "disk_gb": _disk_size_from_config(cfg), }) return result -def list_lxc_templates(node=None): + +def list_lxc_templates(node: str = None) -> list: + """Возвращает список LXC-шаблонов (.tar.zst/.tar.gz/.tar.xz) из всех хранилищ.""" node = node or settings.pve_node px = _client() result = [] for st in px.nodes(node).storage.get(): - if "vztmpl" in st.get("content",""): - 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)}) - except: pass + if "vztmpl" not in st.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), + }) + except Exception as exc: + logger.warning("Не удалось прочитать шаблоны с хранилища %s: %s", st["storage"], exc) return result + def resize_disk(vmid: int, disk_gb: int, node: str = None) -> None: + """Увеличивает диск VM до нужного размера (только в большую сторону).""" node = node or settings.pve_node px = _client() config = px.nodes(node).qemu(vmid).config.get() - for key in ("scsi0","virtio0","ide0","sata0"): - val = config.get(key,"") - if val: - import re as _re - m = _re.search(r"size=(\d+)G", val) - current = int(m.group(1)) if m else 0 - if disk_gb > current: - px.nodes(node).qemu(vmid).resize.put(disk=key, size=f"+{disk_gb - current}G") - break + for key in _DISK_KEYS: + val = config.get(key, "") + if not val: + continue + m = re.search(r"size=(\d+)G", val) + current = int(m.group(1)) if m 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) + def configure_cloud_init(vmid: int, ciuser: str, cipassword: str, node: str = None) -> None: + """Задаёт cloud-init user/password и переключает сеть на DHCP.""" node = node or settings.pve_node - _client().nodes(node).qemu(vmid).config.put(ciuser=ciuser, cipassword=cipassword, ipconfig0="ip=dhcp") - -def get_instance_ip(vmid: int, node: str = None) -> str: - """Возвращает IP-адрес VM/LXC из гостевого агента или сетевой конфигурации.""" - node = node or settings.pve_node - px = _client() - # Пробуем через гостевой агент (QEMU Guest Agent) - try: - ifaces = px.nodes(node).qemu(vmid).agent.get("network-get-interfaces").get("result", []) - for iface in ifaces: - if iface.get("name") != "lo" and iface.get("ip-addresses"): - for addr in iface["ip-addresses"]: - if addr.get("ip-address-type") == "ipv4" and not addr.get("ip-address", "").startswith("127."): - return addr["ip-address"] - except Exception: - pass - # Пробуем через LXC (если контейнер) - try: - config = px.nodes(node).lxc(vmid).config.get() - # Просто пробуем разные пути для LXC - except Exception: - pass - # Пробуем через DHCP-лиз Proxmox - try: - config = px.nodes(node).qemu(vmid).config.get() - net = config.get("net0", "") - if "dhcp" in net.lower(): - return "dhcp (агент не установлен)" - except Exception: - pass - return "неизвестен" + _client().nodes(node).qemu(vmid).config.put( + ciuser=ciuser, + cipassword=cipassword, + ipconfig0="ip=dhcp", + ) def get_instance_ip(vmid: int, node: str = None) -> str: - """Возвращает IP-адрес VM/LXC из гостевого агента или сетевой конфигурации.""" + """Возвращает основной IPv4 инстанса. + + Для VM используется QEMU Guest Agent. Если агент недоступен — возвращает + строку-плейсхолдер, чтобы UI мог отличить «не знаю» от «ошибка». + """ node = node or settings.pve_node px = _client() - # Пробуем через гостевой агент (QEMU Guest Agent) + # 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" and iface.get("ip-addresses"): - for addr in iface["ip-addresses"]: - if addr.get("ip-address-type") == "ipv4" and not addr.get("ip-address", "").startswith("127."): - return addr["ip-address"] - except Exception: - pass - # Пробуем через LXC (если контейнер) - try: - config = px.nodes(node).lxc(vmid).config.get() - # Просто пробуем разные пути для LXC - except Exception: - pass - # Пробуем через DHCP-лиз Proxmox - try: - config = px.nodes(node).qemu(vmid).config.get() - net = config.get("net0", "") - if "dhcp" in net.lower(): - return "dhcp (агент не установлен)" - except Exception: - pass - return "неизвестен" + 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."): + return ip + except Exception as exc: + logger.debug("VM %s: guest-agent недоступен (%s)", vmid, exc) - -def get_instance_ip(vmid: int, node: str = None) -> str: - """Возвращает IP-адрес VM/LXC из гостевого агента или сетевой конфигурации.""" - node = node or settings.pve_node - px = _client() - # Пробуем через гостевой агент (QEMU Guest Agent) + # 2. LXC — адрес можно увидеть через конфиг + интерфейсы внутри. try: - ifaces = px.nodes(node).qemu(vmid).agent.get("network-get-interfaces").get("result", []) + ifaces = px.nodes(node).lxc(vmid).interfaces.get() or [] for iface in ifaces: - if iface.get("name") != "lo" and iface.get("ip-addresses"): - for addr in iface["ip-addresses"]: - if addr.get("ip-address-type") == "ipv4" and not addr.get("ip-address", "").startswith("127."): - return addr["ip-address"] - except Exception: - pass - # Пробуем через LXC (если контейнер) - try: - config = px.nodes(node).lxc(vmid).config.get() - # Просто пробуем разные пути для LXC - except Exception: - pass - # Пробуем через DHCP-лиз Proxmox - try: - config = px.nodes(node).qemu(vmid).config.get() - net = config.get("net0", "") - if "dhcp" in net.lower(): - return "dhcp (агент не установлен)" - except Exception: - pass - return "неизвестен" + for ip in iface.get("ip-addresses", []) or []: + 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%, ram_used, ram_total, uptime.""" + """Live-показатели (cpu%, mem_used, mem_total, uptime, status) для VM и LXC.""" node = node or settings.pve_node px = _client() - try: - status = px.nodes(node).qemu(vmid).status.current.get() + + def _shape(status: dict) -> dict: return { "cpu": round(status.get("cpu", 0) * 100, 1), "mem_used": status.get("mem", 0), @@ -250,43 +243,34 @@ def get_live_stats(vmid: int, node: str = None) -> dict: "uptime": status.get("uptime", 0), "status": status.get("status", "unknown"), } - except Exception: + + for endpoint in (px.nodes(node).qemu(vmid).status.current, px.nodes(node).lxc(vmid).status.current): try: - status = px.nodes(node).lxc(vmid).status.current.get() - return { - "cpu": round(status.get("cpu", 0) * 100, 1), - "mem_used": status.get("mem", 0), - "mem_total": status.get("maxmem", 0), - "uptime": status.get("uptime", 0), - "status": status.get("status", "unknown"), - } + return _shape(endpoint.get()) except Exception: - return {"cpu": 0, "mem_used": 0, "mem_total": 0, "uptime": 0, "status": "unknown"} + continue + return {"cpu": 0, "mem_used": 0, "mem_total": 0, "uptime": 0, "status": "unknown"} -def get_live_stats_lxc(vmid: int, node: str = None) -> dict: - """Live-показатели LXC контейнера.""" - return get_live_stats(vmid, node) +# Алиас для обратной совместимости со старым кодом. +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: - """action: start | stop | shutdown | reboot""" + """Выполняет действие над VM/LXC: start | stop | shutdown | reboot.""" + if action not in _VALID_ACTIONS: + raise ValueError(f"Неизвестное действие: {action}") node = node or settings.pve_node px = _client() endpoint = px.nodes(node).qemu(vmid) if guest_type == "vm" else px.nodes(node).lxc(vmid) - if action == "start": - endpoint.status.start.post() - elif action == "stop": - endpoint.status.stop.post() - elif action == "shutdown": - endpoint.status.shutdown.post() - elif action == "reboot": - endpoint.status.reboot.post() - else: - raise ValueError(f"Неизвестное действие: {action}") + getattr(endpoint.status, action).post() 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": @@ -296,6 +280,7 @@ def delete_guest(guest_type: str, vmid: int, node: str = None) -> None: def get_status(guest_type: str, vmid: int, node: str = None) -> dict: + """Текущий статус VM/LXC (running, stopped, …).""" node = node or settings.pve_node px = _client() if guest_type == "vm": @@ -304,7 +289,7 @@ def get_status(guest_type: str, vmid: int, node: str = None) -> dict: def get_vnc_ticket(guest_type: str, vmid: int, node: str = None) -> dict: - """Запрашивает у Proxmox тикет для VNC/websocket-консоли.""" + """Запрашивает у Proxmox одноразовый тикет для VNC/websocket-консоли.""" node = node or settings.pve_node px = _client() endpoint = px.nodes(node).qemu(vmid) if guest_type == "vm" else px.nodes(node).lxc(vmid)