Обновление файла

This commit is contained in:
2026-07-25 03:29:36 +03:00
parent a2069f2133
commit 5df0cb9cbf
+1 -301
View File
@@ -1,307 +1,7 @@
"""Обёртка над 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: def get_status(guest_type: str, vmid: int, node: str = None) -> dict:
"""Текущий статус VM/LXC (running, stopped, …).""" """Текущий статус VM/LXC (running, stopped, …)."""
node = node or settings.pve_node node = node or settings.pve_node
px = _client() px = _client()
if guest_type == "vm": if guest_type == "vm":
return px.nodes(node).qemu(vmid).status.current.get() return px.nodes(node).qemu(vmid).status.current.get()
return px.nodes(node).lxc(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,
}