Files
Proxmox-VPS-Panel/backend/app/proxmox_client.py
T

326 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Обёртка над Proxmox REST API."""
import logging
import random
import re
import string
import time
from proxmoxer import ProxmoxAPI
from proxmoxer.core import ResourceException
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."""
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()
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 resize_vm(vmid: int, cores: int, memory_mb: int, node: str = None) -> None:
"""Изменяет количество vCPU и RAM у VM."""
node = node or settings.pve_node
_client().nodes(node).qemu(vmid).config.put(cores=cores, memory=memory_mb)
def create_lxc(
new_vmid: int,
name: str,
template_volid: str,
password: str,
cores: int,
memory_mb: int,
disk_gb: int,
storage: str = "SSD",
node: str = None,
) -> str:
"""Создаёт LXC из архива: ostemplate → passwd → resize → start."""
node = node or settings.pve_node
px = _client()
upid = px.nodes(node).lxc.post(
vmid=new_vmid,
hostname=name,
ostemplate=template_volid,
cores=cores,
memory=memory_mb,
swap=memory_mb,
net0="name=eth0,bridge=vmbr0,ip=dhcp",
unprivileged=1,
password=password,
start=0,
)
_wait_task(px, node, upid)
px.nodes(node).lxc(new_vmid).passwd.post(password=password)
px.nodes(node).lxc(new_vmid).resize.put(
disk="rootfs",
size=f"{storage}:{disk_gb}",
)
upid = px.nodes(node).lxc(new_vmid).status.start.post()
if upid:
_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 в ГБ."""
for key in _DISK_KEYS:
match = re.search(r"size=(\d+)G", cfg.get(key, ""))
if match:
return int(match.group(1))
return 10
def list_vm_templates(node: str = None) -> list:
"""Возвращает список VM-шаблонов."""
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-шаблоны (vztmpl)."""
node = node or settings.pve_node
px = _client()
result = []
for storage in px.nodes(node).storage.get():
if "vztmpl" not in storage.get("content", ""):
continue
try:
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", storage["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:
value = config.get(key, "")
if not value:
continue
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)
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 инстанса."""
node = node or settings.pve_node
px = _client()
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 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)
try:
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)
return "не определён (агент недоступен)"
def get_live_stats(vmid: int, node: str = None) -> dict:
"""Возвращает текущие показатели 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"}
_VALID_ACTIONS = {"start", "stop", "shutdown", "reboot"}
def guest_action(guest_type: str, vmid: int, action: str, node: str = None) -> None:
"""Выполняет действие над VM/LXC."""
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 _is_missing_guest_error(exc: Exception) -> bool:
"""Проверяет, что Proxmox сообщает об отсутствии VM/LXC."""
message = str(exc).lower()
return (
"does not exist" in message
or "not found" in message
or "configuration file" in message and "does not exist" in message
)
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, операция считается успешной.
Это позволяет backend удалить устаревшую запись из собственной БД.
"""
node = node or settings.pve_node
px = _client()
endpoint = px.nodes(node).qemu(vmid) if guest_type == "vm" else px.nodes(node).lxc(vmid)
try:
status = endpoint.status.current.get().get("status")
except ResourceException as exc:
if _is_missing_guest_error(exc):
logger.info("%s %s уже отсутствует в Proxmox — считаем удалённым", guest_type, vmid)
return
raise
if status == "running":
try:
shutdown_upid = endpoint.status.shutdown.post(timeout=60)
if shutdown_upid:
_wait_task(px, node, shutdown_upid, timeout=120)
except ResourceException as exc:
if not _is_missing_guest_error(exc):
logger.warning("Мягкая остановка %s %s не удалась: %s", guest_type, vmid, exc)
endpoint.status.stop.post()
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)
try:
upid = endpoint.delete()
except ResourceException as exc:
if _is_missing_guest_error(exc):
logger.info("%s %s исчез во время удаления — считаем удалённым", guest_type, vmid)
return
raise
if upid:
_wait_task(px, node, upid)
def get_status(guest_type: str, vmid: int, node: str = None) -> dict:
"""Возвращает текущий статус VM/LXC."""
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()