Proxmox base64-encodes the input-data of an agent exec in Perl, which dies with "Wide character in subroutine entry" as soon as the payload contains non-ASCII — a Cyrillic comment in a script is enough, so running any such script on a VM failed with a 500. Hand the same UTF-8 bytes over as latin-1: the JSON carries plain bytes, Perl is happy and the guest still receives valid UTF-8.
3111 lines
138 KiB
Python
3111 lines
138 KiB
Python
import time
|
||
import ipaddress
|
||
import urllib3
|
||
import asyncio
|
||
from concurrent.futures import ThreadPoolExecutor
|
||
from typing import List, Dict, Optional, Union, Any
|
||
from proxmoxer import ProxmoxAPI, AuthenticationError
|
||
from loguru import logger
|
||
import threading
|
||
from functools import lru_cache
|
||
from contextlib import asynccontextmanager
|
||
import base64
|
||
|
||
from .net_parser import parse_guest_nics
|
||
from .mixins.vm import VmMixin
|
||
from .mixins.lxc import LxcMixin
|
||
from .mixins.cluster import ClusterMixin
|
||
from .mixins.storage import StorageMixin
|
||
from .mixins.network import NetworkMixin
|
||
from .mixins.snapshot import SnapshotMixin
|
||
from .mixins.firewall import FirewallMixin
|
||
from .mixins.pools import PoolsMixin
|
||
from .mixins.disks import DisksMixin
|
||
from .mixins.access import AccessMixin
|
||
from .mixins.node_admin import NodeAdminMixin
|
||
|
||
proxmox_executor = ThreadPoolExecutor(max_workers=20, thread_name_prefix="proxmox_")
|
||
|
||
connection_cache = {}
|
||
connection_cache_lock = threading.Lock()
|
||
MAX_CACHE_SIZE = 50
|
||
|
||
async def _run_in_executor(func, *args, **kwargs):
|
||
"""Run a blocking function in the proxmox thread pool executor."""
|
||
loop = asyncio.get_event_loop()
|
||
return await loop.run_in_executor(proxmox_executor, lambda: func(*args, **kwargs))
|
||
|
||
def get_proxmox_resources(host: str, user: str = "root@pam",
|
||
password: str = None, token_name: str = None,
|
||
token_value: str = None, verify_ssl: bool = False,
|
||
node: str = None, timeout: int = 10) -> Dict[str, List[Dict]]:
|
||
"""
|
||
Вспомогательная функция для получения всех ресурсов Proxmox
|
||
|
||
Args:
|
||
node: Имя ноды для фильтрации (если None, получить со всех нод)
|
||
timeout: Connection timeout in seconds
|
||
|
||
Returns:
|
||
Dict с ключами 'vms' и 'containers'
|
||
"""
|
||
client = ProxmoxClient(host, user, password, token_name, token_value, verify_ssl, timeout=timeout)
|
||
return client.get_all_resources(node)
|
||
|
||
def cleanup_expired_connections():
|
||
"""Cleanup expired connections from cache"""
|
||
current_time = time.time()
|
||
expired_keys = []
|
||
|
||
for key, data in connection_cache.items():
|
||
if current_time - data['created'] > 3600: # 1 hour
|
||
expired_keys.append(key)
|
||
|
||
for key in expired_keys:
|
||
del connection_cache[key]
|
||
logger.debug(f"Removed expired Proxmox connection: {key}")
|
||
|
||
def clear_server_cache(host: str):
|
||
"""Clear all cached connections for a specific server"""
|
||
keys_to_remove = [key for key in connection_cache.keys() if key.startswith(f"{host}:")]
|
||
for key in keys_to_remove:
|
||
del connection_cache[key]
|
||
logger.info(f"Cleared cache for server: {key}")
|
||
|
||
def clear_all_cache():
|
||
"""Clear entire connection cache"""
|
||
connection_cache.clear()
|
||
logger.info("Cleared all Proxmox connection cache")
|
||
|
||
@lru_cache(maxsize=32)
|
||
def get_cached_client(host: str, user: str, password_hash: str = None,
|
||
token_name: str = None, token_value_hash: str = None) -> 'ProxmoxClient':
|
||
"""Get cached Proxmox client instance"""
|
||
return ProxmoxClient(
|
||
host=host,
|
||
user=user,
|
||
password=password_hash,
|
||
token_name=token_name,
|
||
token_value=token_value_hash
|
||
)
|
||
|
||
class ProxmoxClient(VmMixin, LxcMixin, ClusterMixin, StorageMixin, NetworkMixin, SnapshotMixin, FirewallMixin, PoolsMixin, DisksMixin, AccessMixin, NodeAdminMixin):
|
||
@classmethod
|
||
def from_server(cls, server) -> "ProxmoxClient":
|
||
"""Single source of truth for building a client from a ProxmoxServer model.
|
||
|
||
Picks token auth when available, otherwise password auth, and appends a
|
||
non-default port to the host. Callers across the API and services layers
|
||
(see api/proxmox/_helpers.py and services/task_queue_service.py) should
|
||
go through here instead of duplicating the host/auth branching.
|
||
"""
|
||
host = server.ip_address or getattr(server, "hostname", "") or ""
|
||
if getattr(server, "port", None) and server.port != 8006:
|
||
host = f"{host}:{server.port}"
|
||
|
||
if getattr(server, "use_password", False) and getattr(server, "password", ""):
|
||
return cls(
|
||
host=host,
|
||
user=server.api_user,
|
||
password=server.password,
|
||
verify_ssl=server.verify_ssl,
|
||
)
|
||
return cls(
|
||
host=host,
|
||
user=server.api_user,
|
||
token_name=server.api_token_name,
|
||
token_value=server.api_token_value,
|
||
verify_ssl=server.verify_ssl,
|
||
)
|
||
|
||
def __init__(self, host: str, user: str = "root@pam", password: str = None,
|
||
token_name: str = None, token_value: str = None, verify_ssl: bool = True,
|
||
timeout: int = 30):
|
||
"""
|
||
Инициализация Proxmox клиента
|
||
|
||
Args:
|
||
host: IP адрес или hostname Proxmox сервера
|
||
user: Пользователь (например root@pam)
|
||
password: Пароль (для password auth)
|
||
token_name: Имя API токена (для token auth)
|
||
token_value: Значение API токена (для token auth)
|
||
verify_ssl: Проверять SSL сертификат
|
||
timeout: Таймаут подключения в секундах
|
||
"""
|
||
self.host = host
|
||
self.user = user
|
||
self.timeout = timeout
|
||
# Stored for SSH fallbacks (e.g. pvecm delnode) that need root password auth
|
||
self._password = password
|
||
# Улучшенный ключ кеша - включаем token_name для уникальности
|
||
self.connection_key = f"{host}:{user}:{token_name or 'password'}"
|
||
self.proxmox = None
|
||
self.last_used = time.time()
|
||
# Причина неудачного подключения: 'auth' | 'connection' | None.
|
||
# Нужна, чтобы отличить протухший токен от недоступной ноды.
|
||
self.connect_error_kind = None
|
||
self.connect_error = None
|
||
|
||
# Check cache first
|
||
with connection_cache_lock:
|
||
if self.connection_key in connection_cache:
|
||
cached_client = connection_cache[self.connection_key]
|
||
if time.time() - cached_client['created'] < 3600: # 1 hour cache
|
||
self.proxmox = cached_client['client']
|
||
logger.debug(f"Using cached Proxmox connection for {host}")
|
||
# Обновляем время последнего использования
|
||
cached_client['created'] = time.time()
|
||
return
|
||
else:
|
||
# Remove expired connection
|
||
del connection_cache[self.connection_key]
|
||
logger.debug(f"Removed expired cache for {self.connection_key}")
|
||
|
||
try:
|
||
if token_name and token_value:
|
||
# Аутентификация через API токен (рекомендуется)
|
||
self.proxmox = ProxmoxAPI(
|
||
host,
|
||
user=user,
|
||
token_name=token_name,
|
||
token_value=token_value,
|
||
verify_ssl=verify_ssl,
|
||
timeout=timeout
|
||
)
|
||
logger.info(f"Connected to Proxmox {host} using API token")
|
||
elif password:
|
||
# Аутентификация через пароль
|
||
self.proxmox = ProxmoxAPI(
|
||
host,
|
||
user=user,
|
||
password=password,
|
||
verify_ssl=verify_ssl,
|
||
timeout=timeout
|
||
)
|
||
logger.info("Connected to Proxmox using password")
|
||
else:
|
||
logger.error("Необходимо указать либо password, либо token")
|
||
return
|
||
|
||
# Test connection
|
||
if self.proxmox:
|
||
try:
|
||
self.proxmox.version.get()
|
||
# Cache successful connection
|
||
with connection_cache_lock:
|
||
if len(connection_cache) >= MAX_CACHE_SIZE:
|
||
oldest_key = min(connection_cache.keys(), key=lambda k: connection_cache[k]['created'])
|
||
del connection_cache[oldest_key]
|
||
connection_cache[self.connection_key] = {
|
||
'client': self.proxmox,
|
||
'created': time.time()
|
||
}
|
||
logger.debug(f"Cached connection for {self.connection_key}")
|
||
except Exception as e:
|
||
logger.error(f"Failed to test Proxmox connection for {host}: {e}")
|
||
# При токен-авторизации 401 прилетает не AuthenticationError,
|
||
# а ResourceException со status_code — proxmoxer не делает
|
||
# отдельный логин, токен проверяется на первом же запросе.
|
||
self.connect_error_kind = (
|
||
'auth' if getattr(e, 'status_code', None) == 401 else 'connection'
|
||
)
|
||
self.connect_error = str(e)
|
||
self.proxmox = None
|
||
# Удаляем неудачное подключение из кеша
|
||
with connection_cache_lock:
|
||
if self.connection_key in connection_cache:
|
||
del connection_cache[self.connection_key]
|
||
|
||
except AuthenticationError as e:
|
||
logger.error(f"Ошибка аутентификации Proxmox {host}: {e}")
|
||
self.connect_error_kind = 'auth'
|
||
self.connect_error = str(e)
|
||
self.proxmox = None
|
||
# Удаляем из кеша при ошибке аутентификации
|
||
with connection_cache_lock:
|
||
if self.connection_key in connection_cache:
|
||
del connection_cache[self.connection_key]
|
||
except Exception as e:
|
||
logger.error(f"Ошибка подключения к Proxmox {host}: {e}")
|
||
self.connect_error_kind = (
|
||
'auth' if getattr(e, 'status_code', None) == 401 else 'connection'
|
||
)
|
||
self.connect_error = str(e)
|
||
self.proxmox = None
|
||
# Удаляем из кеша при ошибке подключения
|
||
if self.connection_key in connection_cache:
|
||
del connection_cache[self.connection_key]
|
||
|
||
def is_connected(self) -> bool:
|
||
"""Check if client is properly connected"""
|
||
if not self.proxmox:
|
||
return False
|
||
try:
|
||
self.proxmox.version.get()
|
||
self.last_used = time.time()
|
||
return True
|
||
except Exception:
|
||
return False
|
||
|
||
def check_connection(self) -> tuple:
|
||
"""
|
||
Проверить API, отличая отказ авторизации от недоступности ноды.
|
||
|
||
«Нода не отвечает» и «нода ответила 401» чинятся по-разному: первое —
|
||
сеть/питание, второе — протухший токен (например, после ввода ноды в
|
||
кластер, когда user.cfg и priv/token.cfg замещаются кластерными).
|
||
|
||
Returns:
|
||
(ok, error_kind, error) — error_kind: None | 'auth' | 'connection'
|
||
"""
|
||
if not self.proxmox:
|
||
return (False, self.connect_error_kind or 'connection',
|
||
self.connect_error or 'Failed to connect')
|
||
try:
|
||
self.proxmox.version.get()
|
||
self.last_used = time.time()
|
||
return (True, None, None)
|
||
except AuthenticationError as e:
|
||
return (False, 'auth', str(e))
|
||
except Exception as e:
|
||
kind = 'auth' if getattr(e, 'status_code', None) == 401 else 'connection'
|
||
return (False, kind, str(e))
|
||
|
||
@asynccontextmanager
|
||
async def ensure_connection(self):
|
||
"""Ensure connection is active before use"""
|
||
if not self.is_connected():
|
||
logger.warning(f"Lost connection to Proxmox {self.host}, attempting reconnect")
|
||
# Remove from cache and try to reconnect
|
||
if self.connection_key in connection_cache:
|
||
del connection_cache[self.connection_key]
|
||
yield self.proxmox
|
||
|
||
def get_nodes(self) -> List[Dict]:
|
||
"""Получить список нод кластера"""
|
||
if not self.proxmox:
|
||
return []
|
||
|
||
try:
|
||
nodes = self.proxmox.nodes.get()
|
||
return nodes
|
||
except Exception as e:
|
||
logger.error(f"Ошибка получения списка нод {self.host}: {e}")
|
||
return []
|
||
|
||
def get_vms(self, node: str = None) -> List[Dict]:
|
||
"""
|
||
Получить список виртуальных машин (QEMU)
|
||
|
||
Args:
|
||
node: Имя ноды (если None, получить со всех нод)
|
||
|
||
Returns:
|
||
Список VM с информацией
|
||
"""
|
||
if not self.proxmox:
|
||
logger.warning(f"Cannot get VMs from {self.host}: proxmox client is None")
|
||
return []
|
||
|
||
vms = []
|
||
|
||
try:
|
||
if node:
|
||
nodes = [{'node': node}]
|
||
else:
|
||
nodes = self.get_nodes()
|
||
|
||
for n in nodes:
|
||
node_name = n.get('node')
|
||
try:
|
||
qemu_vms = self.proxmox.nodes(node_name).qemu.get()
|
||
for vm in qemu_vms:
|
||
vm['node'] = node_name
|
||
vm['type'] = 'qemu'
|
||
vms.append(vm)
|
||
except Exception as e:
|
||
logger.error(f"Ошибка получения VM с ноды {node_name}: {e}")
|
||
except Exception as e:
|
||
logger.error(f"Ошибка получения списка VM {self.host}: {e}")
|
||
|
||
return vms
|
||
|
||
def get_containers(self, node: str = None) -> List[Dict]:
|
||
"""
|
||
Получить список LXC контейнеров
|
||
|
||
Args:
|
||
node: Имя ноды (если None, получить со всех нод)
|
||
|
||
Returns:
|
||
Список LXC контейнеров с информацией
|
||
"""
|
||
if not self.proxmox:
|
||
logger.warning(f"Cannot get containers from {self.host}: proxmox client is None")
|
||
return []
|
||
|
||
containers = []
|
||
|
||
try:
|
||
if node:
|
||
nodes = [{'node': node}]
|
||
else:
|
||
nodes = self.get_nodes()
|
||
|
||
for n in nodes:
|
||
node_name = n.get('node')
|
||
try:
|
||
lxc_containers = self.proxmox.nodes(node_name).lxc.get()
|
||
for ct in lxc_containers:
|
||
ct['node'] = node_name
|
||
ct['type'] = 'lxc'
|
||
containers.append(ct)
|
||
except Exception as e:
|
||
logger.error(f"Ошибка получения LXC с ноды {node_name}: {e}")
|
||
except Exception as e:
|
||
logger.error(f"Ошибка получения списка LXC {self.host}: {e}")
|
||
|
||
return containers
|
||
|
||
def get_all_resources(self, node: str = None) -> Dict[str, List[Dict]]:
|
||
"""Получить все ресурсы (VM + LXC)
|
||
|
||
Args:
|
||
node: Имя ноды для фильтрации (если None, получить со всех нод)
|
||
"""
|
||
return {
|
||
'vms': self.get_vms(node),
|
||
'containers': self.get_containers(node)
|
||
}
|
||
|
||
def get_cluster_resources(self, type_: Optional[str] = None) -> List[Dict]:
|
||
"""Получить живые метрики со всех VM/LXC одним запросом через /cluster/resources.
|
||
|
||
Args:
|
||
type_: 'vm' для VM/LXC, 'node' для нод, None для всех
|
||
Returns:
|
||
Список ресурсов с полями cpu, mem, maxmem, disk, maxdisk, uptime, netin, netout, status и т.д.
|
||
"""
|
||
if not self.proxmox:
|
||
return []
|
||
try:
|
||
if type_:
|
||
return self.proxmox.cluster.resources.get(type=type_) or []
|
||
return self.proxmox.cluster.resources.get() or []
|
||
except Exception as e:
|
||
logger.error(f"Ошибка получения cluster/resources с {self.host}: {e}")
|
||
return []
|
||
|
||
def get_node_status(self, node: str) -> Optional[Dict]:
|
||
"""
|
||
Получить статус ноды Proxmox (CPU, память, uptime и т.д.)
|
||
|
||
Args:
|
||
node: Имя ноды
|
||
|
||
Returns:
|
||
Dict с данными о статусе ноды
|
||
"""
|
||
if not self.proxmox:
|
||
return None
|
||
|
||
try:
|
||
status = self.proxmox.nodes(node).status.get()
|
||
return status
|
||
except Exception as e:
|
||
logger.error(f"Ошибка получения статуса ноды {node}: {e}")
|
||
return None
|
||
|
||
def get_node_rrddata(self, node: str, timeframe: str = "hour") -> Dict:
|
||
"""
|
||
Получить исторические данные ноды для графиков
|
||
|
||
Args:
|
||
node: Имя ноды
|
||
timeframe: Временной диапазон (hour, day, week, month, year)
|
||
|
||
Returns:
|
||
Dict с массивом данных RRD
|
||
"""
|
||
if not self.proxmox:
|
||
return {"data": []}
|
||
|
||
try:
|
||
rrddata = self.proxmox.nodes(node).rrddata.get(timeframe=timeframe)
|
||
return {"data": rrddata}
|
||
except Exception as e:
|
||
logger.error(f"Ошибка получения RRD данных ноды {node}: {e}")
|
||
return {"data": []}
|
||
|
||
def get_templates(self, node: str = None) -> List[Dict]:
|
||
"""
|
||
Получить список шаблонов VM (template=1)
|
||
|
||
Args:
|
||
node: Имя ноды (если None, получить со всех нод)
|
||
|
||
Returns:
|
||
Список VM-шаблонов
|
||
"""
|
||
if not self.proxmox:
|
||
return []
|
||
|
||
templates = []
|
||
|
||
try:
|
||
if node:
|
||
nodes = [{'node': node}]
|
||
else:
|
||
nodes = self.get_nodes()
|
||
|
||
for n in nodes:
|
||
node_name = n.get('node')
|
||
try:
|
||
qemu_vms = self.proxmox.nodes(node_name).qemu.get()
|
||
for vm in qemu_vms:
|
||
# Проверяем, является ли VM шаблоном
|
||
if vm.get('template') == 1:
|
||
vm['node'] = node_name
|
||
vm['type'] = 'qemu'
|
||
templates.append(vm)
|
||
except Exception as e:
|
||
logger.error(f"Ошибка получения шаблонов с ноды {node_name}: {e}")
|
||
try:
|
||
lxc_cts = self.proxmox.nodes(node_name).lxc.get()
|
||
for ct in lxc_cts:
|
||
# LXC контейнер-шаблон (template=1)
|
||
if ct.get('template') in (1, '1', True):
|
||
ct['node'] = node_name
|
||
ct['type'] = 'lxc'
|
||
templates.append(ct)
|
||
except Exception as e:
|
||
logger.error(f"Ошибка получения LXC-шаблонов с ноды {node_name}: {e}")
|
||
# vztmpl файлы из storage (стандартные LXC шаблоны)
|
||
try:
|
||
storages = self.proxmox.nodes(node_name).storage.get()
|
||
seen_volids = set()
|
||
for stor in storages:
|
||
stor_name = stor.get('storage')
|
||
stor_content = stor.get('content', '') or ''
|
||
if 'vztmpl' not in stor_content:
|
||
continue
|
||
try:
|
||
items = self.proxmox.nodes(node_name).storage(stor_name).content.get(content='vztmpl')
|
||
except Exception as e:
|
||
logger.warning(f"Не удалось прочитать vztmpl из {stor_name} на {node_name}: {e}")
|
||
continue
|
||
for item in items:
|
||
volid = item.get('volid')
|
||
if not volid or volid in seen_volids:
|
||
continue
|
||
seen_volids.add(volid)
|
||
# Имя файла без 'storage:vztmpl/'
|
||
fname = volid.split('/', 1)[-1] if '/' in volid else volid
|
||
# Убираем расширения .tar.zst / .tar.gz / .tar.xz
|
||
display_name = fname
|
||
for ext in ('.tar.zst', '.tar.gz', '.tar.xz', '.tar'):
|
||
if display_name.endswith(ext):
|
||
display_name = display_name[: -len(ext)]
|
||
break
|
||
templates.append({
|
||
'vmid': None,
|
||
'volid': volid,
|
||
'name': display_name,
|
||
'node': node_name,
|
||
'type': 'lxc',
|
||
'is_file_template': True,
|
||
'maxmem': 0,
|
||
'maxdisk': int(item.get('size') or 0),
|
||
'storage': stor_name,
|
||
})
|
||
except Exception as e:
|
||
logger.error(f"Ошибка получения vztmpl с ноды {node_name}: {e}")
|
||
except Exception as e:
|
||
logger.error(f"Ошибка получения списка шаблонов {self.host}: {e}")
|
||
|
||
return templates
|
||
|
||
def get_storages(self, node: str) -> List[Dict]:
|
||
"""
|
||
Получить список хранилищ на ноде
|
||
|
||
Args:
|
||
node: Имя ноды
|
||
|
||
Returns:
|
||
Список доступных хранилищ
|
||
"""
|
||
if not self.proxmox:
|
||
return []
|
||
|
||
try:
|
||
storages = self.proxmox.nodes(node).storage.get()
|
||
return storages
|
||
except Exception as e:
|
||
logger.error(f"Ошибка получения хранилищ ноды {node}: {e}")
|
||
return []
|
||
|
||
async def async_get_nodes(self) -> List[Dict]:
|
||
"""Non-blocking version of get_nodes()."""
|
||
return await _run_in_executor(self.get_nodes)
|
||
|
||
async def async_get_vms(self, node: str = None) -> List[Dict]:
|
||
"""Non-blocking version of get_vms()."""
|
||
return await _run_in_executor(self.get_vms, node)
|
||
|
||
async def async_get_containers(self, node: str = None) -> List[Dict]:
|
||
"""Non-blocking version of get_containers()."""
|
||
return await _run_in_executor(self.get_containers, node)
|
||
|
||
async def async_get_node_status(self, node: str) -> Optional[Dict]:
|
||
"""Non-blocking version of get_node_status()."""
|
||
return await _run_in_executor(self.get_node_status, node)
|
||
|
||
async def async_get_vm_status(self, node: str, vmid: int) -> Optional[Dict]:
|
||
"""Non-blocking version of get_vm_status()."""
|
||
return await _run_in_executor(self.get_vm_status, node, vmid)
|
||
|
||
async def async_get_container_status(self, node: str, vmid: int) -> Optional[Dict]:
|
||
"""Non-blocking version of get_container_status()."""
|
||
return await _run_in_executor(self.get_container_status, node, vmid)
|
||
|
||
async def async_get_storages(self, node: str) -> List[Dict]:
|
||
"""Non-blocking version of get_storages()."""
|
||
return await _run_in_executor(self.get_storages, node)
|
||
|
||
async def async_get_all_resources(self, node: str = None) -> Dict[str, List[Dict]]:
|
||
"""Non-blocking version of get_all_resources()."""
|
||
return await _run_in_executor(self.get_all_resources, node)
|
||
|
||
def get_next_vmid(self) -> Optional[int]:
|
||
"""
|
||
Получить следующий свободный VMID
|
||
|
||
Returns:
|
||
Свободный VMID или None
|
||
"""
|
||
if not self.proxmox:
|
||
return None
|
||
|
||
try:
|
||
vmid = self.proxmox.cluster.nextid.get()
|
||
return int(vmid)
|
||
except Exception as e:
|
||
logger.error(f"Ошибка получения следующего VMID: {e}")
|
||
return None
|
||
|
||
def clone_vm_from_template(
|
||
self,
|
||
node: str,
|
||
template_vmid: int,
|
||
new_vmid: int,
|
||
name: str,
|
||
full_clone: bool = True,
|
||
target_storage: str = None,
|
||
target_node: str = None,
|
||
description: str = None
|
||
) -> Optional[str]:
|
||
"""
|
||
Клонировать VM из шаблона
|
||
|
||
Args:
|
||
node: Имя ноды где находится шаблон
|
||
template_vmid: VMID шаблона
|
||
new_vmid: VMID новой VM
|
||
name: Имя новой VM
|
||
full_clone: Полный клон (True) или linked clone (False)
|
||
target_storage: Целевое хранилище (опционально)
|
||
target_node: Целевая нода для VM (для кросс-нодного деплоя)
|
||
description: Описание VM
|
||
|
||
Returns:
|
||
UPID задачи или None при ошибке
|
||
"""
|
||
if not self.proxmox:
|
||
return None
|
||
|
||
try:
|
||
params = {
|
||
'newid': new_vmid,
|
||
'name': name,
|
||
'full': 1 if full_clone else 0,
|
||
}
|
||
|
||
if target_storage:
|
||
params['storage'] = target_storage
|
||
|
||
if target_node and target_node != node:
|
||
params['target'] = target_node
|
||
|
||
if description:
|
||
params['description'] = description
|
||
|
||
result = self.proxmox.nodes(node).qemu(template_vmid).clone.post(**params)
|
||
target_info = f" -> {target_node}" if target_node and target_node != node else ""
|
||
logger.info(f"Клонирование VM {template_vmid} -> {new_vmid} ({name}) на {node}{target_info}")
|
||
return result
|
||
except Exception as e:
|
||
logger.error(f"Ошибка клонирования VM {template_vmid} -> {new_vmid}: {e}")
|
||
return None
|
||
|
||
def clone_template_to_node(
|
||
self,
|
||
source_node: str,
|
||
template_vmid: int,
|
||
target_node: str,
|
||
new_vmid: int = None,
|
||
target_storage: str = None
|
||
) -> Optional[Dict]:
|
||
"""
|
||
Клонировать шаблон с одной ноды на другую.
|
||
Используется для репликации шаблонов между нодами кластера.
|
||
|
||
Args:
|
||
source_node: Исходная нода где находится шаблон
|
||
template_vmid: VMID шаблона на исходной ноде
|
||
target_node: Целевая нода куда клонировать
|
||
new_vmid: VMID для клона на целевой ноде (опционально, будет сгенерирован)
|
||
target_storage: Целевое хранилище на целевой ноде (если не указано - найдёт автоматически)
|
||
|
||
Returns:
|
||
Dict с upid задачи и new_vmid, или None при ошибке
|
||
"""
|
||
if not self.proxmox:
|
||
return None
|
||
|
||
if source_node == target_node:
|
||
logger.warning(f"Source and target node are the same: {source_node}")
|
||
return None
|
||
|
||
try:
|
||
# Get next VMID if not provided
|
||
if not new_vmid:
|
||
new_vmid = self.get_next_vmid()
|
||
if not new_vmid:
|
||
logger.error("Failed to get next VMID for template clone")
|
||
return None
|
||
|
||
# Get template info to preserve its name
|
||
template_config = self.proxmox.nodes(source_node).qemu(template_vmid).config.get()
|
||
template_name = template_config.get('name', f'template-{template_vmid}')
|
||
|
||
# Auto-detect target storage if not provided
|
||
# For cross-node cloning with local storage, we MUST specify target storage
|
||
if not target_storage:
|
||
target_storage = self._get_default_vm_storage(target_node)
|
||
if target_storage:
|
||
logger.info(f"Auto-detected target storage: {target_storage} on {target_node}")
|
||
|
||
params = {
|
||
'newid': new_vmid,
|
||
'name': f"{template_name}",
|
||
'target': target_node,
|
||
'full': 1, # Full clone for cross-node
|
||
}
|
||
|
||
if target_storage:
|
||
params['storage'] = target_storage
|
||
|
||
result = self.proxmox.nodes(source_node).qemu(template_vmid).clone.post(**params)
|
||
logger.info(f"Клонирование шаблона {template_vmid} с {source_node} на {target_node} (new vmid: {new_vmid}, storage: {target_storage})")
|
||
|
||
return {
|
||
'upid': result,
|
||
'new_vmid': new_vmid,
|
||
'target_node': target_node
|
||
}
|
||
except Exception as e:
|
||
logger.error(f"Ошибка клонирования шаблона {template_vmid} на ноду {target_node}: {e}")
|
||
return None
|
||
|
||
def _get_default_vm_storage(self, node: str) -> Optional[str]:
|
||
"""
|
||
Получить хранилище по умолчанию для VM на ноде.
|
||
Ищет хранилища с поддержкой 'images' (VM дисков).
|
||
|
||
Returns:
|
||
Имя хранилища или None
|
||
"""
|
||
if not self.proxmox:
|
||
return None
|
||
|
||
try:
|
||
storages = self.proxmox.nodes(node).storage.get()
|
||
|
||
# Priority: local-lvm > local-zfs > any with 'images' content
|
||
priority_storages = ['local-lvm', 'local-zfs', 'local']
|
||
|
||
# Filter storages that support VM images
|
||
vm_storages = []
|
||
for s in storages:
|
||
content = s.get('content', '')
|
||
if 'images' in content:
|
||
vm_storages.append(s)
|
||
|
||
if not vm_storages:
|
||
logger.warning(f"No VM storage found on node {node}")
|
||
return None
|
||
|
||
# Try priority storages first
|
||
for priority in priority_storages:
|
||
for s in vm_storages:
|
||
if s.get('storage') == priority and s.get('active', 1):
|
||
return s.get('storage')
|
||
|
||
# Return first active storage with images support
|
||
for s in vm_storages:
|
||
if s.get('active', 1):
|
||
return s.get('storage')
|
||
|
||
return vm_storages[0].get('storage') if vm_storages else None
|
||
|
||
except Exception as e:
|
||
logger.error(f"Error getting default storage for node {node}: {e}")
|
||
return None
|
||
|
||
def convert_to_template(self, node: str, vmid: int) -> bool:
|
||
"""
|
||
Преобразовать VM в шаблон
|
||
|
||
Args:
|
||
node: Имя ноды
|
||
vmid: VMID VM
|
||
|
||
Returns:
|
||
True если успешно
|
||
"""
|
||
if not self.proxmox:
|
||
return False
|
||
|
||
try:
|
||
self.proxmox.nodes(node).qemu(vmid).template.post()
|
||
logger.info(f"VM {vmid} на {node} преобразован в шаблон")
|
||
return True
|
||
except Exception as e:
|
||
logger.error(f"Ошибка преобразования VM {vmid} в шаблон: {e}")
|
||
return False
|
||
|
||
def create_vm_with_import(self, node: str, vmid: int, name: str,
|
||
memory: int, cores: int,
|
||
disk_storage: str, import_volid: str,
|
||
bridge: str = 'vmbr0', ostype: str = 'l26') -> Optional[str]:
|
||
"""
|
||
Создать ВМ, импортируя готовый диск из скачанного образа (PVE 8.2+).
|
||
|
||
Использует синтаксис диска `<storage>:0,import-from=<volid>`, при котором
|
||
Proxmox сам аллоцирует диск нужного размера и конвертирует образ —
|
||
аналог `qm importdisk` без SSH. Добавляет cloud-init drive и serial-консоль.
|
||
|
||
Args:
|
||
node: имя ноды
|
||
vmid: VMID новой ВМ
|
||
name: имя ВМ
|
||
memory: память в MB
|
||
cores: число ядер
|
||
disk_storage: хранилище для диска ВМ (content=images)
|
||
import_volid: volid скачанного образа, напр. 'local:import/img.qcow2'
|
||
bridge: сетевой мост
|
||
ostype: тип ОС (l26 — Linux 2.6+/3.x/4.x/5.x/6.x)
|
||
|
||
Returns:
|
||
UPID задачи создания/импорта или None
|
||
"""
|
||
if not self.proxmox:
|
||
return None
|
||
params = {
|
||
'vmid': vmid,
|
||
'name': name,
|
||
'memory': memory,
|
||
'cores': cores,
|
||
'sockets': 1,
|
||
'cpu': 'host',
|
||
'scsihw': 'virtio-scsi-single',
|
||
'ostype': ostype,
|
||
'scsi0': f'{disk_storage}:0,import-from={import_volid}',
|
||
'ide2': f'{disk_storage}:cloudinit',
|
||
'boot': 'order=scsi0',
|
||
'serial0': 'socket',
|
||
'vga': 'serial0',
|
||
'agent': 'enabled=1',
|
||
'net0': f'virtio,bridge={bridge}',
|
||
}
|
||
# Исключение пробрасываем наверх — воркер отличит «импорт не поддержан»
|
||
result = self.proxmox.nodes(node).qemu.post(**params)
|
||
logger.info(f"Создание ВМ {vmid} ({name}) с импортом {import_volid} на {node}, UPID: {result}")
|
||
return result
|
||
|
||
def find_template_on_nodes(self, template_vmid: int, nodes: List[str] = None) -> Optional[Dict]:
|
||
"""
|
||
Найти шаблон по VMID на всех нодах кластера
|
||
|
||
Args:
|
||
template_vmid: VMID искомого шаблона
|
||
nodes: Список нод для поиска (опционально, по умолчанию все ноды)
|
||
|
||
Returns:
|
||
Dict с node и template info, или None если не найден
|
||
"""
|
||
if not self.proxmox:
|
||
return None
|
||
|
||
try:
|
||
if not nodes:
|
||
nodes_info = self.get_nodes()
|
||
nodes = [n.get('node') for n in nodes_info if n.get('node')]
|
||
|
||
for node in nodes:
|
||
try:
|
||
status = self.get_vm_status(node, template_vmid)
|
||
if status:
|
||
config = self.proxmox.nodes(node).qemu(template_vmid).config.get()
|
||
is_template = config.get('template', 0) == 1
|
||
return {
|
||
'node': node,
|
||
'vmid': template_vmid,
|
||
'name': config.get('name', ''),
|
||
'is_template': is_template,
|
||
'status': status
|
||
}
|
||
except Exception:
|
||
continue
|
||
|
||
return None
|
||
except Exception as e:
|
||
logger.error(f"Ошибка поиска шаблона {template_vmid}: {e}")
|
||
return None
|
||
|
||
def replicate_template_to_node(
|
||
self,
|
||
source_node: str,
|
||
template_vmid: int,
|
||
target_node: str,
|
||
target_storage: str = None,
|
||
timeout: int = 600
|
||
) -> Optional[int]:
|
||
"""
|
||
Полная репликация шаблона на другую ноду с ожиданием завершения.
|
||
Клонирует шаблон с тем же VMID и преобразует клон в шаблон на целевой ноде.
|
||
|
||
Args:
|
||
source_node: Исходная нода
|
||
template_vmid: VMID шаблона (будет сохранён на целевой ноде)
|
||
target_node: Целевая нода
|
||
target_storage: Целевое хранилище
|
||
timeout: Таймаут ожидания в секундах
|
||
|
||
Returns:
|
||
VMID шаблона на целевой ноде (тот же что и исходный), или None при ошибке
|
||
"""
|
||
if not self.proxmox:
|
||
return None
|
||
|
||
try:
|
||
# Step 1: Clone template to target node with SAME VMID
|
||
clone_result = self.clone_template_to_node(
|
||
source_node=source_node,
|
||
template_vmid=template_vmid,
|
||
target_node=target_node,
|
||
new_vmid=template_vmid, # Keep the same VMID
|
||
target_storage=target_storage
|
||
)
|
||
|
||
if not clone_result:
|
||
logger.error("Failed to start template clone")
|
||
return None
|
||
|
||
upid = clone_result['upid']
|
||
new_vmid = clone_result['new_vmid'] # Should be same as template_vmid
|
||
|
||
# Step 2: Wait for clone to complete
|
||
logger.info(f"Waiting for template clone task: {upid}")
|
||
success = self.wait_for_task(source_node, upid, timeout=timeout)
|
||
|
||
if not success:
|
||
logger.error(f"Template clone task failed or timed out: {upid}")
|
||
return None
|
||
|
||
# Step 3: Convert cloned VM to template
|
||
logger.info(f"Converting cloned VM {new_vmid} to template on {target_node}")
|
||
if not self.convert_to_template(target_node, new_vmid):
|
||
logger.error(f"Failed to convert VM {new_vmid} to template")
|
||
# VM created but not a template - still usable
|
||
return new_vmid
|
||
|
||
logger.info(f"Successfully replicated template {template_vmid} to {target_node} as {new_vmid}")
|
||
return new_vmid
|
||
|
||
except Exception as e:
|
||
logger.error(f"Error replicating template to {target_node}: {e}")
|
||
return None
|
||
|
||
def wait_for_vm_unlock(self, node: str, vmid: int, timeout: int = 120) -> bool:
|
||
"""
|
||
Дождаться снятия блокировки VM (lock: clone/migrate/...)
|
||
|
||
После wait_for_task клон может ещё держать блокировку, особенно
|
||
при cross-node клонировании — config.put в этот момент падает.
|
||
|
||
Args:
|
||
node: Имя ноды
|
||
vmid: VMID VM
|
||
timeout: Таймаут в секундах
|
||
|
||
Returns:
|
||
True если блокировка снята
|
||
"""
|
||
import time as time_module
|
||
|
||
start_time = time_module.time()
|
||
while time_module.time() - start_time < timeout:
|
||
try:
|
||
config = self.proxmox.nodes(node).qemu(vmid).config.get()
|
||
if 'lock' not in config:
|
||
return True
|
||
logger.info(f"VM {vmid} заблокирована ({config.get('lock')}), ожидание разблокировки...")
|
||
except Exception as e:
|
||
# При cross-node клоне VM может ещё не зарегистрироваться на целевой ноде
|
||
if time_module.time() - start_time > 15:
|
||
logger.warning(f"VM {vmid} недоступна при ожидании разблокировки: {e}")
|
||
time_module.sleep(2)
|
||
|
||
logger.warning(f"Таймаут ожидания разблокировки VM {vmid}")
|
||
return False
|
||
|
||
def update_cloud_init(
|
||
self, node: str, vmid: int,
|
||
ciuser=None, cipassword=None, sshkeys=None,
|
||
ipconfig0=None, nameserver=None, searchdomain=None,
|
||
) -> Dict:
|
||
"""Точечно обновить cloud-init параметры существующей VM.
|
||
|
||
Значение None — поле не трогаем; пустая строка "" — удалить параметр.
|
||
Пустой cipassword трактуется как «оставить текущий» (blank = keep).
|
||
sshkeys перед отправкой URL-кодируется (требование PVE).
|
||
"""
|
||
if not self.proxmox:
|
||
return {"success": False, "error": "Not connected"}
|
||
import urllib.parse
|
||
|
||
params: Dict = {}
|
||
deletes = []
|
||
|
||
def _set(key, value, allow_clear=True):
|
||
if value is None:
|
||
return
|
||
if value == '' and allow_clear:
|
||
deletes.append(key)
|
||
elif value != '':
|
||
params[key] = value
|
||
|
||
_set('ciuser', ciuser)
|
||
if cipassword: # пустой пароль не меняем
|
||
params['cipassword'] = cipassword
|
||
if sshkeys is not None:
|
||
if sshkeys.strip() == '':
|
||
deletes.append('sshkeys')
|
||
else:
|
||
params['sshkeys'] = urllib.parse.quote(sshkeys, safe='')
|
||
_set('ipconfig0', ipconfig0)
|
||
_set('nameserver', nameserver)
|
||
_set('searchdomain', searchdomain)
|
||
|
||
if deletes:
|
||
params['delete'] = ','.join(deletes)
|
||
if not params:
|
||
return {"success": True}
|
||
|
||
try:
|
||
self.proxmox.nodes(node).qemu(vmid).config.put(**params)
|
||
logger.info(f"Cloud-init VM {vmid} обновлён: {list(params.keys())}")
|
||
return {"success": True}
|
||
except Exception as e:
|
||
logger.error(f"Ошибка обновления cloud-init VM {vmid}: {e}")
|
||
return {"success": False, "error": str(e)}
|
||
|
||
def configure_vm(
|
||
self,
|
||
node: str,
|
||
vmid: int,
|
||
cores: int = None,
|
||
memory: int = None,
|
||
disk_size: int = None,
|
||
disk_storage: str = None,
|
||
network_bridge: str = None,
|
||
cloud_init_user: str = None,
|
||
cloud_init_password: str = None,
|
||
ssh_keys: str = None,
|
||
ip_config: str = None,
|
||
onboot: bool = None
|
||
) -> Dict:
|
||
"""
|
||
Настроить параметры VM после клонирования
|
||
|
||
Параметры применяются независимыми группами (compute, network,
|
||
cloudinit, disk), чтобы ошибка одной группы не отменяла остальные.
|
||
|
||
Args:
|
||
node: Имя ноды
|
||
vmid: VMID VM
|
||
cores: Количество ядер CPU (итоговое число vCPU, sockets=1)
|
||
memory: Память в MB
|
||
disk_size: Размер диска в GB
|
||
disk_storage: Хранилище диска
|
||
network_bridge: Сетевой мост
|
||
cloud_init_user: Пользователь cloud-init
|
||
cloud_init_password: Пароль cloud-init
|
||
ssh_keys: SSH ключи (public)
|
||
ip_config: Конфигурация IP (например, "ip=dhcp" или "ip=192.168.1.100/24,gw=192.168.1.1")
|
||
onboot: Автозапуск VM при старте хоста
|
||
|
||
Returns:
|
||
{"ok": bool, "applied": [группы], "errors": {группа: сообщение}}
|
||
"""
|
||
import time as time_module
|
||
|
||
result = {"ok": False, "applied": [], "errors": {}}
|
||
if not self.proxmox:
|
||
result["errors"]["connection"] = "Proxmox client not connected"
|
||
return result
|
||
|
||
try:
|
||
current_config = dict(self.proxmox.nodes(node).qemu(vmid).config.get())
|
||
except Exception as e:
|
||
logger.warning(f"Не удалось прочитать конфигурацию VM {vmid}: {e}")
|
||
current_config = {}
|
||
|
||
def _apply(group: str, params: Dict):
|
||
for attempt in (1, 2):
|
||
try:
|
||
self.proxmox.nodes(node).qemu(vmid).config.put(**params)
|
||
result["applied"].append(group)
|
||
logger.info(f"VM {vmid}: группа '{group}' применена: {list(params.keys())}")
|
||
return
|
||
except Exception as e:
|
||
if attempt == 1 and 'lock' in str(e).lower():
|
||
logger.info(f"VM {vmid} заблокирована, повтор группы '{group}' через 5с")
|
||
time_module.sleep(5)
|
||
continue
|
||
result["errors"][group] = str(e)
|
||
logger.error(f"VM {vmid}: ошибка применения группы '{group}': {e}")
|
||
return
|
||
|
||
# CPU / RAM / автозапуск
|
||
compute_params = {}
|
||
if cores:
|
||
compute_params['cores'] = cores
|
||
# sockets=1 гарантирует, что итоговое число vCPU равно cores
|
||
# независимо от топологии шаблона
|
||
compute_params['sockets'] = 1
|
||
if memory:
|
||
compute_params['memory'] = memory
|
||
if onboot is not None:
|
||
compute_params['onboot'] = 1 if onboot else 0
|
||
if compute_params:
|
||
_apply('compute', compute_params)
|
||
|
||
# Сеть: сохраняем модель/MAC/опции из шаблона, меняем только bridge
|
||
if network_bridge:
|
||
existing_net0 = current_config.get('net0')
|
||
if existing_net0:
|
||
parts = [p for p in str(existing_net0).split(',') if p.strip()]
|
||
new_parts = []
|
||
replaced = False
|
||
for p in parts:
|
||
if p.startswith('bridge='):
|
||
new_parts.append(f'bridge={network_bridge}')
|
||
replaced = True
|
||
else:
|
||
new_parts.append(p)
|
||
if not replaced:
|
||
new_parts.append(f'bridge={network_bridge}')
|
||
net0_value = ','.join(new_parts)
|
||
else:
|
||
net0_value = f'virtio,bridge={network_bridge}'
|
||
_apply('network', {'net0': net0_value})
|
||
|
||
# Cloud-init
|
||
ci_params = {}
|
||
if cloud_init_user:
|
||
ci_params['ciuser'] = cloud_init_user
|
||
if cloud_init_password:
|
||
ci_params['cipassword'] = cloud_init_password
|
||
if ssh_keys:
|
||
# SSH keys need URL encoding
|
||
import urllib.parse
|
||
ci_params['sshkeys'] = urllib.parse.quote(ssh_keys, safe='')
|
||
if ip_config:
|
||
ci_params['ipconfig0'] = ip_config
|
||
if ci_params:
|
||
has_ci_drive = any(
|
||
isinstance(v, str) and 'cloudinit' in v for v in current_config.values()
|
||
)
|
||
# Если конфиг прочитать не удалось — всё равно пробуем применить
|
||
if has_ci_drive or not current_config:
|
||
_apply('cloudinit', ci_params)
|
||
else:
|
||
result["errors"]["cloudinit"] = (
|
||
"шаблон без cloud-init диска — пользователь/пароль/SSH/IP не применены"
|
||
)
|
||
logger.warning(f"VM {vmid}: нет cloud-init диска, параметры cloud-init пропущены")
|
||
|
||
# Диск
|
||
if disk_size:
|
||
disk_error = self._resize_vm_disk(node, vmid, disk_size)
|
||
if disk_error:
|
||
result["errors"]["disk"] = disk_error
|
||
logger.error(f"VM {vmid}: ошибка ресайза диска: {disk_error}")
|
||
else:
|
||
result["applied"].append('disk')
|
||
|
||
result["ok"] = not result["errors"]
|
||
return result
|
||
|
||
def _resize_vm_disk(self, node: str, vmid: int, disk_size: int) -> Optional[str]:
|
||
"""
|
||
Увеличить основной диск VM до disk_size GB.
|
||
|
||
Returns:
|
||
None при успехе (или если ресайз не нужен), иначе текст ошибки
|
||
"""
|
||
import re
|
||
|
||
def _parse_size_gb(value: str) -> int:
|
||
if 'size=' not in value:
|
||
return 0
|
||
size_part = value.split('size=')[1].split(',')[0]
|
||
try:
|
||
if size_part.endswith('T'):
|
||
return int(float(size_part[:-1]) * 1024)
|
||
if size_part.endswith('G'):
|
||
return int(float(size_part[:-1]))
|
||
if size_part.endswith('M'):
|
||
return int(float(size_part[:-1])) // 1024
|
||
return int(size_part) // (1024 ** 3)
|
||
except ValueError:
|
||
return 0
|
||
|
||
try:
|
||
config = self.proxmox.nodes(node).qemu(vmid).config.get()
|
||
|
||
candidates = {}
|
||
for key, value in config.items():
|
||
if not re.match(r'^(scsi|virtio|sata|ide)\d+$', key):
|
||
continue
|
||
str_value = str(value)
|
||
if 'media=cdrom' in str_value.lower() or 'cloudinit' in str_value.lower():
|
||
continue
|
||
candidates[key] = str_value
|
||
|
||
if not candidates:
|
||
return f'основной диск VM {vmid} не найден'
|
||
|
||
# Предпочитаем загрузочный диск из boot=order=...
|
||
disk_key = None
|
||
boot_match = re.search(r'order=([^,;]+(?:;[^,;]+)*)', str(config.get('boot', '')))
|
||
if boot_match:
|
||
for dev in boot_match.group(1).split(';'):
|
||
if dev in candidates:
|
||
disk_key = dev
|
||
break
|
||
if not disk_key:
|
||
disk_key = sorted(candidates)[0]
|
||
|
||
current_size_gb = _parse_size_gb(candidates[disk_key])
|
||
if disk_size <= current_size_gb:
|
||
logger.info(f"Диск {disk_key} VM {vmid} уже имеет размер {current_size_gb}G >= запрошенного {disk_size}G")
|
||
return None
|
||
|
||
resize_upid = self.proxmox.nodes(node).qemu(vmid).resize.put(
|
||
disk=disk_key,
|
||
size=f'{disk_size}G'
|
||
)
|
||
if isinstance(resize_upid, str):
|
||
if not self.wait_for_task(node, resize_upid, timeout=120):
|
||
status = self.get_task_status(node, resize_upid) or {}
|
||
reason = status.get('exitstatus') or 'resize task failed'
|
||
return f'ресайз {disk_key} не удался: {reason}'
|
||
logger.info(f"Диск {disk_key} VM {vmid} изменен с {current_size_gb}G до {disk_size}G")
|
||
return None
|
||
except Exception as e:
|
||
return str(e)
|
||
|
||
def get_task_status(self, node: str, upid: str, raise_on_error: bool = False) -> Dict:
|
||
"""Get status of a Proxmox task by UPID.
|
||
|
||
raise_on_error=True — пробросить исключение вместо возврата {}.
|
||
Нужно вызывающим, которым важно отличать «задача не найдена» от
|
||
транзитной сетевой ошибки (см. run_upid_task_sync): пустой словарь
|
||
эту разницу стирает.
|
||
"""
|
||
if not self.proxmox:
|
||
if raise_on_error:
|
||
raise RuntimeError(f"Proxmox client for {self.host} is not connected")
|
||
return {}
|
||
try:
|
||
status = self.proxmox.nodes(node).tasks(upid).status.get()
|
||
return status
|
||
except Exception as e:
|
||
if raise_on_error:
|
||
raise
|
||
logger.error(f"Error getting task status {upid}: {e}")
|
||
return {}
|
||
|
||
def wait_for_task(self, node: str, upid: str, timeout: int = 300) -> bool:
|
||
"""
|
||
Ждать завершения задачи
|
||
|
||
Args:
|
||
node: Имя ноды
|
||
upid: UPID задачи
|
||
timeout: Таймаут в секундах
|
||
|
||
Returns:
|
||
True если задача успешно завершена
|
||
"""
|
||
import time as time_module
|
||
|
||
start_time = time_module.time()
|
||
while time_module.time() - start_time < timeout:
|
||
status = self.get_task_status(node, upid)
|
||
if status:
|
||
if status.get('status') == 'stopped':
|
||
# Проверяем exitstatus
|
||
exit_status = status.get('exitstatus')
|
||
if exit_status != 'OK':
|
||
logger.error(f"Proxmox task {upid} failed with exitstatus={exit_status!r}")
|
||
# 'OK' — success; 'WARNINGS: N' — success with warnings (container still created)
|
||
return exit_status == 'OK' or (isinstance(exit_status, str) and exit_status.startswith('WARNINGS:'))
|
||
time_module.sleep(2)
|
||
|
||
logger.warning(f"Таймаут ожидания задачи {upid}")
|
||
return False
|
||
|
||
def delete_vm(self, node: str, vmid: int, force: bool = False) -> Optional[str]:
|
||
"""
|
||
Удалить виртуальную машину
|
||
|
||
Args:
|
||
node: Имя ноды
|
||
vmid: ID виртуальной машины
|
||
force: Принудительное удаление (остановит VM если запущена)
|
||
|
||
Returns:
|
||
UPID задачи удаления или True при успехе
|
||
|
||
Raises:
|
||
Exception: При ошибке удаления
|
||
"""
|
||
if not self.proxmox:
|
||
raise Exception("Proxmox client not connected")
|
||
|
||
# Check if VM is HA managed and remove from HA first
|
||
try:
|
||
ha_status = self.proxmox.nodes(node).qemu(vmid).status.current.get()
|
||
if ha_status and isinstance(ha_status, dict):
|
||
ha_info = ha_status.get('ha', {})
|
||
if ha_info.get('managed'):
|
||
# Remove from HA
|
||
try:
|
||
self.proxmox.cluster.ha.resources(f"vm:{vmid}").delete()
|
||
logger.info(f"Removed VM {vmid} from HA before deletion")
|
||
except Exception as ha_e:
|
||
logger.warning(f"Failed to remove VM {vmid} from HA: {ha_e}")
|
||
except Exception as e:
|
||
logger.debug(f"Could not check HA status for VM {vmid}: {e}")
|
||
|
||
params = {'purge': 1} # Always purge to handle HA and other resources
|
||
if force:
|
||
params['force'] = 1
|
||
|
||
result = self.proxmox.nodes(node).qemu(vmid).delete(**params)
|
||
logger.info(f"Запущено удаление VM {vmid} на {node}")
|
||
return result if result else True
|
||
|
||
def delete_container(self, node: str, vmid: int, force: bool = False) -> Optional[str]:
|
||
"""
|
||
Удалить контейнер (LXC)
|
||
|
||
Args:
|
||
node: Имя ноды
|
||
vmid: ID контейнера
|
||
force: Принудительное удаление (остановит контейнер если запущен)
|
||
|
||
Returns:
|
||
UPID задачи удаления или True при успехе
|
||
|
||
Raises:
|
||
Exception: При ошибке удаления
|
||
"""
|
||
if not self.proxmox:
|
||
raise Exception("Proxmox client not connected")
|
||
|
||
# Check if container is HA managed and remove from HA first
|
||
try:
|
||
ha_status = self.proxmox.nodes(node).lxc(vmid).status.current.get()
|
||
if ha_status and isinstance(ha_status, dict):
|
||
ha_info = ha_status.get('ha', {})
|
||
if ha_info.get('managed'):
|
||
# Remove from HA
|
||
try:
|
||
self.proxmox.cluster.ha.resources(f"ct:{vmid}").delete()
|
||
logger.info(f"Removed container {vmid} from HA before deletion")
|
||
except Exception as ha_e:
|
||
logger.warning(f"Failed to remove container {vmid} from HA: {ha_e}")
|
||
except Exception as e:
|
||
logger.debug(f"Could not check HA status for container {vmid}: {e}")
|
||
|
||
params = {'purge': 1} # Always purge to handle HA and other resources
|
||
if force:
|
||
params['force'] = 1
|
||
|
||
result = self.proxmox.nodes(node).lxc(vmid).delete(**params)
|
||
logger.info(f"Запущено удаление контейнера {vmid} на {node}")
|
||
return result if result else True
|
||
|
||
def get_vm_config(self, node: str, vmid: int) -> Optional[Dict]:
|
||
"""
|
||
Получить конфигурацию виртуальной машины
|
||
|
||
Args:
|
||
node: Имя ноды
|
||
vmid: ID виртуальной машины
|
||
|
||
Returns:
|
||
Словарь с конфигурацией VM
|
||
"""
|
||
if not self.proxmox:
|
||
return None
|
||
|
||
try:
|
||
config = self.proxmox.nodes(node).qemu(vmid).config.get()
|
||
return dict(config)
|
||
except Exception as e:
|
||
logger.error(f"Ошибка получения конфигурации VM {vmid}: {e}")
|
||
return None
|
||
|
||
def update_vm_config(self, node: str, vmid: int, config: Dict) -> bool:
|
||
"""
|
||
Обновить конфигурацию виртуальной машины
|
||
|
||
Args:
|
||
node: Имя ноды
|
||
vmid: ID виртуальной машины
|
||
config: Словарь с параметрами для обновления
|
||
|
||
Returns:
|
||
True при успехе
|
||
"""
|
||
if not self.proxmox:
|
||
return False
|
||
|
||
import re
|
||
|
||
# Фильтруем разрешенные параметры
|
||
allowed_params = {
|
||
'cores', 'sockets', 'vcpus', 'numa', 'memory', 'balloon', 'name', 'description',
|
||
'cpu', 'cpulimit', 'cpuunits', 'affinity', 'onboot', 'startup', 'boot', 'bootdisk',
|
||
'agent', 'ostype', 'tablet', 'hotplug', 'protection', 'kvm',
|
||
'ciuser', 'cipassword', 'sshkeys', 'tags',
|
||
# Hardware editor: CPU/NUMA, дисплей, контроллер, тип платформы,
|
||
# efidisk0 — EFI-диск, обязателен для загрузки в режиме OVMF (UEFI).
|
||
'numa', 'vga', 'scsihw', 'machine', 'bios', 'efidisk0',
|
||
}
|
||
# Индексированные ключи: сеть/диски + passthrough (PCI/USB) + serial
|
||
indexed_re = re.compile(
|
||
r'^(net|scsi|virtio|ide|sata|ipconfig|hostpci|usb|serial)\d+$'
|
||
)
|
||
# Удаляемые ключи: интерфейсы, passthrough-устройства + опциональные флаги
|
||
delete_token_re = re.compile(
|
||
r'^(net\d+|hostpci\d+|usb\d+|serial\d+|startup|onboot|protection|tags)$'
|
||
)
|
||
|
||
filtered_config = {}
|
||
for k, v in config.items():
|
||
if k in ('onboot', 'protection'):
|
||
filtered_config[k] = 1 if v in (True, 1, '1') else 0
|
||
elif k in allowed_params or indexed_re.match(k):
|
||
filtered_config[k] = v
|
||
# Удаление ключей: delete='net1' / 'startup' (или 'net1,startup')
|
||
delete_value = config.get('delete')
|
||
if isinstance(delete_value, str) and delete_value and all(
|
||
delete_token_re.match(tok) for tok in delete_value.split(',')
|
||
):
|
||
filtered_config['delete'] = delete_value
|
||
|
||
if not filtered_config:
|
||
logger.warning(f"Нет разрешенных параметров для обновления VM {vmid}")
|
||
return False
|
||
|
||
try:
|
||
self.proxmox.nodes(node).qemu(vmid).config.put(**filtered_config)
|
||
logger.info(f"Конфигурация VM {vmid} обновлена: {list(filtered_config.keys())}")
|
||
return True
|
||
except Exception as e:
|
||
logger.error(f"Ошибка обновления конфигурации VM {vmid}: {e}")
|
||
raise RuntimeError(f"Proxmox: {e}") from e
|
||
|
||
def get_container_config(self, node: str, vmid: int) -> Optional[Dict]:
|
||
"""
|
||
Получить конфигурацию LXC контейнера
|
||
|
||
Args:
|
||
node: Имя ноды
|
||
vmid: ID контейнера
|
||
|
||
Returns:
|
||
Словарь с конфигурацией
|
||
"""
|
||
if not self.proxmox:
|
||
return None
|
||
|
||
try:
|
||
config = self.proxmox.nodes(node).lxc(vmid).config.get()
|
||
return dict(config)
|
||
except Exception as e:
|
||
logger.error(f"Ошибка получения конфигурации LXC {vmid}: {e}")
|
||
return None
|
||
|
||
def update_container_config(self, node: str, vmid: int, config: Dict) -> bool:
|
||
"""
|
||
Обновить конфигурацию LXC контейнера
|
||
|
||
Args:
|
||
node: Имя ноды
|
||
vmid: ID контейнера
|
||
config: Словарь с параметрами для обновления
|
||
|
||
Returns:
|
||
True при успехе
|
||
"""
|
||
if not self.proxmox:
|
||
return False
|
||
|
||
import re
|
||
|
||
allowed_params = {
|
||
'hostname', 'memory', 'swap', 'cores', 'cpulimit', 'cpuunits',
|
||
'onboot', 'startup', 'protection', 'description', 'nameserver',
|
||
'searchdomain', 'tags',
|
||
}
|
||
net_re = re.compile(r'^net\d+$')
|
||
delete_token_re = re.compile(r'^(net\d+|startup|onboot|protection|tags)$')
|
||
|
||
filtered_config = {}
|
||
for k, v in config.items():
|
||
if k in ('onboot', 'protection'):
|
||
filtered_config[k] = 1 if v in (True, 1, '1') else 0
|
||
elif k in allowed_params or net_re.match(k):
|
||
filtered_config[k] = v
|
||
delete_value = config.get('delete')
|
||
if isinstance(delete_value, str) and delete_value and all(
|
||
delete_token_re.match(tok) for tok in delete_value.split(',')
|
||
):
|
||
filtered_config['delete'] = delete_value
|
||
|
||
if not filtered_config:
|
||
logger.warning(f"Нет разрешенных параметров для обновления LXC {vmid}")
|
||
return False
|
||
|
||
try:
|
||
self.proxmox.nodes(node).lxc(vmid).config.put(**filtered_config)
|
||
logger.info(f"Конфигурация LXC {vmid} обновлена: {list(filtered_config.keys())}")
|
||
return True
|
||
except Exception as e:
|
||
logger.error(f"Ошибка обновления конфигурации LXC {vmid}: {e}")
|
||
raise RuntimeError(f"Proxmox: {e}") from e
|
||
|
||
def resize_container_disk(self, node: str, vmid: int, disk: str, size: str) -> bool:
|
||
"""
|
||
Изменить размер диска LXC контейнера
|
||
|
||
Args:
|
||
node: Имя ноды
|
||
vmid: ID контейнера
|
||
disk: Имя диска (rootfs, mp0, mp1, etc)
|
||
size: Размер для добавления (например '+5G')
|
||
|
||
Returns:
|
||
True при успехе
|
||
"""
|
||
if not self.proxmox:
|
||
return False
|
||
|
||
resize_upid = self.proxmox.nodes(node).lxc(vmid).resize.put(disk=disk, size=size)
|
||
# Проверяем результат задачи: упавший ресайз не должен считаться
|
||
# успешным (см. resize_vm_disk).
|
||
if isinstance(resize_upid, str):
|
||
if not self.wait_for_task(node, resize_upid, timeout=120):
|
||
status = self.get_task_status(node, resize_upid) or {}
|
||
reason = status.get('exitstatus') or 'resize task failed'
|
||
logger.error(f"Ресайз диска {disk} LXC {vmid} провалился: {reason}")
|
||
raise RuntimeError(f"Proxmox: {reason}")
|
||
logger.info(f"Диск {disk} LXC {vmid} изменен на {size}")
|
||
return True
|
||
|
||
def move_container_disk(self, node: str, vmid: int, disk: str, target_storage: str,
|
||
delete: bool = True) -> Dict:
|
||
"""Переместить том LXC контейнера в другое хранилище (POST lxc/{vmid}/move_volume).
|
||
|
||
Args:
|
||
disk: имя тома (rootfs, mp0, mp1, ...)
|
||
target_storage: целевое хранилище
|
||
delete: удалить исходный том после копирования
|
||
Returns:
|
||
{"success": bool, "upid"?: str, "error"?: str}
|
||
"""
|
||
if not self.proxmox:
|
||
return {"success": False, "error": "Not connected"}
|
||
try:
|
||
params = {"volume": disk, "storage": target_storage, "delete": 1 if delete else 0}
|
||
upid = self.proxmox.nodes(node).lxc(vmid).move_volume.post(**params)
|
||
if isinstance(upid, str):
|
||
if not self.wait_for_task(node, upid, timeout=3600):
|
||
status = self.get_task_status(node, upid) or {}
|
||
reason = status.get("exitstatus") or "move_volume task failed"
|
||
logger.error(f"Move тома {disk} LXC {vmid} провалился: {reason}")
|
||
return {"success": False, "error": f"Proxmox: {reason}"}
|
||
logger.info(f"Том {disk} LXC {vmid} перемещён в {target_storage}")
|
||
return {"success": True, "upid": upid if isinstance(upid, str) else None}
|
||
except Exception as e:
|
||
logger.error(f"Ошибка перемещения тома {disk} LXC {vmid}: {e}")
|
||
return {"success": False, "error": str(e)}
|
||
|
||
def get_vm_interfaces(self, node: str, vmid: int) -> List[Dict]:
|
||
"""
|
||
Получить сетевые интерфейсы VM через QEMU guest agent
|
||
|
||
Args:
|
||
node: Имя ноды
|
||
vmid: ID виртуальной машины
|
||
|
||
Returns:
|
||
Список интерфейсов с IP адресами
|
||
"""
|
||
if not self.proxmox:
|
||
return []
|
||
|
||
try:
|
||
# Пытаемся получить информацию через guest agent
|
||
result = self.proxmox.nodes(node).qemu(vmid).agent('network-get-interfaces').get()
|
||
|
||
interfaces = []
|
||
if result and 'result' in result:
|
||
for iface in result['result']:
|
||
if 'ip-addresses' in iface and iface['ip-addresses']:
|
||
name = iface.get('name', 'unknown')
|
||
# Пропускаем loopback
|
||
if name == 'lo':
|
||
continue
|
||
|
||
ips = []
|
||
for ip_info in iface['ip-addresses']:
|
||
ip = ip_info.get('ip-address')
|
||
ip_type = ip_info.get('ip-address-type', 'unknown')
|
||
if ip and ip_type in ['ipv4', 'ipv6']:
|
||
ips.append({
|
||
'address': ip,
|
||
'type': ip_type,
|
||
'prefix': ip_info.get('prefix', 0)
|
||
})
|
||
|
||
if ips:
|
||
interfaces.append({
|
||
'name': name,
|
||
'hardware_address': iface.get('hardware-address', ''),
|
||
'ips': ips
|
||
})
|
||
|
||
return interfaces
|
||
except Exception as e:
|
||
logger.debug(f"Не удалось получить сетевые интерфейсы VM {vmid} (guest agent может быть не установлен): {e}")
|
||
return []
|
||
|
||
def get_vm_blockstats(self, node: str, vmid: int) -> str:
|
||
"""Raw HMP 'info blockstats' text via the QEMU monitor (VM only).
|
||
Returns '' if unavailable."""
|
||
if not self.proxmox:
|
||
return ""
|
||
try:
|
||
res = self.proxmox.nodes(node).qemu(vmid).monitor.post(command="info blockstats")
|
||
return res if isinstance(res, str) else str(res or "")
|
||
except Exception as e:
|
||
logger.debug(f"blockstats unavailable for VM {vmid} on {node}: {e}")
|
||
return ""
|
||
|
||
def get_node_netstat(self, node: str) -> list:
|
||
"""Per-tap/veth interface counters for the node ([] on failure)."""
|
||
if not self.proxmox:
|
||
return []
|
||
try:
|
||
res = self.proxmox.nodes(node).netstat.get()
|
||
return res if isinstance(res, list) else []
|
||
except Exception as e:
|
||
logger.debug(f"netstat unavailable for node {node}: {e}")
|
||
return []
|
||
|
||
def get_container_interfaces(self, node: str, vmid: int,
|
||
include_live: bool = True) -> List[Dict]:
|
||
"""
|
||
Получить сетевые интерфейсы контейнера с их IP адресами.
|
||
|
||
LXC держит адрес внутри строки netN
|
||
("name=eth0,bridge=vmbr0,hwaddr=..,ip=10.0.0.5/24"); ключа ipconfigN у
|
||
контейнеров не бывает — это cloud-init QEMU. При ip=dhcp статики в
|
||
конфиге нет вовсе, поэтому фактические адреса запущенного контейнера
|
||
дочитываются из /nodes/{node}/lxc/{vmid}/interfaces.
|
||
|
||
Args:
|
||
node: Имя ноды
|
||
vmid: ID контейнера
|
||
include_live: опрашивать ли /interfaces (бесполезно для остановленного CT)
|
||
|
||
Returns:
|
||
Список интерфейсов: {'name', 'hardware_address', 'ips': [{'address', 'type', 'prefix'}]}
|
||
"""
|
||
if not self.proxmox:
|
||
return []
|
||
|
||
interfaces: Dict[str, Dict] = {}
|
||
|
||
def _slot(name: str, mac: str = '') -> Dict:
|
||
iface = interfaces.setdefault(
|
||
name, {'name': name, 'hardware_address': '', 'ips': []}
|
||
)
|
||
if mac and not iface['hardware_address']:
|
||
iface['hardware_address'] = mac
|
||
return iface
|
||
|
||
def _add_ip(iface: Dict, value: str) -> None:
|
||
"""Принимает '10.0.0.5/24' либо '10.0.0.5'; мусор и dhcp игнорирует."""
|
||
addr, _, prefix = (value or '').strip().partition('/')
|
||
if not addr or addr in ('dhcp', 'manual', 'auto'):
|
||
return
|
||
try:
|
||
ip_obj = ipaddress.ip_address(addr)
|
||
except ValueError:
|
||
return
|
||
if ip_obj.is_loopback or ip_obj.is_link_local:
|
||
return
|
||
if any(existing['address'] == addr for existing in iface['ips']):
|
||
return
|
||
iface['ips'].append({
|
||
'address': addr,
|
||
'type': 'ipv6' if ip_obj.version == 6 else 'ipv4',
|
||
'prefix': int(prefix) if prefix.isdigit() else (128 if ip_obj.version == 6 else 32),
|
||
})
|
||
|
||
# 1) Статика из конфига контейнера
|
||
try:
|
||
config = self.proxmox.nodes(node).lxc(vmid).config.get()
|
||
for nic in parse_guest_nics(config):
|
||
iface = _slot(nic.name or f'eth{nic.index}', nic.mac or '')
|
||
_add_ip(iface, nic.ip or '')
|
||
_add_ip(iface, nic.ip6 or '')
|
||
except Exception as e:
|
||
logger.debug(f"Не удалось прочитать конфиг контейнера {vmid}: {e}")
|
||
|
||
# 2) Живые адреса — единственный источник для DHCP-контейнеров.
|
||
# Если статика уже найдена, лишний запрос не делаем: у остановленного
|
||
# контейнера этот эндпоинт всё равно отдаёт ошибку.
|
||
has_static_v4 = any(
|
||
ip_info['type'] == 'ipv4'
|
||
for iface in interfaces.values() for ip_info in iface['ips']
|
||
)
|
||
if not has_static_v4 and include_live:
|
||
try:
|
||
for iface_data in (self.proxmox.nodes(node).lxc(vmid).interfaces.get() or []):
|
||
name = iface_data.get('name') or ''
|
||
if not name or name == 'lo':
|
||
continue
|
||
iface = _slot(name, (iface_data.get('hwaddr') or '').upper())
|
||
_add_ip(iface, iface_data.get('inet') or '')
|
||
_add_ip(iface, iface_data.get('inet6') or '')
|
||
except Exception as e:
|
||
logger.debug(
|
||
f"Интерфейсы контейнера {vmid} недоступны (возможно, остановлен): {e}"
|
||
)
|
||
|
||
return [iface for iface in interfaces.values() if iface['ips']]
|
||
|
||
def execute_command(self, node: str, vmid: int, command: str, timeout: int = 30) -> Dict:
|
||
"""
|
||
Выполнить команду на VM через QEMU guest agent
|
||
|
||
Args:
|
||
node: Имя ноды
|
||
vmid: ID виртуальной машины
|
||
command: Команда для выполнения (например: "ls -la /tmp")
|
||
timeout: Таймаут в секундах для ожидания результата
|
||
|
||
Returns:
|
||
Dict с ключами: success, stdout, stderr, exit_code
|
||
"""
|
||
if not self.proxmox:
|
||
return {
|
||
'success': False,
|
||
'error': 'Proxmox connection not initialized',
|
||
'stdout': '',
|
||
'stderr': '',
|
||
'exit_code': -1
|
||
}
|
||
|
||
try:
|
||
# Запускаем команду через guest agent
|
||
exec_result = self.proxmox.nodes(node).qemu(vmid).agent.exec.post(
|
||
command=command
|
||
)
|
||
|
||
if 'pid' not in exec_result:
|
||
return {
|
||
'success': False,
|
||
'error': 'Failed to start command execution',
|
||
'stdout': '',
|
||
'stderr': '',
|
||
'exit_code': -1
|
||
}
|
||
|
||
pid = exec_result['pid']
|
||
|
||
# Ждем завершения команды и возвращаем результат
|
||
return self._wait_exec_status(node, vmid, pid, timeout)
|
||
|
||
except Exception as e:
|
||
logger.error(f"Failed to execute command on VM {vmid}: {e}")
|
||
return {
|
||
'success': False,
|
||
'error': str(e),
|
||
'stdout': '',
|
||
'stderr': '',
|
||
'exit_code': -1
|
||
}
|
||
|
||
def _wait_exec_status(self, node: str, vmid: int, pid: int, timeout: int) -> Dict:
|
||
"""
|
||
Опросить статус выполнения команды guest agent по pid до завершения.
|
||
|
||
Returns:
|
||
Dict с ключами: success, stdout, stderr, exit_code
|
||
"""
|
||
start_time = time.time()
|
||
while time.time() - start_time < timeout:
|
||
try:
|
||
status = self.proxmox.nodes(node).qemu(vmid).agent('exec-status').get(pid=pid)
|
||
|
||
if status.get('exited'):
|
||
# Команда завершена
|
||
return {
|
||
'success': True,
|
||
'stdout': status.get('out-data', ''),
|
||
'stderr': status.get('err-data', ''),
|
||
'exit_code': status.get('exitcode', 0)
|
||
}
|
||
except Exception as e:
|
||
logger.debug(f"Error checking command status: {e}")
|
||
|
||
# Ждем немного перед следующей проверкой
|
||
time.sleep(0.5)
|
||
|
||
# Таймаут
|
||
return {
|
||
'success': False,
|
||
'error': f'Command execution timeout ({timeout}s)',
|
||
'stdout': '',
|
||
'stderr': '',
|
||
'exit_code': -1
|
||
}
|
||
|
||
def execute_script(self, node: str, vmid: int, script_content: str,
|
||
interpreter: str = "/bin/bash", timeout: int = 60) -> Dict:
|
||
"""
|
||
Выполнить bash скрипт на VM через QEMU guest agent
|
||
|
||
Args:
|
||
node: Имя ноды
|
||
vmid: ID виртуальной машины
|
||
script_content: Содержимое скрипта
|
||
interpreter: Путь к интерпретатору (по умолчанию /bin/bash)
|
||
timeout: Таймаут в секундах
|
||
|
||
Returns:
|
||
Dict с результатом выполнения
|
||
"""
|
||
if not self.proxmox:
|
||
return {
|
||
'success': False,
|
||
'error': 'Proxmox connection not initialized',
|
||
'stdout': '',
|
||
'stderr': '',
|
||
'exit_code': -1
|
||
}
|
||
|
||
# Нормализуем переводы строк CRLF/CR -> LF. Скрипты из браузерного
|
||
# textarea часто приходят с \r, и тогда каждая команда ломается
|
||
# ("$'cmd\\r': command not found", "growpart 1\\r" и т.п.).
|
||
script_content = script_content.replace('\r\n', '\n').replace('\r', '\n')
|
||
|
||
try:
|
||
# Передаем скрипт напрямую интерпретатору через stdin (input-data).
|
||
# Интерпретатор запускается без -c, читая тело скрипта со stdin,
|
||
# поэтому многострочные скрипты, кавычки и shebang (#!...) в первой
|
||
# строке обрабатываются корректно без дополнительного слоя кавычек.
|
||
#
|
||
# Proxmox API сам кодирует input-data в base64 перед передачей
|
||
# guest agent'у, поэтому передаем сырое содержимое скрипта.
|
||
# Proxmox кодирует input-data в base64 силами Perl, а тот падает
|
||
# с "Wide character in subroutine entry" на любой строке с
|
||
# не-ASCII — например на комментариях кириллицей. Отдаём те же
|
||
# UTF-8 байты, представленные как latin-1: в JSON уходят байты,
|
||
# гость получает корректный UTF-8.
|
||
payload = script_content.encode('utf-8').decode('latin-1')
|
||
|
||
exec_result = self.proxmox.nodes(node).qemu(vmid).agent.exec.post(
|
||
command=[interpreter],
|
||
**{'input-data': payload}
|
||
)
|
||
|
||
if 'pid' not in exec_result:
|
||
return {
|
||
'success': False,
|
||
'error': 'Failed to start script execution',
|
||
'stdout': '',
|
||
'stderr': '',
|
||
'exit_code': -1
|
||
}
|
||
|
||
return self._wait_exec_status(node, vmid, exec_result['pid'], timeout)
|
||
|
||
except Exception as e:
|
||
logger.error(f"Failed to execute script on VM {vmid}: {e}")
|
||
return {
|
||
'success': False,
|
||
'error': str(e),
|
||
'stdout': '',
|
||
'stderr': '',
|
||
'exit_code': -1
|
||
}
|
||
|
||
def get_all_lxc_templates(self) -> List[Dict]:
|
||
"""
|
||
Получить шаблоны LXC со всех нод кластера с информацией о типе хранилища.
|
||
|
||
Returns:
|
||
Список шаблонов с полями: volid, storage, node, shared, storage_type
|
||
"""
|
||
if not self.proxmox:
|
||
return []
|
||
|
||
all_templates = []
|
||
seen = set() # Для дедупликации shared шаблонов
|
||
|
||
try:
|
||
nodes = self.get_nodes()
|
||
|
||
# Сначала получим информацию о всех хранилищах кластера
|
||
storage_info = {}
|
||
try:
|
||
cluster_storage = self.proxmox.storage.get()
|
||
for stor in cluster_storage:
|
||
stor_name = stor.get('storage')
|
||
# shared=1 означает что хранилище доступно со всех нод
|
||
storage_info[stor_name] = {
|
||
'shared': stor.get('shared', 0) == 1,
|
||
'type': stor.get('type', 'unknown')
|
||
}
|
||
except Exception as e:
|
||
logger.warning(f"Could not get cluster storage info: {e}")
|
||
|
||
for node_info in nodes:
|
||
node = node_info.get('node')
|
||
if not node:
|
||
continue
|
||
|
||
try:
|
||
templates = self.get_lxc_templates(node)
|
||
for tpl in templates:
|
||
stor_name = tpl.get('storage', '')
|
||
volid = tpl.get('volid', '')
|
||
|
||
# Определяем shared ли хранилище
|
||
is_shared = storage_info.get(stor_name, {}).get('shared', False)
|
||
stor_type = storage_info.get(stor_name, {}).get('type', 'unknown')
|
||
|
||
# Для shared хранилищ - добавляем только один раз
|
||
if is_shared:
|
||
if volid in seen:
|
||
continue
|
||
seen.add(volid)
|
||
|
||
tpl['shared'] = is_shared
|
||
tpl['storage_type'] = stor_type
|
||
all_templates.append(tpl)
|
||
except Exception as e:
|
||
logger.warning(f"Could not get templates from node {node}: {e}")
|
||
|
||
return all_templates
|
||
except Exception as e:
|
||
logger.error(f"Error getting all LXC templates: {e}")
|
||
return []
|
||
|
||
def get_lxc_templates(self, node: str, storage: str = None) -> List[Dict]:
|
||
"""
|
||
Получить список доступных шаблонов LXC контейнеров
|
||
|
||
Args:
|
||
node: Имя ноды
|
||
storage: Хранилище (если None - ищем на всех с типом vztmpl)
|
||
|
||
Returns:
|
||
Список шаблонов с информацией
|
||
"""
|
||
if not self.proxmox:
|
||
return []
|
||
|
||
templates = []
|
||
try:
|
||
# Получаем список хранилищ с типом vztmpl
|
||
storages = self.proxmox.nodes(node).storage.get()
|
||
|
||
for stor in storages:
|
||
stor_name = stor.get('storage')
|
||
stor_content = stor.get('content', '')
|
||
|
||
# Проверяем, поддерживает ли хранилище vztmpl
|
||
if 'vztmpl' not in stor_content:
|
||
continue
|
||
|
||
if storage and stor_name != storage:
|
||
continue
|
||
|
||
try:
|
||
# Получаем содержимое хранилища
|
||
logger.info(f"[LXC Templates] Fetching templates from storage: {stor_name}")
|
||
content = self.proxmox.nodes(node).storage(stor_name).content.get(content='vztmpl')
|
||
logger.info(f"[LXC Templates] Found {len(content)} templates in {stor_name}")
|
||
for item in content:
|
||
item['storage'] = stor_name
|
||
item['node'] = node
|
||
templates.append(item)
|
||
except Exception as e:
|
||
logger.warning(f"Не удалось получить шаблоны из {stor_name}: {e}")
|
||
|
||
return templates
|
||
except Exception as e:
|
||
logger.error(f"Ошибка получения LXC шаблонов с ноды {node}: {e}")
|
||
return []
|
||
|
||
def download_lxc_template(self, node: str, storage: str, template: str) -> Optional[str]:
|
||
"""
|
||
Скачать шаблон LXC из репозитория
|
||
|
||
Args:
|
||
node: Имя ноды
|
||
storage: Хранилище для загрузки
|
||
template: Имя шаблона (например debian-12-standard_12.2-1_amd64.tar.zst)
|
||
|
||
Returns:
|
||
UPID задачи или None
|
||
"""
|
||
if not self.proxmox:
|
||
return None
|
||
|
||
try:
|
||
result = self.proxmox.nodes(node).aplinfo.post(
|
||
storage=storage,
|
||
template=template
|
||
)
|
||
logger.info(f"Запущена загрузка шаблона {template} на {node}:{storage}")
|
||
return result
|
||
except Exception as e:
|
||
logger.error(f"Ошибка загрузки шаблона {template}: {e}")
|
||
return None
|
||
|
||
def get_available_lxc_templates(self, node: str) -> List[Dict]:
|
||
"""
|
||
Получить список доступных для загрузки шаблонов LXC
|
||
|
||
Args:
|
||
node: Имя ноды
|
||
|
||
Returns:
|
||
Список доступных шаблонов из репозитория
|
||
"""
|
||
if not self.proxmox:
|
||
return []
|
||
|
||
try:
|
||
templates = self.proxmox.nodes(node).aplinfo.get()
|
||
return templates
|
||
except Exception as e:
|
||
logger.error(f"Ошибка получения списка доступных шаблонов: {e}")
|
||
return []
|
||
|
||
def create_lxc_container(
|
||
self,
|
||
node: str,
|
||
vmid: int,
|
||
ostemplate: str,
|
||
hostname: str,
|
||
password: str = None,
|
||
ssh_public_keys: str = None,
|
||
storage: str = 'local-lvm',
|
||
rootfs_size: int = 8,
|
||
memory: int = 512,
|
||
swap: int = 512,
|
||
cores: int = 1,
|
||
net0: str = None,
|
||
unprivileged: bool = True,
|
||
start_after_create: bool = False,
|
||
onboot: bool = False,
|
||
description: str = None,
|
||
features: str = None,
|
||
nameserver: str = None,
|
||
searchdomain: str = None,
|
||
) -> Optional[str]:
|
||
"""
|
||
Создать новый LXC контейнер
|
||
|
||
Args:
|
||
node: Имя ноды
|
||
vmid: ID контейнера
|
||
ostemplate: Путь к шаблону (storage:vztmpl/template.tar.gz)
|
||
hostname: Имя хоста контейнера
|
||
password: Пароль root (опционально, если есть ssh_public_keys)
|
||
ssh_public_keys: SSH публичные ключи
|
||
storage: Хранилище для rootfs
|
||
rootfs_size: Размер rootfs в GB
|
||
memory: Память в MB
|
||
swap: Swap в MB
|
||
cores: Количество ядер CPU
|
||
net0: Конфигурация сети (например: name=eth0,bridge=vmbr0,ip=dhcp)
|
||
unprivileged: Непривилегированный контейнер (рекомендуется)
|
||
start_after_create: Запустить после создания
|
||
onboot: Автозапуск при старте хоста
|
||
description: Описание контейнера
|
||
features: Дополнительные features (nesting, keyctl, etc)
|
||
|
||
Returns:
|
||
UPID задачи или None при ошибке
|
||
"""
|
||
if not self.proxmox:
|
||
return None
|
||
|
||
try:
|
||
params = {
|
||
'vmid': vmid,
|
||
'ostemplate': ostemplate,
|
||
'hostname': hostname,
|
||
'storage': storage,
|
||
'rootfs': f'{storage}:{rootfs_size}',
|
||
'memory': memory,
|
||
'swap': swap,
|
||
'cores': cores,
|
||
'unprivileged': 1 if unprivileged else 0,
|
||
'start': 1 if start_after_create else 0,
|
||
'onboot': 1 if onboot else 0,
|
||
}
|
||
|
||
if password:
|
||
params['password'] = password
|
||
|
||
if ssh_public_keys:
|
||
# proxmoxer URL-encodes params automatically; pass key as-is
|
||
params['ssh-public-keys'] = ssh_public_keys.strip()
|
||
|
||
if net0:
|
||
params['net0'] = net0
|
||
else:
|
||
# Конфигурация по умолчанию
|
||
params['net0'] = 'name=eth0,bridge=vmbr0,ip=dhcp'
|
||
|
||
if description:
|
||
params['description'] = description
|
||
|
||
# Enable nesting by default — required for systemd 255+ (Ubuntu 24.04, Debian 13)
|
||
# to avoid "WARN: Systemd 255 detected. You may need to enable nesting." warning
|
||
if features:
|
||
params['features'] = features
|
||
else:
|
||
params['features'] = 'nesting=1'
|
||
|
||
if nameserver:
|
||
params['nameserver'] = nameserver
|
||
|
||
if searchdomain:
|
||
params['searchdomain'] = searchdomain
|
||
|
||
# Ретрай на транзиентную гонку блокировки конфига (частый случай:
|
||
# тот же VMID пересоздаётся сразу после удаления, пока нода ещё
|
||
# держит pve-config-<vmid>.lock — напр. фоновая очистка диска).
|
||
import time as _time
|
||
attempts = 3
|
||
for attempt in range(1, attempts + 1):
|
||
try:
|
||
result = self.proxmox.nodes(node).lxc.post(**params)
|
||
logger.info(f"Запущено создание LXC контейнера {vmid} ({hostname}) на {node}")
|
||
return result
|
||
except Exception as e:
|
||
msg = str(e)
|
||
is_lock_race = "got lock" in msg or "can't lock file" in msg
|
||
if is_lock_race and attempt < attempts:
|
||
logger.warning(
|
||
f"Создание LXC {vmid}: блокировка конфига (попытка "
|
||
f"{attempt}/{attempts}), повтор через 5с: {msg}"
|
||
)
|
||
_time.sleep(5)
|
||
continue
|
||
raise
|
||
except Exception as e:
|
||
logger.error(f"Ошибка создания LXC контейнера {vmid}: {e}")
|
||
raise
|
||
|
||
def clone_lxc_container(
|
||
self,
|
||
node: str,
|
||
source_vmid: int,
|
||
new_vmid: int,
|
||
hostname: str,
|
||
full_clone: bool = True,
|
||
target_storage: str = None,
|
||
description: str = None
|
||
) -> Optional[str]:
|
||
"""
|
||
Клонировать LXC контейнер
|
||
|
||
Args:
|
||
node: Имя ноды
|
||
source_vmid: VMID исходного контейнера
|
||
new_vmid: VMID нового контейнера
|
||
hostname: Имя хоста нового контейнера
|
||
full_clone: Полный клон (True) или linked clone (False)
|
||
target_storage: Целевое хранилище
|
||
description: Описание
|
||
|
||
Returns:
|
||
UPID задачи или None
|
||
"""
|
||
if not self.proxmox:
|
||
return None
|
||
|
||
try:
|
||
params = {
|
||
'newid': new_vmid,
|
||
'hostname': hostname,
|
||
'full': 1 if full_clone else 0,
|
||
}
|
||
|
||
if target_storage:
|
||
params['storage'] = target_storage
|
||
|
||
if description:
|
||
params['description'] = description
|
||
|
||
result = self.proxmox.nodes(node).lxc(source_vmid).clone.post(**params)
|
||
logger.info(f"Клонирование LXC {source_vmid} -> {new_vmid} ({hostname}) на {node}")
|
||
return result
|
||
except Exception as e:
|
||
logger.error(f"Ошибка клонирования LXC {source_vmid} -> {new_vmid}: {e}")
|
||
return None
|
||
|
||
def migrate_vm(
|
||
self,
|
||
node: str,
|
||
vmid: int,
|
||
target_node: str,
|
||
target_storage: str = None,
|
||
online: bool = False,
|
||
with_local_disks: bool = True,
|
||
) -> Optional[str]:
|
||
"""
|
||
Мигрировать VM (qemu) на другую ноду.
|
||
|
||
Args:
|
||
node: Текущая нода VM
|
||
vmid: ID виртуальной машины
|
||
target_node: Целевая нода
|
||
target_storage: Целевое хранилище (опционально, storage migration)
|
||
online: Онлайн (live) миграция для запущенной VM
|
||
with_local_disks: Мигрировать локальные диски (обязательно для live
|
||
миграции VM с дисками на local storage)
|
||
|
||
Returns:
|
||
UPID задачи или None
|
||
"""
|
||
if not self.proxmox:
|
||
return None
|
||
|
||
try:
|
||
params = {'target': target_node}
|
||
if online:
|
||
params['online'] = 1
|
||
if with_local_disks:
|
||
params['with-local-disks'] = 1
|
||
if target_storage:
|
||
params['targetstorage'] = target_storage
|
||
|
||
result = self.proxmox.nodes(node).qemu(vmid).migrate.post(**params)
|
||
logger.info(f"Миграция VM {vmid} с {node} на {target_node}")
|
||
return result if isinstance(result, str) else None
|
||
except Exception as e:
|
||
logger.error(f"Ошибка миграции VM {vmid}: {e}")
|
||
raise RuntimeError(f"Proxmox: {e}") from e
|
||
|
||
def migrate_container(
|
||
self,
|
||
node: str,
|
||
vmid: int,
|
||
target_node: str,
|
||
target_storage: str = None,
|
||
online: bool = False
|
||
) -> Optional[str]:
|
||
"""
|
||
Мигрировать LXC контейнер на другую ноду
|
||
|
||
Args:
|
||
node: Текущая нода контейнера
|
||
vmid: ID контейнера
|
||
target_node: Целевая нода
|
||
target_storage: Целевое хранилище (опционально)
|
||
online: Онлайн миграция (для запущенных контейнеров)
|
||
|
||
Returns:
|
||
UPID задачи или None
|
||
"""
|
||
if not self.proxmox:
|
||
return None
|
||
|
||
try:
|
||
params = {
|
||
'target': target_node,
|
||
}
|
||
|
||
if target_storage:
|
||
params['target-storage'] = target_storage
|
||
|
||
if online:
|
||
params['online'] = 1
|
||
|
||
result = self.proxmox.nodes(node).lxc(vmid).migrate.post(**params)
|
||
logger.info(f"Миграция LXC {vmid} с {node} на {target_node}")
|
||
return result if isinstance(result, str) else None
|
||
except Exception as e:
|
||
logger.error(f"Ошибка миграции LXC {vmid}: {e}")
|
||
raise RuntimeError(f"Proxmox: {e}") from e
|
||
|
||
def remote_migrate_vm(
|
||
self,
|
||
node: str,
|
||
vmid: int,
|
||
target_endpoint: str,
|
||
target_vmid: int,
|
||
target_storage: str = None,
|
||
target_bridge: str = None,
|
||
online: bool = False,
|
||
delete_source: bool = True,
|
||
bwlimit: int = None,
|
||
) -> Optional[str]:
|
||
"""
|
||
Мигрировать VM (qemu) на другой (независимый) кластер через remote_migrate
|
||
API (PVE >= 8.0). В отличие от migrate_vm, авторизация на целевой стороне
|
||
идёт по API-токену из target_endpoint, а не по членству в corosync.
|
||
|
||
Args:
|
||
node: Текущая нода VM
|
||
vmid: ID виртуальной машины на источнике
|
||
target_endpoint: Строка `apitoken=...,host=...,fingerprint=...` целевого кластера
|
||
target_vmid: ID виртуальной машины на цели
|
||
target_storage: Целевое хранилище (применяется ко всем дискам)
|
||
target_bridge: Целевой сетевой мост (применяется ко всем интерфейсам)
|
||
online: Live-миграция запущенной VM
|
||
delete_source: Удалить VM с источника после успешного переноса
|
||
bwlimit: Ограничение полосы (KiB/s)
|
||
|
||
Returns:
|
||
UPID задачи (на источнике) или None
|
||
"""
|
||
if not self.proxmox:
|
||
return None
|
||
|
||
try:
|
||
params = {
|
||
'target-endpoint': target_endpoint,
|
||
'target-vmid': target_vmid,
|
||
'delete': 1 if delete_source else 0,
|
||
}
|
||
if target_storage:
|
||
params['target-storage'] = target_storage
|
||
if target_bridge:
|
||
params['target-bridge'] = target_bridge
|
||
if online:
|
||
params['online'] = 1
|
||
if bwlimit:
|
||
params['bwlimit'] = bwlimit
|
||
|
||
result = self.proxmox.nodes(node).qemu(vmid).remote_migrate.post(**params)
|
||
logger.info(f"Remote-миграция VM {vmid} с {node} на {target_vmid}@{target_endpoint.split(',')[1] if ',' in target_endpoint else target_endpoint}")
|
||
return result if isinstance(result, str) else None
|
||
except Exception as e:
|
||
logger.error(f"Ошибка remote-миграции VM {vmid}: {e}")
|
||
raise RuntimeError(f"Proxmox: {e}") from e
|
||
|
||
def remote_migrate_container(
|
||
self,
|
||
node: str,
|
||
vmid: int,
|
||
target_endpoint: str,
|
||
target_vmid: int,
|
||
target_storage: str = None,
|
||
target_bridge: str = None,
|
||
online: bool = False,
|
||
delete_source: bool = True,
|
||
bwlimit: int = None,
|
||
restart: bool = False,
|
||
) -> Optional[str]:
|
||
"""
|
||
Мигрировать LXC контейнер на другой (независимый) кластер через
|
||
remote_migrate API (PVE >= 8.0). См. remote_migrate_vm.
|
||
|
||
Args:
|
||
restart: Перезапустить контейнер на источнике перед переносом
|
||
(аналог pct remote-migrate --restart, нужен, если online=False,
|
||
но контейнер запущен)
|
||
"""
|
||
if not self.proxmox:
|
||
return None
|
||
|
||
try:
|
||
params = {
|
||
'target-endpoint': target_endpoint,
|
||
'target-vmid': target_vmid,
|
||
'delete': 1 if delete_source else 0,
|
||
}
|
||
if target_storage:
|
||
params['target-storage'] = target_storage
|
||
if target_bridge:
|
||
params['target-bridge'] = target_bridge
|
||
if online:
|
||
params['online'] = 1
|
||
if restart:
|
||
params['restart'] = 1
|
||
if bwlimit:
|
||
params['bwlimit'] = bwlimit
|
||
|
||
result = self.proxmox.nodes(node).lxc(vmid).remote_migrate.post(**params)
|
||
logger.info(f"Remote-миграция LXC {vmid} с {node} на {target_vmid}")
|
||
return result if isinstance(result, str) else None
|
||
except Exception as e:
|
||
logger.error(f"Ошибка remote-миграции LXC {vmid}: {e}")
|
||
raise RuntimeError(f"Proxmox: {e}") from e
|
||
|
||
def get_vm_termproxy(self, node: str, vmid: int) -> Dict:
|
||
"""
|
||
Получить данные для терминального подключения к VM через xterm.js
|
||
|
||
Args:
|
||
node: Имя ноды
|
||
vmid: ID виртуальной машины
|
||
|
||
Returns:
|
||
Dict с данными терминала (port, ticket, user)
|
||
"""
|
||
if not self.proxmox:
|
||
return {}
|
||
|
||
try:
|
||
term_data = self.proxmox.nodes(node).qemu(vmid).termproxy.post()
|
||
return term_data
|
||
except Exception as e:
|
||
logger.error(f"Ошибка получения termproxy для VM {vmid} на {node}: {e}")
|
||
return {}
|
||
|
||
def get_container_termproxy(self, node: str, vmid: int) -> Dict:
|
||
"""
|
||
Получить данные для терминального подключения к LXC контейнеру через xterm.js
|
||
|
||
Args:
|
||
node: Имя ноды
|
||
vmid: ID контейнера
|
||
|
||
Returns:
|
||
Dict с данными терминала (port, ticket, user)
|
||
"""
|
||
if not self.proxmox:
|
||
return {}
|
||
|
||
try:
|
||
term_data = self.proxmox.nodes(node).lxc(vmid).termproxy.post()
|
||
return term_data
|
||
except Exception as e:
|
||
logger.error(f"Ошибка получения termproxy для LXC {vmid} на {node}: {e}")
|
||
return {}
|
||
|
||
def exec_in_container(self, node: str, vmid: int, command: list) -> Dict:
|
||
"""
|
||
Выполнить команду в LXC контейнере через pct exec
|
||
|
||
Args:
|
||
node: Имя ноды
|
||
vmid: ID контейнера
|
||
command: Список [команда, arg1, arg2, ...]
|
||
|
||
Returns:
|
||
Dict с upid задачи выполнения
|
||
"""
|
||
if not self.proxmox:
|
||
return {}
|
||
|
||
try:
|
||
# Proxmox ожидает command и args отдельно
|
||
cmd = command[0] if command else "/bin/bash"
|
||
args = command[1:] if len(command) > 1 else []
|
||
|
||
# Формируем data для POST запроса
|
||
data = {"command": cmd}
|
||
for idx, arg in enumerate(args):
|
||
data[f"args[{idx}]"] = arg
|
||
|
||
result = self.proxmox.nodes(node).lxc(vmid).exec.post(**data)
|
||
return result
|
||
except Exception as e:
|
||
logger.error(f"Ошибка exec в LXC {vmid} на {node}: {e}")
|
||
return {}
|
||
|
||
def get_task_log(self, node: str, upid: str, start: int = 0, limit: int = 500) -> List[Dict]:
|
||
"""Get task log lines"""
|
||
if not self.proxmox:
|
||
return []
|
||
try:
|
||
log = self.proxmox.nodes(node).tasks(upid).log.get(start=start, limit=limit)
|
||
return log
|
||
except Exception as e:
|
||
logger.error(f"Error getting task log {upid}: {e}")
|
||
return []
|
||
|
||
def sdn_is_available(self) -> bool:
|
||
"""
|
||
Check if SDN is available on this Proxmox cluster.
|
||
SDN requires Proxmox VE 7.0+ and proper configuration.
|
||
"""
|
||
if not self.proxmox:
|
||
return False
|
||
|
||
try:
|
||
# Try to access SDN API
|
||
self.proxmox.cluster.sdn.get()
|
||
return True
|
||
except Exception as e:
|
||
logger.debug(f"SDN not available: {e}")
|
||
return False
|
||
|
||
def get_sdn_zone(self, zone: str) -> Optional[Dict]:
|
||
"""
|
||
Get details of a specific SDN zone.
|
||
|
||
Args:
|
||
zone: Zone name/ID
|
||
|
||
Returns:
|
||
Zone configuration dict or None
|
||
"""
|
||
if not self.proxmox:
|
||
return None
|
||
|
||
try:
|
||
zone_data = self.proxmox.cluster.sdn.zones(zone).get()
|
||
return zone_data
|
||
except Exception as e:
|
||
logger.error(f"Error getting SDN zone {zone}: {e}")
|
||
return None
|
||
|
||
def delete_sdn_zone(self, zone: str) -> Dict:
|
||
"""
|
||
Delete an SDN zone.
|
||
|
||
Args:
|
||
zone: Zone name to delete
|
||
|
||
Returns:
|
||
Result dict with success status
|
||
"""
|
||
if not self.proxmox:
|
||
return {"success": False, "error": "Not connected"}
|
||
|
||
try:
|
||
self.proxmox.cluster.sdn.zones(zone).delete()
|
||
logger.info(f"Deleted SDN zone: {zone}")
|
||
return {"success": True}
|
||
except Exception as e:
|
||
logger.error(f"Error deleting SDN zone {zone}: {e}")
|
||
return {"success": False, "error": str(e)}
|
||
|
||
def get_sdn_vnet(self, vnet: str) -> Optional[Dict]:
|
||
"""
|
||
Get details of a specific VNet.
|
||
|
||
Args:
|
||
vnet: VNet name
|
||
|
||
Returns:
|
||
VNet configuration dict or None
|
||
"""
|
||
if not self.proxmox:
|
||
return None
|
||
|
||
try:
|
||
vnet_data = self.proxmox.cluster.sdn.vnets(vnet).get()
|
||
return vnet_data
|
||
except Exception as e:
|
||
logger.error(f"Error getting SDN vnet {vnet}: {e}")
|
||
return None
|
||
|
||
def delete_sdn_vnet(self, vnet: str) -> Dict:
|
||
"""
|
||
Delete an SDN VNet.
|
||
|
||
Args:
|
||
vnet: VNet name to delete
|
||
|
||
Returns:
|
||
Result dict with success status
|
||
"""
|
||
if not self.proxmox:
|
||
return {"success": False, "error": "Not connected"}
|
||
|
||
try:
|
||
self.proxmox.cluster.sdn.vnets(vnet).delete()
|
||
logger.info(f"Deleted SDN vnet: {vnet}")
|
||
return {"success": True}
|
||
except Exception as e:
|
||
logger.error(f"Error deleting SDN vnet {vnet}: {e}")
|
||
return {"success": False, "error": str(e)}
|
||
|
||
def get_sdn_subnets(self, vnet: str) -> List[Dict]:
|
||
"""
|
||
Get subnets for a specific VNet.
|
||
|
||
Args:
|
||
vnet: VNet name
|
||
|
||
Returns:
|
||
List of subnets in the VNet
|
||
"""
|
||
if not self.proxmox:
|
||
return []
|
||
|
||
try:
|
||
subnets = self.proxmox.cluster.sdn.vnets(vnet).subnets.get()
|
||
return subnets if isinstance(subnets, list) else []
|
||
except Exception as e:
|
||
logger.error(f"Error getting subnets for vnet {vnet}: {e}")
|
||
return []
|
||
|
||
def delete_sdn_subnet(self, vnet: str, subnet: str) -> Dict:
|
||
"""
|
||
Delete a subnet from a VNet.
|
||
|
||
Args:
|
||
vnet: VNet name
|
||
subnet: Subnet to delete (CIDR format, URL-encoded)
|
||
|
||
Returns:
|
||
Result dict with success status
|
||
"""
|
||
if not self.proxmox:
|
||
return {"success": False, "error": "Not connected"}
|
||
|
||
try:
|
||
# Subnet ID in URL is the CIDR with / replaced by -
|
||
subnet_id = subnet.replace("/", "-")
|
||
self.proxmox.cluster.sdn.vnets(vnet).subnets(subnet_id).delete()
|
||
logger.info(f"Deleted subnet {subnet} from vnet {vnet}")
|
||
return {"success": True}
|
||
except Exception as e:
|
||
logger.error(f"Error deleting subnet {subnet} from vnet {vnet}: {e}")
|
||
return {"success": False, "error": str(e)}
|
||
|
||
def apply_sdn_changes(self) -> Dict:
|
||
"""
|
||
Apply pending SDN configuration changes to all nodes.
|
||
This is required after creating/modifying zones, vnets, or subnets.
|
||
|
||
Returns:
|
||
Result dict with success status
|
||
"""
|
||
if not self.proxmox:
|
||
return {"success": False, "error": "Not connected"}
|
||
|
||
try:
|
||
result = self.proxmox.cluster.sdn.put()
|
||
logger.info("Applied SDN changes to cluster")
|
||
return {"success": True, "upid": result}
|
||
except Exception as e:
|
||
logger.error(f"Error applying SDN changes: {e}")
|
||
return {"success": False, "error": str(e)}
|
||
|
||
def get_sdn_pending(self) -> List[Dict]:
|
||
"""
|
||
Get pending SDN changes.
|
||
|
||
Returns:
|
||
List of pending changes
|
||
"""
|
||
if not self.proxmox:
|
||
return []
|
||
|
||
try:
|
||
# Pending status is typically in the zone/vnet responses
|
||
zones = self.get_sdn_zones()
|
||
vnets = self.get_sdn_vnets()
|
||
|
||
pending = []
|
||
for zone in zones:
|
||
if zone.get('pending'):
|
||
pending.append({"type": "zone", "id": zone.get('zone'), "pending": zone.get('pending')})
|
||
for vnet in vnets:
|
||
if vnet.get('pending'):
|
||
pending.append({"type": "vnet", "id": vnet.get('vnet'), "pending": vnet.get('pending')})
|
||
|
||
return pending
|
||
except Exception as e:
|
||
logger.error(f"Error getting SDN pending changes: {e}")
|
||
return []
|
||
|
||
def update_sdn_zone(self, zone: str, **kwargs) -> Dict:
|
||
"""
|
||
Update an existing SDN zone.
|
||
|
||
Args:
|
||
zone: Zone name to update
|
||
**kwargs: Fields to update (mtu, dns, reversedns, ipam, etc.)
|
||
|
||
Returns:
|
||
Result dict with success status
|
||
"""
|
||
if not self.proxmox:
|
||
return {"success": False, "error": "Not connected"}
|
||
|
||
try:
|
||
self.proxmox.cluster.sdn.zones(zone).put(**kwargs)
|
||
logger.info(f"Updated SDN zone: {zone}")
|
||
return {"success": True, "zone": zone}
|
||
except Exception as e:
|
||
logger.error(f"Error updating SDN zone {zone}: {e}")
|
||
return {"success": False, "error": str(e)}
|
||
|
||
def update_sdn_vnet(self, vnet: str, **kwargs) -> Dict:
|
||
"""
|
||
Update an existing SDN VNet.
|
||
|
||
Args:
|
||
vnet: VNet name to update
|
||
**kwargs: Fields to update (alias, tag, vlanaware, etc.)
|
||
|
||
Returns:
|
||
Result dict with success status
|
||
"""
|
||
if not self.proxmox:
|
||
return {"success": False, "error": "Not connected"}
|
||
|
||
try:
|
||
self.proxmox.cluster.sdn.vnets(vnet).put(**kwargs)
|
||
logger.info(f"Updated SDN vnet: {vnet}")
|
||
return {"success": True, "vnet": vnet}
|
||
except Exception as e:
|
||
logger.error(f"Error updating SDN vnet {vnet}: {e}")
|
||
return {"success": False, "error": str(e)}
|
||
|
||
def get_node_networks(self, node: str) -> List[Dict]:
|
||
"""
|
||
Get all network interfaces configured on a node.
|
||
|
||
Args:
|
||
node: Node name
|
||
|
||
Returns:
|
||
List of network interface objects
|
||
"""
|
||
if not self.proxmox:
|
||
return []
|
||
|
||
try:
|
||
ifaces = self.proxmox.nodes(node).network.get()
|
||
return ifaces if isinstance(ifaces, list) else []
|
||
except Exception as e:
|
||
logger.error(f"Error getting node {node} networks: {e}")
|
||
return []
|
||
|
||
def get_node_network(self, node: str, iface: str) -> Optional[Dict]:
|
||
"""
|
||
Get details of a specific network interface on a node.
|
||
|
||
Args:
|
||
node: Node name
|
||
iface: Interface name (e.g., vmbr0, bond0)
|
||
|
||
Returns:
|
||
Interface configuration dict or None
|
||
"""
|
||
if not self.proxmox:
|
||
return None
|
||
|
||
try:
|
||
return self.proxmox.nodes(node).network(iface).get()
|
||
except Exception as e:
|
||
logger.error(f"Error getting node {node} interface {iface}: {e}")
|
||
return None
|
||
|
||
def create_node_network(self, node: str, iface_type: str, **kwargs) -> Dict:
|
||
"""
|
||
Create a new network interface on a node.
|
||
|
||
Args:
|
||
node: Node name
|
||
iface_type: Type of interface ('bridge', 'bond', 'vlan', 'eth')
|
||
**kwargs: Interface parameters (iface, address, netmask, gateway,
|
||
bridge_ports, bond_slaves, bond_mode, vlan-id,
|
||
vlan-raw-device, autostart, comments, etc.)
|
||
|
||
Returns:
|
||
Result dict with success status
|
||
"""
|
||
if not self.proxmox:
|
||
return {"success": False, "error": "Not connected"}
|
||
|
||
try:
|
||
params = {"type": iface_type}
|
||
params.update(kwargs)
|
||
self.proxmox.nodes(node).network.post(**params)
|
||
iface_name = kwargs.get("iface", "")
|
||
logger.info(f"Created {iface_type} interface {iface_name} on node {node}")
|
||
return {"success": True, "iface": iface_name}
|
||
except Exception as e:
|
||
logger.error(f"Error creating {iface_type} interface on node {node}: {e}")
|
||
return {"success": False, "error": str(e)}
|
||
|
||
def update_node_network(self, node: str, iface: str, **kwargs) -> Dict:
|
||
"""
|
||
Update a network interface on a node.
|
||
|
||
Args:
|
||
node: Node name
|
||
iface: Interface name to update
|
||
**kwargs: Fields to update
|
||
|
||
Returns:
|
||
Result dict with success status
|
||
"""
|
||
if not self.proxmox:
|
||
return {"success": False, "error": "Not connected"}
|
||
|
||
try:
|
||
self.proxmox.nodes(node).network(iface).put(**kwargs)
|
||
logger.info(f"Updated interface {iface} on node {node}")
|
||
return {"success": True, "iface": iface}
|
||
except Exception as e:
|
||
logger.error(f"Error updating interface {iface} on node {node}: {e}")
|
||
return {"success": False, "error": str(e)}
|
||
|
||
def delete_node_network(self, node: str, iface: str) -> Dict:
|
||
"""
|
||
Delete a network interface from a node.
|
||
|
||
Args:
|
||
node: Node name
|
||
iface: Interface name to delete
|
||
|
||
Returns:
|
||
Result dict with success status
|
||
"""
|
||
if not self.proxmox:
|
||
return {"success": False, "error": "Not connected"}
|
||
|
||
try:
|
||
self.proxmox.nodes(node).network(iface).delete()
|
||
logger.info(f"Deleted interface {iface} from node {node}")
|
||
return {"success": True}
|
||
except Exception as e:
|
||
logger.error(f"Error deleting interface {iface} from node {node}: {e}")
|
||
return {"success": False, "error": str(e)}
|
||
|
||
def apply_node_network_config(self, node: str) -> Dict:
|
||
"""
|
||
Apply pending network configuration changes on a node.
|
||
|
||
Args:
|
||
node: Node name
|
||
|
||
Returns:
|
||
Result dict with success status
|
||
"""
|
||
if not self.proxmox:
|
||
return {"success": False, "error": "Not connected"}
|
||
|
||
try:
|
||
result = self.proxmox.nodes(node).network.put()
|
||
logger.info(f"Applied network config on node {node}")
|
||
return {"success": True, "result": result}
|
||
except Exception as e:
|
||
logger.error(f"Error applying network config on node {node}: {e}")
|
||
return {"success": False, "error": str(e)}
|
||
|
||
def revert_node_network_config(self, node: str) -> Dict:
|
||
"""
|
||
Revert pending network configuration changes on a node (restore from running config).
|
||
|
||
Args:
|
||
node: Node name
|
||
|
||
Returns:
|
||
Result dict with success status
|
||
"""
|
||
if not self.proxmox:
|
||
return {"success": False, "error": "Not connected"}
|
||
|
||
try:
|
||
self.proxmox.nodes(node).network.delete()
|
||
logger.info(f"Reverted network config on node {node}")
|
||
return {"success": True}
|
||
except Exception as e:
|
||
logger.error(f"Error reverting network config on node {node}: {e}")
|
||
return {"success": False, "error": str(e)}
|
||
|
||
def get_snapshot_config(self, node: str, vmid: int, snapname: str, vm_type: str = 'qemu') -> Optional[Dict]:
|
||
"""
|
||
Get configuration of a snapshot.
|
||
|
||
Args:
|
||
node: Node name
|
||
vmid: VM/Container ID
|
||
snapname: Snapshot name
|
||
vm_type: 'qemu' for VMs, 'lxc' for containers
|
||
|
||
Returns:
|
||
Snapshot configuration dict or None
|
||
"""
|
||
if not self.proxmox:
|
||
return None
|
||
|
||
try:
|
||
if vm_type == 'qemu':
|
||
config = self.proxmox.nodes(node).qemu(vmid).snapshot(snapname).config.get()
|
||
else:
|
||
config = self.proxmox.nodes(node).lxc(vmid).snapshot(snapname).config.get()
|
||
return config
|
||
except Exception as e:
|
||
logger.error(f"Error getting snapshot {snapname} config: {e}")
|
||
return None
|
||
|
||
def get_cluster_storages(self) -> List[Dict]:
|
||
"""Get all storages configured on this Proxmox cluster"""
|
||
if not self.proxmox:
|
||
return []
|
||
try:
|
||
storages = self.proxmox.storage.get()
|
||
return storages
|
||
except Exception as e:
|
||
logger.error(f"Error getting storages from {self.host}: {e}")
|
||
return []
|
||
|
||
def create_storage(self, storage_id: str, storage_type: str, **kwargs) -> Dict:
|
||
"""Create a new storage configuration"""
|
||
if not self.proxmox:
|
||
return {"success": False, "error": "Not connected"}
|
||
try:
|
||
self.proxmox.storage.post(storage=storage_id, type=storage_type, **kwargs)
|
||
return {"success": True}
|
||
except Exception as e:
|
||
logger.error(f"Error creating storage {storage_id}: {e}")
|
||
return {"success": False, "error": str(e)}
|
||
|
||
def update_storage(self, storage_id: str, **kwargs) -> Dict:
|
||
"""Update storage configuration"""
|
||
if not self.proxmox:
|
||
return {"success": False, "error": "Not connected"}
|
||
try:
|
||
self.proxmox.storage(storage_id).put(**kwargs)
|
||
return {"success": True}
|
||
except Exception as e:
|
||
logger.error(f"Error updating storage {storage_id}: {e}")
|
||
return {"success": False, "error": str(e)}
|
||
|
||
def delete_storage(self, storage_id: str) -> Dict:
|
||
"""Delete storage configuration"""
|
||
if not self.proxmox:
|
||
return {"success": False, "error": "Not connected"}
|
||
try:
|
||
self.proxmox.storage(storage_id).delete()
|
||
return {"success": True}
|
||
except Exception as e:
|
||
logger.error(f"Error deleting storage {storage_id}: {e}")
|
||
return {"success": False, "error": str(e)}
|
||
|
||
def list_backups(self, node: str, storage: str, vmid: int = None) -> List[Dict]:
|
||
"""List backup files in a storage on a node"""
|
||
if not self.proxmox:
|
||
return []
|
||
try:
|
||
params = {"content": "backup"}
|
||
if vmid:
|
||
params["vmid"] = vmid
|
||
items = self.proxmox.nodes(node).storage(storage).content.get(**params)
|
||
return items
|
||
except Exception as e:
|
||
logger.error(f"Error listing backups on {node}/{storage}: {e}")
|
||
return []
|
||
|
||
def create_backup(self, node: str, vmid: int, storage: str,
|
||
mode: str = "snapshot", compress: str = "zstd",
|
||
remove: int = 1, keep_last: int = None, notes: str = None) -> Dict:
|
||
"""
|
||
Trigger vzdump backup for a VM/container.
|
||
Returns {"success": True, "upid": "..."} or {"success": False, "error": "..."}
|
||
"""
|
||
if not self.proxmox:
|
||
return {"success": False, "error": "Not connected"}
|
||
try:
|
||
params = {
|
||
"storage": storage,
|
||
"mode": mode,
|
||
"compress": compress,
|
||
"remove": int(bool(remove)),
|
||
}
|
||
# «Все VM»: vzdump требует all=1, а не vmid (vmid=0 — ошибка PVE)
|
||
if vmid in (None, 0, "all"):
|
||
params["all"] = 1
|
||
else:
|
||
params["vmid"] = vmid
|
||
if keep_last and int(keep_last) > 0:
|
||
params["prune-backups"] = f"keep-last={int(keep_last)}"
|
||
if notes:
|
||
params["notes-template"] = notes
|
||
upid = self.proxmox.nodes(node).vzdump.post(**params)
|
||
return {"success": True, "upid": upid}
|
||
except Exception as e:
|
||
logger.error(f"Error creating backup for VM {vmid} on {node}: {e}")
|
||
return {"success": False, "error": str(e)}
|
||
|
||
def restore_vm(self, node: str, vmid: int, archive: str, storage: str = None,
|
||
new_vmid: int = None, start: bool = False, unique: bool = True,
|
||
force: bool = False) -> Dict:
|
||
"""
|
||
Restore a QEMU VM from backup.
|
||
archive: volume id like 'local:backup/vzdump-qemu-100-...'
|
||
storage: TARGET storage for the restored disks. When empty/None, Proxmox
|
||
restores each disk to the storage recorded in the backup config
|
||
("from backup configuration"). The backup source is encoded in
|
||
`archive`, so this must NOT be the backup storage.
|
||
force: overwrite an existing VM with the same vmid (must be stopped).
|
||
"""
|
||
if not self.proxmox:
|
||
return {"success": False, "error": "Not connected"}
|
||
try:
|
||
# For QEMU, restore is triggered by passing `archive` to POST /nodes/{node}/qemu.
|
||
# There is NO `restore` parameter (unlike LXC) — sending it returns a 400.
|
||
params = {
|
||
"vmid": new_vmid if new_vmid else vmid,
|
||
"archive": archive,
|
||
"start": 1 if start else 0,
|
||
"unique": 1 if unique else 0,
|
||
}
|
||
if storage:
|
||
params["storage"] = storage
|
||
if force:
|
||
params["force"] = 1
|
||
upid = self.proxmox.nodes(node).qemu.post(**params)
|
||
return {"success": True, "upid": upid}
|
||
except Exception as e:
|
||
logger.error(f"Error restoring VM {vmid} on {node}: {e}")
|
||
return {"success": False, "error": str(e)}
|
||
|
||
def restore_lxc(self, node: str, vmid: int, archive: str, storage: str = None,
|
||
new_vmid: int = None, start: bool = False,
|
||
force: bool = False) -> Dict:
|
||
"""Restore an LXC container from backup.
|
||
|
||
storage: TARGET storage for the restored rootfs. When empty/None, Proxmox
|
||
restores to the storage recorded in the backup config. The backup
|
||
source is encoded in `archive`, so this must NOT be the backup storage.
|
||
force: overwrite an existing container with the same vmid (must be stopped).
|
||
"""
|
||
if not self.proxmox:
|
||
return {"success": False, "error": "Not connected"}
|
||
try:
|
||
params = {
|
||
"vmid": new_vmid if new_vmid else vmid,
|
||
"ostemplate": archive,
|
||
"restore": 1,
|
||
"start": 1 if start else 0,
|
||
}
|
||
if storage:
|
||
params["storage"] = storage
|
||
if force:
|
||
params["force"] = 1
|
||
upid = self.proxmox.nodes(node).lxc.post(**params)
|
||
return {"success": True, "upid": upid}
|
||
except Exception as e:
|
||
logger.error(f"Error restoring LXC {vmid} on {node}: {e}")
|
||
return {"success": False, "error": str(e)}
|
||
|
||
def delete_backup(self, node: str, storage: str, volid: str) -> Dict:
|
||
"""Delete a specific backup file by its volume ID"""
|
||
if not self.proxmox:
|
||
return {"success": False, "error": "Not connected"}
|
||
try:
|
||
self.proxmox.nodes(node).storage(storage).content(volid).delete()
|
||
return {"success": True}
|
||
except Exception as e:
|
||
logger.error(f"Error deleting backup {volid}: {e}")
|
||
return {"success": False, "error": str(e)}
|
||
|
||
def get_cluster_backup_jobs(self) -> List[Dict]:
|
||
"""Fetch native vzdump backup jobs configured in Proxmox (cluster/backup)"""
|
||
if not self.proxmox:
|
||
return []
|
||
try:
|
||
jobs = self.proxmox.cluster.backup.get()
|
||
return jobs if isinstance(jobs, list) else []
|
||
except Exception as e:
|
||
logger.error(f"Error fetching cluster backup jobs: {e}")
|
||
return []
|
||
|
||
def create_cluster_backup_job(self, props: Dict) -> Dict:
|
||
"""Создать нативное задание бэкапа Proxmox (POST /cluster/backup).
|
||
|
||
props — уже подготовленные параметры vzdump (schedule, storage, mode,
|
||
compress, enabled, all/vmid/pool/node, prune-backups, comment, ...).
|
||
"""
|
||
if not self.proxmox:
|
||
return {"success": False, "error": "Not connected"}
|
||
try:
|
||
self.proxmox.cluster.backup.post(**props)
|
||
logger.info(f"Создано нативное задание бэкапа: {props.get('id') or '(auto id)'}")
|
||
return {"success": True}
|
||
except Exception as e:
|
||
logger.error(f"Ошибка создания задания бэкапа: {e}")
|
||
return {"success": False, "error": str(e)}
|
||
|
||
def update_cluster_backup_job(self, job_id: str, props: Dict) -> Dict:
|
||
"""Обновить нативное задание бэкапа Proxmox (PUT /cluster/backup/{id})."""
|
||
if not self.proxmox:
|
||
return {"success": False, "error": "Not connected"}
|
||
try:
|
||
self.proxmox.cluster.backup(job_id).put(**props)
|
||
logger.info(f"Обновлено нативное задание бэкапа: {job_id}")
|
||
return {"success": True}
|
||
except Exception as e:
|
||
logger.error(f"Ошибка обновления задания бэкапа {job_id}: {e}")
|
||
return {"success": False, "error": str(e)}
|
||
|
||
def delete_cluster_backup_job(self, job_id: str) -> Dict:
|
||
"""Удалить нативное задание бэкапа Proxmox (DELETE /cluster/backup/{id})."""
|
||
if not self.proxmox:
|
||
return {"success": False, "error": "Not connected"}
|
||
try:
|
||
self.proxmox.cluster.backup(job_id).delete()
|
||
logger.info(f"Удалено нативное задание бэкапа: {job_id}")
|
||
return {"success": True}
|
||
except Exception as e:
|
||
logger.error(f"Ошибка удаления задания бэкапа {job_id}: {e}")
|
||
return {"success": False, "error": str(e)}
|
||
|