"""Обёртка над Proxmox REST API. Все методы — синхронные (proxmoxer работает через requests). Используйте внутри FastAPI через run_in_threadpool, если эндпоинт async. """ import logging import random import re import string import time from proxmoxer import ProxmoxAPI from .config import settings logger = logging.getLogger(__name__) def _client() -> ProxmoxAPI: """Создаёт клиент Proxmox API, аутентифицированный по API-токену.""" host = settings.pve_host.replace("https://", "").replace("http://", "").split(":")[0] return ProxmoxAPI( host, user=settings.pve_token_name.split("!")[0], token_name=settings.pve_token_name.split("!")[1], token_value=settings.pve_token_value, verify_ssl=settings.pve_verify_ssl, ) 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()) 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( newid=new_vmid, name=name, full=1, ) _wait_task(px, node, upid) 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) def create_lxc( new_vmid: int, name: str, template_volid: str, cores: int, memory_mb: int, disk_gb: int, storage: str = "local-lvm", node: str = None, ) -> str: """Создаёт LXC-контейнер из шаблона. Возвращает сгенерированный root-пароль.""" node = node or settings.pve_node px = _client() password = gen_password() upid = px.nodes(node).lxc.post( vmid=new_vmid, hostname=name, ostemplate=template_volid, cores=cores, memory=memory_mb, swap=memory_mb, rootfs=f"{storage}:{disk_gb}", password=password, net0="name=eth0,bridge=vmbr0,ip=dhcp", unprivileged=1, ) _wait_task(px, node, upid) return password def _wait_task(px: ProxmoxAPI, node: str, upid: str, timeout: int = 1200, poll: float = 2.0) -> None: """Ждёт завершения асинхронной задачи 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(delay) delay = min(delay * 1.5, 5.0) raise TimeoutError("Превышено время ожидания задачи Proxmox") _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() 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_size_from_config(cfg), }) return result def list_lxc_templates(node: str = None) -> list: """Возвращает список 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", ""): 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 _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: """Возвращает основной IPv4 инстанса. Для VM используется QEMU Guest Agent. Если агент недоступен — возвращает строку-плейсхолдер, чтобы UI мог отличить «не знаю» от «ошибка». """ 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", []) ) 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."): 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 []: 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.""" node = node or settings.pve_node px = _client() def _shape(status: dict) -> dict: 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"), } for endpoint in (px.nodes(node).qemu(vmid).status.current, px.nodes(node).lxc(vmid).status.current): try: 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.""" 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) 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": upid = px.nodes(node).qemu(vmid).delete() else: upid = px.nodes(node).lxc(vmid).delete() # Proxmox API возвращает upid для отслеживания статуса задачи. # Ждём завершения, чтобы инстанс действительно был удалён к моменту # возврата ответа клиенту. if upid: _wait_task(px, node, upid) 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": return px.nodes(node).qemu(vmid).status.current.get() return px.nodes(node).lxc(vmid).status.current.get()