Files
Proxmox-VPS-Panel/backend/app/proxmox_client.py
T
2026-07-25 02:31:06 +03:00

308 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.
Все методы — синхронные (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_mb=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 с экспоненциальной задержкой.
Начинаем с 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(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-шаблонов (.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" 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
px = _client()
if guest_type == "vm":
return px.nodes(node).qemu(vmid).status.current.get()
return px.nodes(node).lxc(vmid).status.current.get()
def get_vnc_ticket(guest_type: str, vmid: int, node: str = None) -> dict:
"""Запрашивает у Proxmox одноразовый тикет для VNC/websocket-консоли.
Тикет привязан к origin (схема+хост+порт), через который был сделан
запрос к Proxmox.
"""
node = node or settings.pve_node
px = _client()
endpoint = px.nodes(node).qemu(vmid) if guest_type == "vm" else px.nodes(node).lxc(vmid)
result = endpoint.vncproxy.post(websocket=1)
return {
"ticket": result["ticket"],
"port": result["port"],
"node": node,
"vmid": vmid,
"guest_type": guest_type,
"pve_host": settings.pve_host,
}