Add a "scan exports" button next to the server field for NFS storages
that calls Proxmox's /nodes/{node}/scan/nfs and turns the export path
input into a select populated with the real exports found on the
target server, instead of requiring the path to be typed by hand.
355 lines
18 KiB
Python
355 lines
18 KiB
Python
from typing import List, Dict, Optional, Union, Any, BinaryIO
|
||
import time
|
||
import urllib3
|
||
from loguru import logger
|
||
|
||
class StorageMixin:
|
||
def get_node_isos(self, node: str) -> List[Dict]:
|
||
"""Получить список ISO-образов со всех ISO-хранилищ ноды."""
|
||
if not self.proxmox:
|
||
return []
|
||
result: List[Dict] = []
|
||
try:
|
||
storages = self.proxmox.nodes(node).storage.get()
|
||
for s in storages:
|
||
content = s.get('content', '')
|
||
if 'iso' not in content:
|
||
continue
|
||
storage_id = s.get('storage')
|
||
try:
|
||
items = self.proxmox.nodes(node).storage(storage_id).content.get(content='iso')
|
||
for it in items:
|
||
result.append({
|
||
'volid': it.get('volid'),
|
||
'storage': storage_id,
|
||
'size': it.get('size'),
|
||
'format': it.get('format'),
|
||
'name': (it.get('volid') or '').split('/')[-1],
|
||
})
|
||
except Exception as e:
|
||
logger.debug(f"Не удалось получить ISO из {storage_id}: {e}")
|
||
except Exception as e:
|
||
logger.error(f"Ошибка получения списка ISO для ноды {node}: {e}")
|
||
return result
|
||
|
||
def get_lxc_storage_templates(self, node: Optional[str] = None) -> List[Dict]:
|
||
"""
|
||
Получить список LXC CT-шаблонов из хранилищ Proxmox.
|
||
Ищет файлы типа vzdump/vztmpl на всех нодах (или на указанной).
|
||
Возвращает список объектов с полями: volid, storage, node, name, size.
|
||
"""
|
||
if not self.proxmox:
|
||
return []
|
||
result: List[Dict] = []
|
||
try:
|
||
if node:
|
||
nodes_list = [{'node': node}]
|
||
else:
|
||
nodes_list = self.get_nodes()
|
||
|
||
seen_volids: set = set()
|
||
for n in nodes_list:
|
||
node_name = n.get('node')
|
||
try:
|
||
storages = self.proxmox.nodes(node_name).storage.get()
|
||
for s in storages:
|
||
content = s.get('content', '')
|
||
if 'vztmpl' not in content:
|
||
continue
|
||
storage_id = s.get('storage')
|
||
try:
|
||
items = self.proxmox.nodes(node_name).storage(storage_id).content.get(content='vztmpl')
|
||
for it in items:
|
||
volid = it.get('volid', '')
|
||
if volid in seen_volids:
|
||
continue
|
||
seen_volids.add(volid)
|
||
filename = volid.split('/')[-1] if '/' in volid else volid
|
||
result.append({
|
||
'volid': volid,
|
||
'storage': storage_id,
|
||
'node': node_name,
|
||
'name': filename,
|
||
'size': it.get('size', 0),
|
||
})
|
||
except Exception as e:
|
||
logger.debug(f"Не удалось получить CT-шаблоны из {storage_id} на {node_name}: {e}")
|
||
except Exception as e:
|
||
logger.error(f"Ошибка получения хранилищ ноды {node_name}: {e}")
|
||
except Exception as e:
|
||
logger.error(f"Ошибка получения CT-шаблонов {self.host}: {e}")
|
||
return result
|
||
|
||
def download_url(self, node: str, storage: str, url: str, content: str,
|
||
filename: str, checksum: Optional[str] = None,
|
||
checksum_algorithm: Optional[str] = None) -> Optional[str]:
|
||
"""
|
||
Скачать файл (ISO / vztmpl / образ для импорта) по URL прямо на ноду
|
||
средствами Proxmox (aria2). Использует endpoint
|
||
POST /nodes/{node}/storage/{storage}/download-url.
|
||
|
||
Args:
|
||
node: имя ноды
|
||
storage: целевое хранилище
|
||
url: прямой URL к образу (.img/.qcow2/.iso/.tar.zst ...)
|
||
content: тип контента ('iso', 'vztmpl', 'import')
|
||
filename: имя файла в хранилище
|
||
checksum: контрольная сумма (опционально)
|
||
checksum_algorithm: алгоритм (md5/sha1/sha224/sha256/sha384/sha512)
|
||
|
||
Returns:
|
||
UPID задачи или None
|
||
"""
|
||
if not self.proxmox:
|
||
return None
|
||
try:
|
||
params = {
|
||
'url': url,
|
||
'content': content,
|
||
'filename': filename,
|
||
}
|
||
if checksum and checksum_algorithm:
|
||
params['checksum'] = checksum
|
||
params['checksum-algorithm'] = checksum_algorithm
|
||
# 'download-url' содержит дефис — обращаемся через call-форму proxmoxer
|
||
result = self.proxmox.nodes(node).storage(storage)('download-url').post(**params)
|
||
logger.info(f"Запущена загрузка {filename} ({content}) на {node}:{storage}, UPID: {result}")
|
||
return result
|
||
except Exception as e:
|
||
logger.error(f"Ошибка загрузки {url} на {node}:{storage}: {e}")
|
||
raise
|
||
|
||
def upload_file(self, node: str, storage: str, content: str, filename: str,
|
||
file_obj: BinaryIO, checksum: Optional[str] = None,
|
||
checksum_algorithm: Optional[str] = None) -> Optional[str]:
|
||
"""
|
||
Загрузить локальный файл (ISO / vztmpl) прямо в хранилище ноды через
|
||
POST /nodes/{node}/storage/{storage}/upload (multipart).
|
||
|
||
proxmoxer сам детектирует io.IOBase-значения в data и переносит их в
|
||
files, используя requests_toolbelt для потоковой отправки больших
|
||
файлов (иначе есть риск упереться в 2 GiB лимит SSL-буфера). Имя файла
|
||
в хранилище Proxmox берёт из file_obj.name — поэтому file_obj должен
|
||
быть открыт из пути, чей basename уже равен нужному filename.
|
||
|
||
Args:
|
||
node: имя ноды
|
||
storage: целевое хранилище
|
||
content: тип контента ('iso' или 'vztmpl')
|
||
filename: имя файла в хранилище (для логов; фактическое имя Proxmox
|
||
берёт из file_obj.name)
|
||
file_obj: открытый бинарный файл (basename == filename)
|
||
checksum: контрольная сумма (опционально)
|
||
checksum_algorithm: алгоритм (md5/sha1/sha224/sha256/sha384/sha512)
|
||
|
||
Returns:
|
||
UPID задачи или None
|
||
"""
|
||
if not self.proxmox:
|
||
return None
|
||
try:
|
||
params = {'content': content, 'filename': file_obj}
|
||
if checksum and checksum_algorithm:
|
||
params['checksum'] = checksum
|
||
params['checksum-algorithm'] = checksum_algorithm
|
||
result = self.proxmox.nodes(node).storage(storage).upload.post(**params)
|
||
logger.info(f"Загружен файл {filename} ({content}) на {node}:{storage}, UPID: {result}")
|
||
return result
|
||
except Exception as e:
|
||
logger.error(f"Ошибка загрузки файла {filename} на {node}:{storage}: {e}")
|
||
raise
|
||
|
||
def volume_exists(self, node: str, storage: str, volid: str) -> bool:
|
||
"""Проверить, есть ли уже том volid в хранилище (для идемпотентной загрузки)."""
|
||
if not self.proxmox:
|
||
return False
|
||
try:
|
||
items = self.proxmox.nodes(node).storage(storage).content.get()
|
||
return any(it.get('volid') == volid for it in (items or []))
|
||
except Exception as e:
|
||
logger.debug(f"volume_exists check failed {storage}/{volid}: {e}")
|
||
return False
|
||
|
||
def delete_volume(self, node: str, storage: str, volid: str) -> bool:
|
||
"""Удалить том из хранилища. volid вида 'storage:content/filename'.
|
||
Возвращает True при успехе, False при ошибке."""
|
||
if not self.proxmox:
|
||
return False
|
||
# proxmoxer принимает volume без префикса хранилища
|
||
volume = volid.split(':', 1)[1] if ':' in volid else volid
|
||
try:
|
||
self.proxmox.nodes(node).storage(storage).content(volume).delete()
|
||
logger.info(f"Deleted volume {volid} from {node}:{storage}")
|
||
return True
|
||
except Exception as e:
|
||
logger.warning(f"delete_volume failed {node}:{storage}/{volume}: {e}")
|
||
return False
|
||
|
||
def get_download_target_storages(self, node: str, content: str) -> List[Dict]:
|
||
"""
|
||
Получить активные хранилища ноды, поддерживающие указанный тип контента
|
||
(для выбора цели загрузки образов).
|
||
|
||
Args:
|
||
node: имя ноды
|
||
content: 'iso' | 'vztmpl' | 'images' | 'import'
|
||
"""
|
||
if not self.proxmox:
|
||
return []
|
||
result: List[Dict] = []
|
||
try:
|
||
for s in self.proxmox.nodes(node).storage.get():
|
||
if s.get('active', 1) == 0 or s.get('enabled', 1) == 0:
|
||
continue
|
||
if content in (s.get('content', '') or '').split(','):
|
||
result.append(s)
|
||
except Exception as e:
|
||
logger.error(f"Ошибка получения хранилищ ({content}) ноды {node}: {e}")
|
||
return result
|
||
|
||
def get_storage_node_status(self) -> Dict[str, List[Dict[str, Any]]]:
|
||
"""
|
||
Доступность каждого хранилища по нодам — одним запросом /cluster/resources.
|
||
|
||
Конфиг кластера (/storage) не знает, поднялось ли хранилище на конкретной
|
||
ноде: VG/пул может быть объявлен, но физически отсутствовать — типичный
|
||
случай после ввода новой ноды в кластер, где локальные хранилища ноды
|
||
заменяются кластерным конфигом.
|
||
|
||
Хранилища, отключённые в конфиге (disable), в /cluster/resources не
|
||
попадают, поэтому в карте их не будет.
|
||
|
||
Returns:
|
||
{storage_id: [{'node': 'prod', 'active': True}, ...]}
|
||
"""
|
||
if not self.proxmox:
|
||
return {}
|
||
status: Dict[str, List[Dict[str, Any]]] = {}
|
||
try:
|
||
for res in self.get_cluster_resources(type_='storage'):
|
||
storage_id = res.get('storage')
|
||
node = res.get('node')
|
||
if not storage_id or not node:
|
||
continue
|
||
status.setdefault(storage_id, []).append({
|
||
'node': node,
|
||
'active': res.get('status') == 'available',
|
||
})
|
||
except Exception as e:
|
||
logger.error(f"Ошибка получения статусов хранилищ с {self.host}: {e}")
|
||
return status
|
||
|
||
def scan_nfs_exports(self, node: str, server: str) -> List[Dict]:
|
||
"""
|
||
Получить список NFS-экспортов удалённого сервера через
|
||
GET /nodes/{node}/scan/nfs — для выбора export в диалоге добавления
|
||
хранилища вместо ручного ввода пути.
|
||
"""
|
||
if not self.proxmox:
|
||
return []
|
||
return self.proxmox.nodes(node).scan.nfs.get(server=server)
|
||
|
||
def get_node_storages(self, node: str, content: str = None) -> List[Dict]:
|
||
"""Get storages available on a specific node, optionally filtered by content type"""
|
||
if not self.proxmox:
|
||
return []
|
||
try:
|
||
params = {}
|
||
if content:
|
||
params['content'] = content
|
||
storages = self.proxmox.nodes(node).storage.get(**params)
|
||
return storages
|
||
except Exception as e:
|
||
logger.error(f"Error getting storages for node {node}: {e}")
|
||
return []
|
||
|
||
def vzdump_guest(self, node: str, vmid: int, storage: str,
|
||
compress: str = 'zstd', mode: str = 'snapshot') -> Dict:
|
||
"""
|
||
Создать резервную копию VM/LXC через vzdump.
|
||
|
||
Args:
|
||
node: Имя ноды
|
||
vmid: ID ВМ или контейнера
|
||
storage: Хранилище для бэкапа (должно поддерживать content=backup)
|
||
compress: Алгоритм сжатия (zstd, lzo, gzip, 0)
|
||
mode: Режим снятия бэкапа (snapshot, suspend, stop)
|
||
|
||
Returns:
|
||
Dict с UPID задачи
|
||
"""
|
||
if not self.proxmox:
|
||
return {'success': False, 'error': 'Not connected'}
|
||
|
||
try:
|
||
upid = self.proxmox.nodes(node).vzdump.post(
|
||
vmid=vmid,
|
||
storage=storage,
|
||
compress=compress,
|
||
mode=mode,
|
||
remove=0, # не удалять старые бэкапы автоматически
|
||
)
|
||
logger.info(f"vzdump started for vmid {vmid} on {node}, storage {storage}, UPID: {upid}")
|
||
return {'success': True, 'upid': upid, 'vmid': vmid}
|
||
except Exception as e:
|
||
logger.error(f"Ошибка vzdump vmid {vmid} на {node}: {e}")
|
||
return {'success': False, 'error': str(e), 'vmid': vmid}
|
||
|
||
def get_backup_storages(self, node: str) -> List[Dict]:
|
||
"""
|
||
Получить список хранилищ, поддерживающих content=backup на ноде.
|
||
|
||
Returns:
|
||
Список Dict с полями: storage, type, content, avail, total
|
||
"""
|
||
if not self.proxmox:
|
||
return []
|
||
|
||
try:
|
||
storages = self.proxmox.nodes(node).storage.get(content='backup', enabled=1)
|
||
return storages
|
||
except Exception as e:
|
||
logger.error(f"Ошибка получения backup-хранилищ для {node}: {e}")
|
||
return []
|
||
|
||
def get_node_pci_devices(self, node: str) -> List[Dict]:
|
||
"""
|
||
Получить список PCI-устройств ноды для passthrough (hostpciN).
|
||
|
||
Returns:
|
||
Список Dict с полями: id (0000:01:00.0), vendor_name, device_name,
|
||
iommugroup, mdev, subsystem_* и т.д. Отсортирован по id.
|
||
"""
|
||
if not self.proxmox:
|
||
return []
|
||
try:
|
||
devices = self.proxmox.nodes(node).hardware.pci.get()
|
||
return sorted(devices, key=lambda d: d.get('id', ''))
|
||
except Exception as e:
|
||
logger.error(f"Ошибка получения PCI-устройств ноды {node}: {e}")
|
||
return []
|
||
|
||
def get_node_usb_devices(self, node: str) -> List[Dict]:
|
||
"""
|
||
Получить список USB-устройств ноды для passthrough (usbN).
|
||
|
||
Returns:
|
||
Список Dict с полями: busnum, devnum, vendid, prodid, port,
|
||
usbpath, class, speed, prodname/manufacturer. Отсортирован по usbpath.
|
||
"""
|
||
if not self.proxmox:
|
||
return []
|
||
try:
|
||
# Не все версии PVE отдают hardware/usb; scanusb — исторический fallback.
|
||
try:
|
||
devices = self.proxmox.nodes(node).hardware.usb.get()
|
||
except Exception:
|
||
devices = self.proxmox.nodes(node).scanusb.get()
|
||
# Отбрасываем корневые хабы без vendor/product id — их нельзя пробросить.
|
||
cleaned = [d for d in devices if d.get('vendid') and d.get('prodid')]
|
||
return sorted(cleaned, key=lambda d: str(d.get('usbpath') or d.get('port') or ''))
|
||
except Exception as e:
|
||
logger.error(f"Ошибка получения USB-устройств ноды {node}: {e}")
|
||
return []
|
||
|