Подготовлена поддержка остановки перед удалением и LXC-шаблонов по VMID

This commit is contained in:
2026-08-09 17:26:56 +03:00
parent 70b6603bbe
commit 89e44599cd
+99 -80
View File
@@ -1,8 +1,4 @@
"""Обёртка над Proxmox REST API. """Обёртка над Proxmox REST API."""
Все методы — синхронные (proxmoxer работает через requests). Используйте
внутри FastAPI через run_in_threadpool, если эндпоинт async.
"""
import logging import logging
import random import random
@@ -30,34 +26,52 @@ def _client() -> ProxmoxAPI:
def gen_password(length: int = 14) -> str: def gen_password(length: int = 14) -> str:
"""Генерирует криптостойкий пароль из букв и цифр.""" """Генерирует пароль из букв и цифр."""
alphabet = string.ascii_letters + string.digits alphabet = string.ascii_letters + string.digits
return "".join(random.choice(alphabet) for _ in range(length)) return "".join(random.choice(alphabet) for _ in range(length))
def get_next_vmid() -> int: def get_next_vmid() -> int:
"""Берёт следующий свободный VMID у самого Proxmox.""" """Берёт следующий свободный VMID у Proxmox."""
px = _client() return int(_client().cluster.nextid.get())
return int(px.cluster.nextid.get())
def clone_vm(source_vmid: int, new_vmid: int, name: str, node: str = None, storage: str = None) -> None: def clone_vm(source_vmid: int, new_vmid: int, name: str, node: str = None, storage: str = None) -> None:
"""Клонирует VM-шаблон в новую VM.""" """Клонирует VM-шаблон в новую VM."""
node = node or settings.pve_node node = node or settings.pve_node
px = _client() px = _client()
upid = px.nodes(node).qemu(source_vmid).clone.post( 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 clone_lxc(source_vmid: int, new_vmid: int, name: str, cores: int, memory_mb: int, password: str, node: str = None) -> str:
"""Клонирует LXC-шаблон по VMID и задаёт ресурсы и пароль."""
node = node or settings.pve_node
px = _client()
upid = px.nodes(node).lxc(source_vmid).clone.post(
newid=new_vmid, newid=new_vmid,
name=name, hostname=name,
full=1, full=1,
) )
_wait_task(px, node, upid) _wait_task(px, node, upid)
px.nodes(node).lxc(new_vmid).config.put(
hostname=name,
cores=cores,
memory=memory_mb,
swap=memory_mb,
)
# У LXC отдельный endpoint для смены пароля root.
px.nodes(node).lxc(new_vmid).passwd.post(password=password)
return password
def resize_vm(vmid: int, cores: int, memory_mb: int, node: str = None) -> None: def resize_vm(vmid: int, cores: int, memory_mb: int, node: str = None) -> None:
"""Изменяет количество vCPU и RAM у VM.""" """Изменяет количество vCPU и RAM у VM."""
node = node or settings.pve_node node = node or settings.pve_node
px = _client() _client().nodes(node).qemu(vmid).config.put(cores=cores, memory=memory_mb)
px.nodes(node).qemu(vmid).config.put(cores=cores, memory=memory_mb)
def create_lxc( def create_lxc(
@@ -70,7 +84,7 @@ def create_lxc(
storage: str = "local-lvm", storage: str = "local-lvm",
node: str = None, node: str = None,
) -> str: ) -> str:
"""Создаёт LXC-контейнер из шаблона. Возвращает сгенерированный root-пароль.""" """Создаёт LXC из архива vzdump-шаблона."""
node = node or settings.pve_node node = node or settings.pve_node
px = _client() px = _client()
password = gen_password() password = gen_password()
@@ -91,7 +105,7 @@ def create_lxc(
def _wait_task(px: ProxmoxAPI, node: str, upid: str, timeout: int = 1200, poll: float = 2.0) -> None: def _wait_task(px: ProxmoxAPI, node: str, upid: str, timeout: int = 1200, poll: float = 2.0) -> None:
"""Ждёт завершения асинхронной задачи Proxmox с экспоненциальной задержкой.""" """Ждёт завершения асинхронной задачи Proxmox."""
start = time.time() start = time.time()
delay = poll delay = poll
while time.time() - start < timeout: while time.time() - start < timeout:
@@ -109,18 +123,17 @@ _DISK_KEYS = ("scsi0", "virtio0", "ide0", "sata0")
def _disk_size_from_config(cfg: dict) -> int: def _disk_size_from_config(cfg: dict) -> int:
"""Извлекает размер диска (ГБ) из конфигурации VM, иначе 10.""" """Извлекает размер диска VM в ГБ."""
for k in _DISK_KEYS: for key in _DISK_KEYS:
v = cfg.get(k, "") value = cfg.get(key, "")
if ",size=" in v: match = re.search(r"size=(\d+)G", value)
m = re.search(r"size=(\d+)G", v) if match:
if m: return int(match.group(1))
return int(m.group(1))
return 10 return 10
def list_vm_templates(node: str = None) -> list: def list_vm_templates(node: str = None) -> list:
"""Возвращает список VM-шаблонов (template=1) с параметрами.""" """Возвращает список VM-шаблонов."""
node = node or settings.pve_node node = node or settings.pve_node
px = _client() px = _client()
result = [] result = []
@@ -138,49 +151,50 @@ def list_vm_templates(node: str = None) -> list:
def list_lxc_templates(node: str = None) -> list: def list_lxc_templates(node: str = None) -> list:
"""Возвращает список LXC-шаблонов из всех хранилищ.""" """Возвращает архивные LXC-шаблоны из доступных хранилищ."""
node = node or settings.pve_node node = node or settings.pve_node
px = _client() px = _client()
result = [] result = []
for st in px.nodes(node).storage.get(): for storage in px.nodes(node).storage.get():
if "vztmpl" not in st.get("content", ""): if "vztmpl" not in storage.get("content", ""):
continue continue
try: try:
for item in px.nodes(node).storage(st["storage"]).content.get(): for item in px.nodes(node).storage(storage["storage"]).content.get():
if item.get("content") == "vztmpl": if item.get("content") != "vztmpl":
name = item["volid"].split("/")[-1] continue
for ext in (".tar.zst", ".tar.gz", ".tar.xz"): name = item["volid"].split("/")[-1]
name = name.replace(ext, "") for extension in (".tar.zst", ".tar.gz", ".tar.xz"):
result.append({ name = name.replace(extension, "")
"volid": item["volid"], result.append({
"name": name, "volid": item["volid"],
"storage": st["storage"], "name": name,
"size_mb": round(item.get("size", 0) / (1024 ** 2), 1), "storage": storage["storage"],
}) "size_mb": round(item.get("size", 0) / (1024 ** 2), 1),
})
except Exception as exc: except Exception as exc:
logger.warning("Не удалось прочитать шаблоны с хранилища %s: %s", st["storage"], exc) logger.warning("Не удалось прочитать шаблоны с хранилища %s: %s", storage["storage"], exc)
return result return result
def resize_disk(vmid: int, disk_gb: int, node: str = None) -> None: def resize_disk(vmid: int, disk_gb: int, node: str = None) -> None:
"""Увеличивает диск VM до нужного размера (только в большую сторону).""" """Увеличивает диск VM до указанного размера."""
node = node or settings.pve_node node = node or settings.pve_node
px = _client() px = _client()
config = px.nodes(node).qemu(vmid).config.get() config = px.nodes(node).qemu(vmid).config.get()
for key in _DISK_KEYS: for key in _DISK_KEYS:
val = config.get(key, "") value = config.get(key, "")
if not val: if not value:
continue continue
m = re.search(r"size=(\d+)G", val) match = re.search(r"size=(\d+)G", value)
current = int(m.group(1)) if m else 0 current = int(match.group(1)) if match else 0
if disk_gb > current: if disk_gb > current:
px.nodes(node).qemu(vmid).resize.put(disk=key, size=f"+{disk_gb - current}G") px.nodes(node).qemu(vmid).resize.put(disk=key, size=f"+{disk_gb - current}G")
return return
logger.warning("У VM %s не найден ни один диск для resize", vmid) logger.warning("У VM %s не найден диск для resize", vmid)
def configure_cloud_init(vmid: int, ciuser: str, cipassword: str, node: str = None) -> None: def configure_cloud_init(vmid: int, ciuser: str, cipassword: str, node: str = None) -> None:
"""Задаёт cloud-init user/password и переключает сеть на DHCP.""" """Задаёт cloud-init user/password и сеть DHCP."""
node = node or settings.pve_node node = node or settings.pve_node
_client().nodes(node).qemu(vmid).config.put( _client().nodes(node).qemu(vmid).config.put(
ciuser=ciuser, ciuser=ciuser,
@@ -190,48 +204,37 @@ def configure_cloud_init(vmid: int, ciuser: str, cipassword: str, node: str = No
def get_instance_ip(vmid: int, node: str = None) -> str: def get_instance_ip(vmid: int, node: str = None) -> str:
"""Возвращает основной IPv4 инстанса. """Возвращает основной IPv4 инстанса."""
Для VM используется QEMU Guest Agent. Если агент недоступен — возвращает
строку-плейсхолдер, чтобы UI мог отличить «не знаю» от «ошибка».
"""
node = node or settings.pve_node node = node or settings.pve_node
px = _client() px = _client()
# 1. QEMU Guest Agent (требует установленного qemu-guest-agent в гостевой ОС).
try: try:
ifaces = ( ifaces = px.nodes(node).qemu(vmid).agent.get("network-get-interfaces").get("result", [])
px.nodes(node).qemu(vmid).agent.get("network-get-interfaces").get("result", [])
)
for iface in ifaces: for iface in ifaces:
if iface.get("name") == "lo": if iface.get("name") == "lo":
continue continue
for addr in iface.get("ip-addresses", []) or []: for address in iface.get("ip-addresses", []) or []:
ip = addr.get("ip-address", "") ip = address.get("ip-address", "")
if addr.get("ip-address-type") == "ipv4" and not ip.startswith("127."): if address.get("ip-address-type") == "ipv4" and not ip.startswith("127."):
return ip return ip
except Exception as exc: except Exception as exc:
logger.debug("VM %s: guest-agent недоступен (%s)", vmid, exc) logger.debug("VM %s: guest-agent недоступен (%s)", vmid, exc)
# 2. LXC — адрес можно увидеть через конфиг + интерфейсы внутри.
try: try:
ifaces = px.nodes(node).lxc(vmid).interfaces.get() or [] for iface in px.nodes(node).lxc(vmid).interfaces.get() or []:
for iface in ifaces: for ip_data in iface.get("ip-addresses", []) or []:
for ip in iface.get("ip-addresses", []) or []: ip = ip_data.get("ip-address", "")
if not ip.startswith("127."): if not ip.startswith("127."):
return ip return ip
except Exception as exc: except Exception as exc:
logger.debug("LXC %s: интерфейсы недоступны (%s)", vmid, exc) logger.debug("LXC %s: интерфейсы недоступны (%s)", vmid, exc)
# 3. Fallback: понятная заглушка вместо молчаливого «неизвестен».
return "не определён (агент недоступен)" return "не определён (агент недоступен)"
def get_live_stats(vmid: int, node: str = None) -> dict: def get_live_stats(vmid: int, node: str = None) -> dict:
"""Live-показатели (cpu%, mem_used, mem_total, uptime, status) для VM и LXC.""" """Возвращает текущие показатели VM или LXC."""
node = node or settings.pve_node node = node or settings.pve_node
px = _client() px = _client()
def _shape(status: dict) -> dict: def shape(status: dict) -> dict:
return { return {
"cpu": round(status.get("cpu", 0) * 100, 1), "cpu": round(status.get("cpu", 0) * 100, 1),
"mem_used": status.get("mem", 0), "mem_used": status.get("mem", 0),
@@ -242,21 +245,18 @@ def get_live_stats(vmid: int, node: str = None) -> dict:
for endpoint in (px.nodes(node).qemu(vmid).status.current, px.nodes(node).lxc(vmid).status.current): for endpoint in (px.nodes(node).qemu(vmid).status.current, px.nodes(node).lxc(vmid).status.current):
try: try:
return _shape(endpoint.get()) return shape(endpoint.get())
except Exception: except Exception:
continue continue
return {"cpu": 0, "mem_used": 0, "mem_total": 0, "uptime": 0, "status": "unknown"} return {"cpu": 0, "mem_used": 0, "mem_total": 0, "uptime": 0, "status": "unknown"}
# Алиас для обратной совместимости со старым кодом.
get_live_stats_lxc = get_live_stats get_live_stats_lxc = get_live_stats
_VALID_ACTIONS = {"start", "stop", "shutdown", "reboot"} _VALID_ACTIONS = {"start", "stop", "shutdown", "reboot"}
def guest_action(guest_type: str, vmid: int, action: str, node: str = None) -> None: def guest_action(guest_type: str, vmid: int, action: str, node: str = None) -> None:
"""Выполняет действие над VM/LXC: start | stop | shutdown | reboot.""" """Выполняет действие над VM/LXC."""
if action not in _VALID_ACTIONS: if action not in _VALID_ACTIONS:
raise ValueError(f"Неизвестное действие: {action}") raise ValueError(f"Неизвестное действие: {action}")
node = node or settings.pve_node node = node or settings.pve_node
@@ -265,23 +265,42 @@ def guest_action(guest_type: str, vmid: int, action: str, node: str = None) -> N
getattr(endpoint.status, action).post() getattr(endpoint.status, action).post()
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: def delete_guest(guest_type: str, vmid: int, node: str = None) -> None:
"""Удаляет VM или LXC, дожидаясь завершения задачи в Proxmox.""" """Останавливает работающий инстанс и удаляет его."""
node = node or settings.pve_node node = node or settings.pve_node
px = _client() px = _client()
if guest_type == "vm": endpoint = px.nodes(node).qemu(vmid) if guest_type == "vm" else px.nodes(node).lxc(vmid)
upid = px.nodes(node).qemu(vmid).delete() status = endpoint.status.current.get().get("status")
else:
upid = px.nodes(node).lxc(vmid).delete() if status == "running":
# Proxmox API возвращает upid для отслеживания статуса задачи. try:
# Ждём завершения, чтобы инстанс действительно был удалён к моменту shutdown_upid = endpoint.status.shutdown.post(timeout=60)
# возврата ответа клиенту. if shutdown_upid:
_wait_task(px, node, shutdown_upid, timeout=120)
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)
upid = endpoint.delete()
if upid: if upid:
_wait_task(px, node, upid) _wait_task(px, node, upid)
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."""
node = node or settings.pve_node node = node or settings.pve_node
px = _client() px = _client()
if guest_type == "vm": if guest_type == "vm":