Обновление файла
This commit is contained in:
+129
-144
@@ -1,4 +1,12 @@
|
|||||||
|
"""Обёртка над Proxmox REST API.
|
||||||
|
|
||||||
|
Все методы — синхронные (proxmoxer работает через requests). Используйте
|
||||||
|
внутри FastAPI через run_in_threadpool, если эндпоинт async.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
import random
|
import random
|
||||||
|
import re
|
||||||
import string
|
import string
|
||||||
import time
|
import time
|
||||||
|
|
||||||
@@ -6,6 +14,8 @@ from proxmoxer import ProxmoxAPI
|
|||||||
|
|
||||||
from .config import settings
|
from .config import settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def _client() -> ProxmoxAPI:
|
def _client() -> ProxmoxAPI:
|
||||||
"""Создаёт клиент Proxmox API, аутентифицированный по API-токену."""
|
"""Создаёт клиент Proxmox API, аутентифицированный по API-токену."""
|
||||||
@@ -20,17 +30,19 @@ 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()
|
px = _client()
|
||||||
return int(px.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."""
|
||||||
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(
|
upid = px.nodes(node).qemu(source_vmid).clone.post(
|
||||||
@@ -42,6 +54,7 @@ def clone_vm(source_vmid: int, new_vmid: int, name: str, node: str = None, stora
|
|||||||
|
|
||||||
|
|
||||||
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."""
|
||||||
node = node or settings.pve_node
|
node = node or settings.pve_node
|
||||||
px = _client()
|
px = _client()
|
||||||
px.nodes(node).qemu(vmid).config.put(cores=cores, memory=memory_mb)
|
px.nodes(node).qemu(vmid).config.put(cores=cores, memory=memory_mb)
|
||||||
@@ -77,172 +90,152 @@ def create_lxc(
|
|||||||
return password
|
return password
|
||||||
|
|
||||||
|
|
||||||
def _wait_task(px: ProxmoxAPI, node: str, upid: str, timeout: int = 1200) -> None:
|
def _wait_task(px: ProxmoxAPI, node: str, upid: str, timeout: int = 1200, poll: float = 2.0) -> None:
|
||||||
"""Ждёт завершения асинхронной задачи Proxmox (клонирование, создание и т.п.)."""
|
"""Ждёт завершения асинхронной задачи Proxmox с экспоненциальной задержкой.
|
||||||
|
|
||||||
|
Начинаем с poll=1s и удваиваем до 5s — снижает нагрузку на Proxmox
|
||||||
|
при долгом клонировании больших дисков.
|
||||||
|
"""
|
||||||
start = time.time()
|
start = time.time()
|
||||||
|
delay = poll
|
||||||
while time.time() - start < timeout:
|
while time.time() - start < timeout:
|
||||||
status = px.nodes(node).tasks(upid).status.get()
|
status = px.nodes(node).tasks(upid).status.get()
|
||||||
if status.get("status") == "stopped":
|
if status.get("status") == "stopped":
|
||||||
if status.get("exitstatus") != "OK":
|
if status.get("exitstatus") != "OK":
|
||||||
raise RuntimeError(f"Задача Proxmox завершилась с ошибкой: {status}")
|
raise RuntimeError(f"Задача Proxmox завершилась с ошибкой: {status}")
|
||||||
return
|
return
|
||||||
time.sleep(2)
|
time.sleep(delay)
|
||||||
|
delay = min(delay * 1.5, 5.0)
|
||||||
raise TimeoutError("Превышено время ожидания задачи Proxmox")
|
raise TimeoutError("Превышено время ожидания задачи Proxmox")
|
||||||
|
|
||||||
|
|
||||||
def list_vm_templates(node=None):
|
_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
|
node = node or settings.pve_node
|
||||||
px = _client()
|
px = _client()
|
||||||
result = []
|
result = []
|
||||||
for vm in px.nodes(node).qemu.get():
|
for vm in px.nodes(node).qemu.get():
|
||||||
if vm.get("template") == 1:
|
if vm.get("template") == 1:
|
||||||
cfg = px.nodes(node).qemu(vm["vmid"]).config.get()
|
cfg = px.nodes(node).qemu(vm["vmid"]).config.get()
|
||||||
disk = 10
|
|
||||||
for k in ("scsi0","virtio0","ide0","sata0"):
|
|
||||||
v = cfg.get(k,"")
|
|
||||||
if ",size=" in v:
|
|
||||||
import re as _re
|
|
||||||
m = _re.search(r"size=(\d+)G", v)
|
|
||||||
if m: disk = int(m.group(1)); break
|
|
||||||
result.append({
|
result.append({
|
||||||
"vmid": vm["vmid"], "name": vm.get("name",""),
|
"vmid": vm["vmid"],
|
||||||
"cores": int(cfg.get("cores",1)), "memory_mb": int(cfg.get("memory",1024)),
|
"name": vm.get("name", ""),
|
||||||
"disk_gb": disk,
|
"cores": int(cfg.get("cores", 1)),
|
||||||
|
"memory_mb": int(cfg.get("memory", 1024)),
|
||||||
|
"disk_gb": _disk_size_from_config(cfg),
|
||||||
})
|
})
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def list_lxc_templates(node=None):
|
|
||||||
|
def list_lxc_templates(node: str = None) -> list:
|
||||||
|
"""Возвращает список LXC-шаблонов (.tar.zst/.tar.gz/.tar.xz) из всех хранилищ."""
|
||||||
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 st in px.nodes(node).storage.get():
|
||||||
if "vztmpl" in st.get("content",""):
|
if "vztmpl" not in st.get("content", ""):
|
||||||
try:
|
continue
|
||||||
for item in px.nodes(node).storage(st["storage"]).content.get():
|
try:
|
||||||
if item.get("content") == "vztmpl":
|
for item in px.nodes(node).storage(st["storage"]).content.get():
|
||||||
name = item["volid"].split("/")[-1]
|
if item.get("content") == "vztmpl":
|
||||||
for ext in (".tar.zst",".tar.gz",".tar.xz"): name = name.replace(ext,"")
|
name = item["volid"].split("/")[-1]
|
||||||
result.append({"volid": item["volid"], "name": name, "storage": st["storage"], "size_mb": round(item.get("size",0)/(1024**2),1)})
|
for ext in (".tar.zst", ".tar.gz", ".tar.xz"):
|
||||||
except: pass
|
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
|
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 до нужного размера (только в большую сторону)."""
|
||||||
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 ("scsi0","virtio0","ide0","sata0"):
|
for key in _DISK_KEYS:
|
||||||
val = config.get(key,"")
|
val = config.get(key, "")
|
||||||
if val:
|
if not val:
|
||||||
import re as _re
|
continue
|
||||||
m = _re.search(r"size=(\d+)G", val)
|
m = re.search(r"size=(\d+)G", val)
|
||||||
current = int(m.group(1)) if m else 0
|
current = int(m.group(1)) if m 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")
|
||||||
break
|
return
|
||||||
|
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."""
|
||||||
node = node or settings.pve_node
|
node = node or settings.pve_node
|
||||||
_client().nodes(node).qemu(vmid).config.put(ciuser=ciuser, cipassword=cipassword, ipconfig0="ip=dhcp")
|
_client().nodes(node).qemu(vmid).config.put(
|
||||||
|
ciuser=ciuser,
|
||||||
def get_instance_ip(vmid: int, node: str = None) -> str:
|
cipassword=cipassword,
|
||||||
"""Возвращает IP-адрес VM/LXC из гостевого агента или сетевой конфигурации."""
|
ipconfig0="ip=dhcp",
|
||||||
node = node or settings.pve_node
|
)
|
||||||
px = _client()
|
|
||||||
# Пробуем через гостевой агент (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" and iface.get("ip-addresses"):
|
|
||||||
for addr in iface["ip-addresses"]:
|
|
||||||
if addr.get("ip-address-type") == "ipv4" and not addr.get("ip-address", "").startswith("127."):
|
|
||||||
return addr["ip-address"]
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
# Пробуем через LXC (если контейнер)
|
|
||||||
try:
|
|
||||||
config = px.nodes(node).lxc(vmid).config.get()
|
|
||||||
# Просто пробуем разные пути для LXC
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
# Пробуем через DHCP-лиз Proxmox
|
|
||||||
try:
|
|
||||||
config = px.nodes(node).qemu(vmid).config.get()
|
|
||||||
net = config.get("net0", "")
|
|
||||||
if "dhcp" in net.lower():
|
|
||||||
return "dhcp (агент не установлен)"
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return "неизвестен"
|
|
||||||
|
|
||||||
|
|
||||||
def get_instance_ip(vmid: int, node: str = None) -> str:
|
def get_instance_ip(vmid: int, node: str = None) -> str:
|
||||||
"""Возвращает IP-адрес VM/LXC из гостевого агента или сетевой конфигурации."""
|
"""Возвращает основной IPv4 инстанса.
|
||||||
|
|
||||||
|
Для VM используется QEMU Guest Agent. Если агент недоступен — возвращает
|
||||||
|
строку-плейсхолдер, чтобы UI мог отличить «не знаю» от «ошибка».
|
||||||
|
"""
|
||||||
node = node or settings.pve_node
|
node = node or settings.pve_node
|
||||||
px = _client()
|
px = _client()
|
||||||
# Пробуем через гостевой агент (QEMU Guest Agent)
|
# 1. QEMU Guest Agent (требует установленного qemu-guest-agent в гостевой ОС).
|
||||||
try:
|
try:
|
||||||
ifaces = px.nodes(node).qemu(vmid).agent.get("network-get-interfaces").get("result", [])
|
ifaces = (
|
||||||
|
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" and iface.get("ip-addresses"):
|
if iface.get("name") == "lo":
|
||||||
for addr in iface["ip-addresses"]:
|
continue
|
||||||
if addr.get("ip-address-type") == "ipv4" and not addr.get("ip-address", "").startswith("127."):
|
for addr in iface.get("ip-addresses", []) or []:
|
||||||
return addr["ip-address"]
|
ip = addr.get("ip-address", "")
|
||||||
except Exception:
|
if addr.get("ip-address-type") == "ipv4" and not ip.startswith("127."):
|
||||||
pass
|
return ip
|
||||||
# Пробуем через LXC (если контейнер)
|
except Exception as exc:
|
||||||
try:
|
logger.debug("VM %s: guest-agent недоступен (%s)", vmid, exc)
|
||||||
config = px.nodes(node).lxc(vmid).config.get()
|
|
||||||
# Просто пробуем разные пути для LXC
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
# Пробуем через DHCP-лиз Proxmox
|
|
||||||
try:
|
|
||||||
config = px.nodes(node).qemu(vmid).config.get()
|
|
||||||
net = config.get("net0", "")
|
|
||||||
if "dhcp" in net.lower():
|
|
||||||
return "dhcp (агент не установлен)"
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return "неизвестен"
|
|
||||||
|
|
||||||
|
# 2. LXC — адрес можно увидеть через конфиг + интерфейсы внутри.
|
||||||
def get_instance_ip(vmid: int, node: str = None) -> str:
|
|
||||||
"""Возвращает IP-адрес VM/LXC из гостевого агента или сетевой конфигурации."""
|
|
||||||
node = node or settings.pve_node
|
|
||||||
px = _client()
|
|
||||||
# Пробуем через гостевой агент (QEMU Guest Agent)
|
|
||||||
try:
|
try:
|
||||||
ifaces = px.nodes(node).qemu(vmid).agent.get("network-get-interfaces").get("result", [])
|
ifaces = px.nodes(node).lxc(vmid).interfaces.get() or []
|
||||||
for iface in ifaces:
|
for iface in ifaces:
|
||||||
if iface.get("name") != "lo" and iface.get("ip-addresses"):
|
for ip in iface.get("ip-addresses", []) or []:
|
||||||
for addr in iface["ip-addresses"]:
|
if not ip.startswith("127."):
|
||||||
if addr.get("ip-address-type") == "ipv4" and not addr.get("ip-address", "").startswith("127."):
|
return ip
|
||||||
return addr["ip-address"]
|
except Exception as exc:
|
||||||
except Exception:
|
logger.debug("LXC %s: интерфейсы недоступны (%s)", vmid, exc)
|
||||||
pass
|
|
||||||
# Пробуем через LXC (если контейнер)
|
# 3. Fallback: понятная заглушка вместо молчаливого «неизвестен».
|
||||||
try:
|
return "не определён (агент недоступен)"
|
||||||
config = px.nodes(node).lxc(vmid).config.get()
|
|
||||||
# Просто пробуем разные пути для LXC
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
# Пробуем через DHCP-лиз Proxmox
|
|
||||||
try:
|
|
||||||
config = px.nodes(node).qemu(vmid).config.get()
|
|
||||||
net = config.get("net0", "")
|
|
||||||
if "dhcp" in net.lower():
|
|
||||||
return "dhcp (агент не установлен)"
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return "неизвестен"
|
|
||||||
|
|
||||||
|
|
||||||
def get_live_stats(vmid: int, node: str = None) -> dict:
|
def get_live_stats(vmid: int, node: str = None) -> dict:
|
||||||
"""Возвращает live-показатели: cpu%, ram_used, ram_total, uptime."""
|
"""Live-показатели (cpu%, mem_used, mem_total, uptime, status) для VM и LXC."""
|
||||||
node = node or settings.pve_node
|
node = node or settings.pve_node
|
||||||
px = _client()
|
px = _client()
|
||||||
try:
|
|
||||||
status = px.nodes(node).qemu(vmid).status.current.get()
|
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),
|
||||||
@@ -250,43 +243,34 @@ def get_live_stats(vmid: int, node: str = None) -> dict:
|
|||||||
"uptime": status.get("uptime", 0),
|
"uptime": status.get("uptime", 0),
|
||||||
"status": status.get("status", "unknown"),
|
"status": status.get("status", "unknown"),
|
||||||
}
|
}
|
||||||
except Exception:
|
|
||||||
|
for endpoint in (px.nodes(node).qemu(vmid).status.current, px.nodes(node).lxc(vmid).status.current):
|
||||||
try:
|
try:
|
||||||
status = px.nodes(node).lxc(vmid).status.current.get()
|
return _shape(endpoint.get())
|
||||||
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"),
|
|
||||||
}
|
|
||||||
except Exception:
|
except Exception:
|
||||||
return {"cpu": 0, "mem_used": 0, "mem_total": 0, "uptime": 0, "status": "unknown"}
|
continue
|
||||||
|
return {"cpu": 0, "mem_used": 0, "mem_total": 0, "uptime": 0, "status": "unknown"}
|
||||||
|
|
||||||
|
|
||||||
def get_live_stats_lxc(vmid: int, node: str = None) -> dict:
|
# Алиас для обратной совместимости со старым кодом.
|
||||||
"""Live-показатели LXC контейнера."""
|
get_live_stats_lxc = get_live_stats
|
||||||
return get_live_stats(vmid, node)
|
|
||||||
|
|
||||||
|
_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:
|
||||||
"""action: start | stop | shutdown | reboot"""
|
"""Выполняет действие над VM/LXC: start | stop | shutdown | reboot."""
|
||||||
|
if action not in _VALID_ACTIONS:
|
||||||
|
raise ValueError(f"Неизвестное действие: {action}")
|
||||||
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)
|
||||||
if action == "start":
|
getattr(endpoint.status, action).post()
|
||||||
endpoint.status.start.post()
|
|
||||||
elif action == "stop":
|
|
||||||
endpoint.status.stop.post()
|
|
||||||
elif action == "shutdown":
|
|
||||||
endpoint.status.shutdown.post()
|
|
||||||
elif action == "reboot":
|
|
||||||
endpoint.status.reboot.post()
|
|
||||||
else:
|
|
||||||
raise ValueError(f"Неизвестное действие: {action}")
|
|
||||||
|
|
||||||
|
|
||||||
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":
|
if guest_type == "vm":
|
||||||
@@ -296,6 +280,7 @@ def delete_guest(guest_type: str, vmid: int, node: str = None) -> None:
|
|||||||
|
|
||||||
|
|
||||||
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, …)."""
|
||||||
node = node or settings.pve_node
|
node = node or settings.pve_node
|
||||||
px = _client()
|
px = _client()
|
||||||
if guest_type == "vm":
|
if guest_type == "vm":
|
||||||
@@ -304,7 +289,7 @@ def get_status(guest_type: str, vmid: int, node: str = None) -> dict:
|
|||||||
|
|
||||||
|
|
||||||
def get_vnc_ticket(guest_type: str, vmid: int, node: str = None) -> dict:
|
def get_vnc_ticket(guest_type: str, vmid: int, node: str = None) -> dict:
|
||||||
"""Запрашивает у Proxmox тикет для VNC/websocket-консоли."""
|
"""Запрашивает у Proxmox одноразовый тикет для VNC/websocket-консоли."""
|
||||||
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)
|
||||||
|
|||||||
Reference in New Issue
Block a user