From 175f1c441ac919af33b9c8872ab4fd0ade3f6d61 Mon Sep 17 00:00:00 2001 From: host Date: Sun, 9 Aug 2026 14:46:55 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=20Proxmox=20client=20=D0=B4=D0=BB=D1=8F=20VM=20=D0=B8=20?= =?UTF-8?q?LXC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/proxmox_client.py | 109 ++++++++++++++++++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 backend/app/proxmox_client.py diff --git a/backend/app/proxmox_client.py b/backend/app/proxmox_client.py new file mode 100644 index 0000000..249c7d7 --- /dev/null +++ b/backend/app/proxmox_client.py @@ -0,0 +1,109 @@ +import re +import time +from urllib.parse import urlparse + +from proxmoxer import ProxmoxAPI + +from .config import settings + + +def _client() -> ProxmoxAPI: + """Создаёт Proxmox API client по отдельному токену панели.""" + parsed = urlparse(settings.pve_host) + if not parsed.hostname: + raise RuntimeError("PVE_HOST имеет некорректный формат") + if "!" not in settings.pve_token_name: + raise RuntimeError("PVE_TOKEN_NAME должен иметь формат user@realm!token") + user, token_name = settings.pve_token_name.split("!", 1) + return ProxmoxAPI( + parsed.hostname, + port=parsed.port or 8006, + user=user, + token_name=token_name, + token_value=settings.pve_token_value.get_secret_value(), + verify_ssl=settings.pve_verify_ssl, + ) + + +def _wait_task(client: ProxmoxAPI, node: str, upid: str, timeout: int = 1800) -> None: + """Ожидает завершения задачи Proxmox с ограничением времени.""" + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + task = client.nodes(node).tasks(upid).status.get() + if task.get("status") == "stopped": + if task.get("exitstatus") != "OK": + raise RuntimeError("Задача Proxmox завершилась с ошибкой") + return + time.sleep(2) + raise TimeoutError("Превышено время ожидания задачи Proxmox") + + +def next_vmid() -> int: + """Запрашивает следующий свободный VMID у Proxmox.""" + return int(_client().cluster.nextid.get()) + + +def provision_vm( + source_vmid: int, + vmid: int, + name: str, + cores: int, + memory_mb: int, + disk_gb: int, + username: str, + password: str, +) -> None: + """Клонирует cloud-init VM, настраивает ресурсы и запускает её.""" + client = _client() + node = settings.pve_node + params = {"newid": vmid, "name": name, "full": 1} + if settings.pve_vm_storage: + params["storage"] = settings.pve_vm_storage + upid = client.nodes(node).qemu(source_vmid).clone.post(**params) + _wait_task(client, node, upid) + client.nodes(node).qemu(vmid).config.put( + cores=cores, + memory=memory_mb, + ciuser=username, + cipassword=password, + ipconfig0="ip=dhcp", + ) + # Увеличиваем диск только если шаблон меньше заданного размера. + config = client.nodes(node).qemu(vmid).config.get() + for key in ("scsi0", "virtio0", "sata0", "ide0"): + value = config.get(key, "") + match = re.search(r"size=(\d+)G", value) + if match and disk_gb > int(match.group(1)): + client.nodes(node).qemu(vmid).resize.put( + disk=key, size=f"+{disk_gb - int(match.group(1))}G" + ) + break + client.nodes(node).qemu(vmid).status.start.post() + + +def provision_lxc( + source_template: str, + vmid: int, + name: str, + cores: int, + memory_mb: int, + disk_gb: int, + password: str, +) -> None: + """Создаёт LXC из CT-шаблона и запускает его с DHCP.""" + client = _client() + node = settings.pve_node + upid = client.nodes(node).lxc.post( + vmid=vmid, + hostname=name, + ostemplate=source_template, + cores=cores, + memory=memory_mb, + swap=memory_mb, + rootfs=f"{settings.pve_lxc_storage}:{disk_gb}", + password=password, + net0=f"name=eth0,bridge={settings.pve_bridge},ip=dhcp", + unprivileged=1, + ) + _wait_task(client, node, upid) + client.nodes(node).lxc(vmid).status.start.post()