fix(delete): считать уже отсутствующий в Proxmox ресурс успешно удалённым

This commit is contained in:
2026-08-09 22:56:21 +03:00
parent 3e110c4e30
commit cbfe4139e3
+39 -33
View File
@@ -7,6 +7,7 @@ import string
import time import time
from proxmoxer import ProxmoxAPI from proxmoxer import ProxmoxAPI
from proxmoxer.core import ResourceException
from .config import settings from .config import settings
@@ -26,7 +27,7 @@ def _client() -> ProxmoxAPI:
def gen_password(length: int = 14) -> str: def gen_password(length: int = 14) -> str:
"""Генерирует пароль из букв и цифр (для VM-cloud-init, если пользователь не задал).""" """Генерирует пароль из букв и цифр."""
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))
@@ -64,25 +65,9 @@ def create_lxc(
storage: str = "local-lvm", storage: str = "local-lvm",
node: str = None, node: str = None,
) -> str: ) -> str:
"""Создаёт LXC из архивного шаблона и запускает его. """Создаёт LXC из архива: ostemplate → passwd → resize → start."""
Шаги (каждый — отдельный запрос к Proxmox API):
1. Создаёт LXC через ``ostemplate`` (без запуска).
2. Задаёт root-пароль через ``/passwd``.
3. Создаёт rootfs указанного размера через ``/resize``.
4. Запускает контейнер через ``/status/start``.
``password`` приходит из формы (клиент задаёт сам) и сохраняется в БД
для одноразового показа в карточке инстанса.
LXC создаётся ТОЛЬКО из архивного шаблона (vztmpl на storage).
Клонирование существующего LXC по VMID не поддерживается.
"""
node = node or settings.pve_node node = node or settings.pve_node
px = _client() px = _client()
# 1. Создаём LXC через ostemplate. start=0 — не запускаем, чтобы успеть
# задать пароль и диск нужного размера до старта.
upid = px.nodes(node).lxc.post( upid = px.nodes(node).lxc.post(
vmid=new_vmid, vmid=new_vmid,
hostname=name, hostname=name,
@@ -96,23 +81,14 @@ def create_lxc(
start=0, start=0,
) )
_wait_task(px, node, upid) _wait_task(px, node, upid)
# 2. Задаём пароль через отдельный endpoint /passwd.
# Поле password в POST /lxc на некоторых версиях Proxmox игнорируется,
# поэтому дублируем через /passwd — это идемпотентная операция.
px.nodes(node).lxc(new_vmid).passwd.post(password=password) px.nodes(node).lxc(new_vmid).passwd.post(password=password)
# 3. Создаём rootfs указанного размера (storage:size_G).
px.nodes(node).lxc(new_vmid).resize.put( px.nodes(node).lxc(new_vmid).resize.put(
disk="rootfs", disk="rootfs",
size=f"{storage}:{disk_gb}", size=f"{storage}:{disk_gb}",
) )
# 4. Запускаем контейнер.
upid = px.nodes(node).lxc(new_vmid).status.start.post() upid = px.nodes(node).lxc(new_vmid).status.start.post()
if upid: if upid:
_wait_task(px, node, upid) _wait_task(px, node, upid)
return password return password
@@ -137,8 +113,7 @@ _DISK_KEYS = ("scsi0", "virtio0", "ide0", "sata0")
def _disk_size_from_config(cfg: dict) -> int: def _disk_size_from_config(cfg: dict) -> int:
"""Извлекает размер диска VM в ГБ.""" """Извлекает размер диска VM в ГБ."""
for key in _DISK_KEYS: for key in _DISK_KEYS:
value = cfg.get(key, "") match = re.search(r"size=(\d+)G", cfg.get(key, ""))
match = re.search(r"size=(\d+)G", value)
if match: if match:
return int(match.group(1)) return int(match.group(1))
return 10 return 10
@@ -163,7 +138,7 @@ 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-шаблоны (vztmpl) из доступных хранилищ.""" """Возвращает архивные LXC-шаблоны (vztmpl)."""
node = node or settings.pve_node node = node or settings.pve_node
px = _client() px = _client()
result = [] result = []
@@ -276,6 +251,16 @@ 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 _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: def _wait_guest_stopped(px: ProxmoxAPI, guest_type: str, vmid: int, node: str, timeout: int = 180) -> None:
"""Ожидает, пока VM или LXC перейдёт в состояние stopped.""" """Ожидает, пока VM или LXC перейдёт в состояние stopped."""
endpoint = px.nodes(node).qemu(vmid) if guest_type == "vm" else px.nodes(node).lxc(vmid) endpoint = px.nodes(node).qemu(vmid) if guest_type == "vm" else px.nodes(node).lxc(vmid)
@@ -289,23 +274,44 @@ def _wait_guest_stopped(px: ProxmoxAPI, guest_type: str, vmid: int, node: str, t
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, операция считается успешной.
Это позволяет backend удалить устаревшую запись из собственной БД.
"""
node = node or settings.pve_node node = node or settings.pve_node
px = _client() px = _client()
endpoint = px.nodes(node).qemu(vmid) if guest_type == "vm" else px.nodes(node).lxc(vmid) endpoint = px.nodes(node).qemu(vmid) if guest_type == "vm" else px.nodes(node).lxc(vmid)
status = endpoint.status.current.get().get("status")
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": if status == "running":
try: try:
shutdown_upid = endpoint.status.shutdown.post(timeout=60) shutdown_upid = endpoint.status.shutdown.post(timeout=60)
if shutdown_upid: if shutdown_upid:
_wait_task(px, node, shutdown_upid, timeout=120) _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: except Exception as exc:
logger.warning("Мягкая остановка %s %s не удалась: %s", guest_type, vmid, exc) logger.warning("Мягкая остановка %s %s не удалась: %s", guest_type, vmid, exc)
endpoint.status.stop.post() endpoint.status.stop.post()
_wait_guest_stopped(px, guest_type, vmid, node) _wait_guest_stopped(px, guest_type, vmid, node)
upid = endpoint.delete() 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: if upid:
_wait_task(px, node, upid) _wait_task(px, node, upid)