diff --git a/backend/app/proxmox_client.py b/backend/app/proxmox_client.py index 871ff68..a5a40fd 100644 --- a/backend/app/proxmox_client.py +++ b/backend/app/proxmox_client.py @@ -1,3 +1,280 @@ +"""Обёртка над 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": + px.nodes(node).qemu(vmid).delete() + else: + px.nodes(node).lxc(vmid).delete() + + def get_status(guest_type: str, vmid: int, node: str = None) -> dict: """Текущий статус VM/LXC (running, stopped, …).""" node = node or settings.pve_node