Refactor backend: Split models and proxmox_client into modules, add alembic
Some checks failed
Backend Tests / pytest (Python 3.12) (push) Failing after 2m27s
Some checks failed
Backend Tests / pytest (Python 3.12) (push) Failing after 2m27s
This commit is contained in:
310
backend/app/proxmox/mixins/cluster.py
Normal file
310
backend/app/proxmox/mixins/cluster.py
Normal file
@@ -0,0 +1,310 @@
|
||||
from typing import List, Dict, Optional, Union, Any
|
||||
import time
|
||||
import urllib3
|
||||
from loguru import logger
|
||||
|
||||
class ClusterMixin:
|
||||
def is_cluster(self) -> bool:
|
||||
"""Проверить, является ли сервер частью кластера"""
|
||||
if not self.proxmox:
|
||||
return False
|
||||
|
||||
try:
|
||||
# Проверяем через cluster/status - это наиболее надёжный способ
|
||||
cluster_status = self.proxmox.cluster.status.get()
|
||||
# В кластере будет тип 'cluster' с информацией
|
||||
for item in cluster_status:
|
||||
if item.get('type') == 'cluster':
|
||||
# Это кластер
|
||||
return True
|
||||
# Также проверяем количество нод
|
||||
nodes = [item for item in cluster_status if item.get('type') == 'node']
|
||||
return len(nodes) > 1
|
||||
except Exception as e:
|
||||
# Если /cluster/status не работает, пробуем через ноды
|
||||
try:
|
||||
nodes = self.get_nodes()
|
||||
return len(nodes) > 1
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def get_ha_resources(self) -> List[Dict]:
|
||||
"""Получить список ресурсов в HA"""
|
||||
if not self.proxmox:
|
||||
return []
|
||||
|
||||
try:
|
||||
resources = self.proxmox.cluster.ha.resources.get()
|
||||
return resources
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения HA ресурсов: {e}")
|
||||
return []
|
||||
|
||||
def get_ha_groups(self) -> List[Dict]:
|
||||
"""Получить список HA групп"""
|
||||
if not self.proxmox:
|
||||
return []
|
||||
|
||||
try:
|
||||
groups = self.proxmox.cluster.ha.groups.get()
|
||||
return groups
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения HA групп: {e}")
|
||||
return []
|
||||
|
||||
def is_in_ha(self, vmid: int, vm_type: str = 'vm') -> bool:
|
||||
"""
|
||||
Проверить, находится ли VM/контейнер в HA
|
||||
|
||||
Args:
|
||||
vmid: ID виртуальной машины или контейнера
|
||||
vm_type: 'vm' или 'ct'
|
||||
"""
|
||||
if not self.proxmox:
|
||||
return False
|
||||
|
||||
try:
|
||||
sid = f"{vm_type}:{vmid}"
|
||||
resources = self.get_ha_resources()
|
||||
return any(r.get('sid') == sid for r in resources)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def add_to_ha(self, vmid: int, vm_type: str = 'vm', group: str = None,
|
||||
max_restart: int = 1, max_relocate: int = 1,
|
||||
state: str = 'started', comment: str = None) -> Dict:
|
||||
"""
|
||||
Добавить VM/контейнер в HA
|
||||
|
||||
Args:
|
||||
vmid: ID виртуальной машины или контейнера
|
||||
vm_type: 'vm' для QEMU, 'ct' для LXC
|
||||
group: Имя HA группы (опционально)
|
||||
max_restart: Максимальное количество перезапусков при сбое
|
||||
max_relocate: Максимальное количество перемещений на другую ноду
|
||||
state: Целевое состояние: 'started', 'stopped', 'enabled', 'disabled', 'ignored'
|
||||
comment: Комментарий
|
||||
|
||||
Returns:
|
||||
Dict с результатом операции
|
||||
"""
|
||||
if not self.proxmox:
|
||||
return {'success': False, 'error': 'Not connected'}
|
||||
|
||||
try:
|
||||
sid = f"{vm_type}:{vmid}"
|
||||
|
||||
# Проверяем, не добавлен ли уже
|
||||
if self.is_in_ha(vmid, vm_type):
|
||||
return {'success': False, 'error': 'Already in HA', 'already_in_ha': True}
|
||||
|
||||
params = {
|
||||
'sid': sid,
|
||||
'max_restart': max_restart,
|
||||
'max_relocate': max_relocate,
|
||||
'state': state
|
||||
}
|
||||
|
||||
if group:
|
||||
params['group'] = group
|
||||
if comment:
|
||||
params['comment'] = comment
|
||||
|
||||
self.proxmox.cluster.ha.resources.post(**params)
|
||||
logger.info(f"Added {sid} to HA")
|
||||
return {'success': True, 'sid': sid}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка добавления {vmid} в HA: {e}")
|
||||
return {'success': False, 'error': str(e)}
|
||||
|
||||
def remove_from_ha(self, vmid: int, vm_type: str = 'vm') -> Dict:
|
||||
"""
|
||||
Удалить VM/контейнер из HA
|
||||
|
||||
Args:
|
||||
vmid: ID виртуальной машины или контейнера
|
||||
vm_type: 'vm' для QEMU, 'ct' для LXC
|
||||
|
||||
Returns:
|
||||
Dict с результатом операции
|
||||
"""
|
||||
if not self.proxmox:
|
||||
return {'success': False, 'error': 'Not connected'}
|
||||
|
||||
try:
|
||||
sid = f"{vm_type}:{vmid}"
|
||||
|
||||
# Проверяем, что ресурс в HA
|
||||
if not self.is_in_ha(vmid, vm_type):
|
||||
return {'success': False, 'error': 'Not in HA', 'not_in_ha': True}
|
||||
|
||||
self.proxmox.cluster.ha.resources(sid).delete()
|
||||
logger.info(f"Removed {sid} from HA")
|
||||
return {'success': True, 'sid': sid}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка удаления {vmid} из HA: {e}")
|
||||
return {'success': False, 'error': str(e)}
|
||||
|
||||
def get_ha_status(self, vmid: int, vm_type: str = 'vm') -> Optional[Dict]:
|
||||
"""
|
||||
Получить статус HA для VM/контейнера
|
||||
|
||||
Returns:
|
||||
Dict со статусом HA или None если не в HA
|
||||
"""
|
||||
if not self.proxmox:
|
||||
return None
|
||||
|
||||
try:
|
||||
sid = f"{vm_type}:{vmid}"
|
||||
resources = self.get_ha_resources()
|
||||
|
||||
for r in resources:
|
||||
if r.get('sid') == sid:
|
||||
return {
|
||||
'in_ha': True,
|
||||
'state': r.get('state'),
|
||||
'group': r.get('group'),
|
||||
'max_restart': r.get('max_restart'),
|
||||
'max_relocate': r.get('max_relocate'),
|
||||
'comment': r.get('comment')
|
||||
}
|
||||
|
||||
return {'in_ha': False}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения HA статуса {vmid}: {e}")
|
||||
return None
|
||||
|
||||
def create_cluster(self, cluster_name: str, link0: str = None) -> Dict:
|
||||
"""
|
||||
Создать новый Proxmox кластер на этой ноде.
|
||||
Требует аутентификации через пароль (root@pam).
|
||||
|
||||
Args:
|
||||
cluster_name: Уникальное имя кластера (не изменяемо после создания)
|
||||
link0: IP-адрес для corosync link0 (по умолчанию — IP ноды)
|
||||
|
||||
Returns:
|
||||
Dict с UPID задачи или ошибкой
|
||||
"""
|
||||
if not self.proxmox:
|
||||
return {'success': False, 'error': 'Not connected'}
|
||||
|
||||
try:
|
||||
params = {'clustername': cluster_name}
|
||||
if link0:
|
||||
params['link0'] = link0
|
||||
|
||||
result = self.proxmox.cluster.config.post(**params)
|
||||
logger.info(f"Cluster '{cluster_name}' creation started on {self.host}, UPID: {result}")
|
||||
return {'success': True, 'upid': result, 'cluster_name': cluster_name}
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка создания кластера '{cluster_name}' на {self.host}: {e}")
|
||||
return {'success': False, 'error': str(e)}
|
||||
|
||||
def get_cluster_join_info(self) -> Dict:
|
||||
"""
|
||||
Получить информацию для присоединения к кластеру (fingerprint, join token).
|
||||
Вызывается на существующей кластерной ноде.
|
||||
|
||||
Returns:
|
||||
Dict с полями: totem, nodelist, fingerprint, config_digest
|
||||
"""
|
||||
if not self.proxmox:
|
||||
return {'success': False, 'error': 'Not connected'}
|
||||
|
||||
try:
|
||||
info = self.proxmox.cluster.config.join.get()
|
||||
logger.info(f"Got cluster join info from {self.host}")
|
||||
return {'success': True, 'data': info}
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения join info с {self.host}: {e}")
|
||||
return {'success': False, 'error': str(e)}
|
||||
|
||||
def join_cluster(self, cluster_host: str, rootpw: str, fingerprint: str,
|
||||
link0: str = None) -> Dict:
|
||||
"""
|
||||
Присоединить эту ноду к существующему кластеру.
|
||||
ВАЖНО: Нода должна быть пустой — /etc/pve будет перезаписан.
|
||||
|
||||
Args:
|
||||
cluster_host: IP/hostname существующей кластерной ноды
|
||||
rootpw: root-пароль кластерной ноды (нужен для join)
|
||||
fingerprint: SHA-256 fingerprint кластерного сертификата
|
||||
link0: Локальный IP для corosync link0 (по умолчанию — IP ноды)
|
||||
|
||||
Returns:
|
||||
Dict с UPID задачи или ошибкой
|
||||
"""
|
||||
if not self.proxmox:
|
||||
return {'success': False, 'error': 'Not connected'}
|
||||
|
||||
try:
|
||||
params = {
|
||||
'hostname': cluster_host,
|
||||
'password': rootpw,
|
||||
'fingerprint': fingerprint,
|
||||
}
|
||||
if link0:
|
||||
params['link0'] = link0
|
||||
|
||||
result = self.proxmox.cluster.config.join.post(**params)
|
||||
logger.info(f"Node {self.host} joining cluster at {cluster_host}, UPID: {result}")
|
||||
return {'success': True, 'upid': result}
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка присоединения {self.host} к кластеру {cluster_host}: {e}")
|
||||
return {'success': False, 'error': str(e)}
|
||||
|
||||
def delete_cluster_node(self, node_name: str) -> Dict:
|
||||
"""
|
||||
Удалить ноду из кластера (pvecm delnode).
|
||||
Вызывается на ДРУГОЙ ноде кластера, не на удаляемой.
|
||||
Удаляемая нода должна быть ВЫКЛЮЧЕНА.
|
||||
|
||||
Args:
|
||||
node_name: Имя ноды для удаления (hostname)
|
||||
|
||||
Returns:
|
||||
Dict с результатом
|
||||
"""
|
||||
if not self.proxmox:
|
||||
return {'success': False, 'error': 'Not connected'}
|
||||
|
||||
try:
|
||||
# Proxmox PVE 8 API: DELETE /nodes/{node}
|
||||
self.proxmox.nodes(node_name).delete()
|
||||
logger.info(f"Node '{node_name}' deleted from cluster via {self.host}")
|
||||
return {'success': True, 'node': node_name}
|
||||
except Exception as e:
|
||||
logger.warning(f"REST API delete node failed, trying SSH fallback: {e}")
|
||||
# SSH fallback через pvecm delnode
|
||||
return self._delete_cluster_node_via_ssh(node_name)
|
||||
|
||||
def _delete_cluster_node_via_ssh(self, node_name: str) -> Dict:
|
||||
"""SSH fallback для удаления ноды через pvecm delnode"""
|
||||
from app.ssh_client import SSHClient
|
||||
ssh = SSHClient(
|
||||
hostname=self.host,
|
||||
username='root',
|
||||
password=getattr(self, '_password', None),
|
||||
)
|
||||
try:
|
||||
if not ssh.connect():
|
||||
return {'success': False, 'error': 'SSH connection failed'}
|
||||
|
||||
output, exit_code = ssh.execute(f"pvecm delnode {node_name}", return_exit_code=True)
|
||||
ssh.close()
|
||||
|
||||
# "Could not kill node (error = CS_ERR_NOT_EXIST)" — ignorable per Proxmox docs
|
||||
if exit_code == 0 or (output and 'CS_ERR_NOT_EXIST' in output):
|
||||
logger.info(f"Node '{node_name}' removed via pvecm delnode (SSH)")
|
||||
return {'success': True, 'node': node_name, 'method': 'ssh'}
|
||||
else:
|
||||
return {'success': False, 'error': output or 'pvecm delnode failed'}
|
||||
except Exception as e:
|
||||
logger.error(f"SSH pvecm delnode failed for {node_name}: {e}")
|
||||
return {'success': False, 'error': str(e)}
|
||||
|
||||
194
backend/app/proxmox/mixins/lxc.py
Normal file
194
backend/app/proxmox/mixins/lxc.py
Normal file
@@ -0,0 +1,194 @@
|
||||
from typing import List, Dict, Optional, Union, Any
|
||||
import time
|
||||
import urllib3
|
||||
import shlex
|
||||
from loguru import logger
|
||||
|
||||
class LxcMixin:
|
||||
def start_container(self, node: str, vmid: int) -> Optional[str]:
|
||||
"""Запустить LXC контейнер. Возвращает UPID задачи или None при ошибке."""
|
||||
if not self.proxmox:
|
||||
return None
|
||||
|
||||
try:
|
||||
upid = self.proxmox.nodes(node).lxc(vmid).status.start.post()
|
||||
return upid if isinstance(upid, str) else None
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка запуска LXC {vmid} на {node}: {e}")
|
||||
return None
|
||||
|
||||
def stop_container(self, node: str, vmid: int, force: bool = False) -> Optional[str]:
|
||||
"""Остановить LXC контейнер. Возвращает UPID задачи или None при ошибке."""
|
||||
if not self.proxmox:
|
||||
return None
|
||||
|
||||
try:
|
||||
upid = self.proxmox.nodes(node).lxc(vmid).status.stop.post()
|
||||
return upid if isinstance(upid, str) else None
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка остановки LXC {vmid} на {node}: {e}")
|
||||
return None
|
||||
|
||||
def restart_container(self, node: str, vmid: int) -> Optional[str]:
|
||||
"""Перезапустить LXC контейнер. Возвращает UPID задачи или None при ошибке."""
|
||||
if not self.proxmox:
|
||||
return None
|
||||
|
||||
try:
|
||||
upid = self.proxmox.nodes(node).lxc(vmid).status.reboot.post()
|
||||
return upid if isinstance(upid, str) else None
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка перезапуска LXC {vmid} на {node}: {e}")
|
||||
return None
|
||||
|
||||
def shutdown_container(self, node: str, vmid: int, timeout: int = 60) -> Optional[str]:
|
||||
"""Корректно выключить LXC. Возвращает UPID задачи."""
|
||||
if not self.proxmox:
|
||||
return None
|
||||
try:
|
||||
upid = self.proxmox.nodes(node).lxc(vmid).status.shutdown.post(timeout=timeout)
|
||||
return upid if isinstance(upid, str) else None
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка корректного выключения LXC {vmid} на {node}: {e}")
|
||||
return None
|
||||
|
||||
def clone_container(self, node: str, vmid: int, new_vmid: int, hostname: str,
|
||||
full: bool = True, target_node: Optional[str] = None,
|
||||
target_storage: Optional[str] = None,
|
||||
description: Optional[str] = None) -> Optional[str]:
|
||||
"""Клонировать LXC контейнер."""
|
||||
if not self.proxmox:
|
||||
return None
|
||||
try:
|
||||
params = {'newid': new_vmid, 'hostname': hostname, 'full': 1 if full 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
|
||||
upid = self.proxmox.nodes(node).lxc(vmid).clone.post(**params)
|
||||
return upid if isinstance(upid, str) else None
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка клонирования LXC {vmid} -> {new_vmid}: {e}")
|
||||
return None
|
||||
|
||||
def change_container_password(self, node: str, vmid: int, username: str, password: str) -> Dict:
|
||||
"""
|
||||
Сменить пароль пользователя в LXC через `pct exec` (chpasswd).
|
||||
Контейнер должен быть запущен.
|
||||
"""
|
||||
if not self.proxmox:
|
||||
return {'success': False, 'error': 'Proxmox connection not initialized'}
|
||||
try:
|
||||
result = self.proxmox.nodes(node).lxc(vmid).status.current.get()
|
||||
if result.get('status') != 'running':
|
||||
return {'success': False, 'error': 'Container is not running'}
|
||||
# Используем printf + chpasswd; shlex.quote защищает от shell-injection
|
||||
# Proxmox exec API ожидает отдельные поля: command + args[N]
|
||||
entry = shlex.quote(f'{username}:{password}')
|
||||
cmd_str = f"printf '%s\\n' {entry} | chpasswd"
|
||||
data = {'command': 'bash', 'args[0]': '-c', 'args[1]': cmd_str}
|
||||
res = self.proxmox.nodes(node).lxc(vmid).exec.post(**data)
|
||||
return {'success': True, 'result': res}
|
||||
except Exception as e:
|
||||
logger.error(f"Не удалось сменить пароль в LXC {vmid}: {e}")
|
||||
return {'success': False, 'error': str(e)}
|
||||
|
||||
def set_container_notes(self, node: str, vmid: int, description: str) -> bool:
|
||||
"""Установить description (заметки) у LXC."""
|
||||
if not self.proxmox:
|
||||
return False
|
||||
try:
|
||||
self.proxmox.nodes(node).lxc(vmid).config.put(description=description or '')
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обновления заметок LXC {vmid}: {e}")
|
||||
return False
|
||||
|
||||
def force_stop_container(self, node: str, vmid: int) -> bool:
|
||||
"""Принудительно остановить LXC контейнер (через SSH - аналог kill -9)"""
|
||||
if not self.proxmox or not self.host:
|
||||
return False
|
||||
|
||||
try:
|
||||
# Пробуем остановить через SSH команду
|
||||
# Сначала получаем информацию о контейнере
|
||||
container_info = self.proxmox.nodes(node).lxc(vmid).status.current.get()
|
||||
if container_info.get('status') != 'running':
|
||||
logger.info(f"Контейнер {vmid} на {node} уже остановлен")
|
||||
return True
|
||||
|
||||
# Используем обычный stop - Proxmox должен остановить его
|
||||
try:
|
||||
self.proxmox.nodes(node).lxc(vmid).status.stop.post()
|
||||
logger.info(f"Принудительная остановка контейнера {vmid} на {node} выполнена")
|
||||
return True
|
||||
except Exception as api_error:
|
||||
logger.warning(f"Прямой stop не сработал, ошибка: {api_error}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка принудительной остановки контейнера {vmid}: {e}")
|
||||
return False
|
||||
|
||||
def get_container_status(self, node: str, vmid: int) -> Optional[Dict]:
|
||||
"""Получить статус конкретного LXC контейнера"""
|
||||
if not self.proxmox:
|
||||
return None
|
||||
|
||||
try:
|
||||
status = self.proxmox.nodes(node).lxc(vmid).status.current.get()
|
||||
return status
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения статуса LXC {vmid} на {node}: {e}")
|
||||
return None
|
||||
|
||||
def get_container_stats(self, node: str, vmid: int) -> Optional[Dict]:
|
||||
"""Получить статистику LXC контейнера (CPU, память, диск)"""
|
||||
return self.get_container_status(node, vmid)
|
||||
|
||||
def get_container_rrddata(self, node: str, vmid: int, timeframe: str = "hour") -> Dict:
|
||||
"""
|
||||
Получить исторические данные контейнера для графиков
|
||||
|
||||
Args:
|
||||
node: Имя ноды
|
||||
vmid: ID контейнера
|
||||
timeframe: Период времени (hour, day, week, month, year)
|
||||
|
||||
Returns:
|
||||
Dict с временными рядами данных
|
||||
"""
|
||||
if not self.proxmox:
|
||||
return {}
|
||||
|
||||
try:
|
||||
rrddata = self.proxmox.nodes(node).lxc(vmid).rrddata.get(timeframe=timeframe)
|
||||
return {'data': rrddata, 'timeframe': timeframe}
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения RRD данных LXC {vmid} на {node}: {e}")
|
||||
return {}
|
||||
|
||||
def get_container_vnc(self, node: str, vmid: int) -> Dict:
|
||||
"""
|
||||
Получить данные для VNC подключения к LXC контейнеру
|
||||
|
||||
Args:
|
||||
node: Имя ноды
|
||||
vmid: ID контейнера
|
||||
|
||||
Returns:
|
||||
Dict с данными VNC (port, ticket, cert, upid)
|
||||
"""
|
||||
if not self.proxmox:
|
||||
return {}
|
||||
|
||||
try:
|
||||
# Создать терминал прокси сессию для LXC
|
||||
vnc_data = self.proxmox.nodes(node).lxc(vmid).vncproxy.post(websocket=1)
|
||||
return vnc_data
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения VNC для LXC {vmid} на {node}: {e}")
|
||||
return {}
|
||||
|
||||
143
backend/app/proxmox/mixins/network.py
Normal file
143
backend/app/proxmox/mixins/network.py
Normal file
@@ -0,0 +1,143 @@
|
||||
from typing import List, Dict, Optional, Union, Any
|
||||
import time
|
||||
import urllib3
|
||||
from loguru import logger
|
||||
|
||||
class NetworkMixin:
|
||||
def get_sdn_vnets(self) -> List[Dict]:
|
||||
"""
|
||||
Get all SDN VNets (Virtual Networks).
|
||||
|
||||
Returns:
|
||||
List of VNets with vnet name, zone, alias, etc.
|
||||
"""
|
||||
if not self.proxmox:
|
||||
return []
|
||||
|
||||
try:
|
||||
vnets = self.proxmox.cluster.sdn.vnets.get()
|
||||
return vnets if isinstance(vnets, list) else []
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting SDN vnets: {e}")
|
||||
return []
|
||||
|
||||
def get_sdn_zones(self) -> List[Dict]:
|
||||
"""
|
||||
Get all SDN zones.
|
||||
|
||||
Returns:
|
||||
List of SDN zones with type, zone name, pending status
|
||||
"""
|
||||
if not self.proxmox:
|
||||
return []
|
||||
|
||||
try:
|
||||
zones = self.proxmox.cluster.sdn.zones.get()
|
||||
return zones if isinstance(zones, list) else []
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting SDN zones: {e}")
|
||||
return []
|
||||
|
||||
def create_sdn_zone(self, zone: str, zone_type: str = "simple", **kwargs) -> Dict:
|
||||
"""
|
||||
Create a new SDN zone.
|
||||
|
||||
Args:
|
||||
zone: Zone name (alphanumeric, max 8 chars)
|
||||
zone_type: Type of zone (simple, vlan, qinq, vxlan, evpn)
|
||||
**kwargs: Additional zone options (mtu, dns, reversedns, etc.)
|
||||
|
||||
Returns:
|
||||
Result dict with success status
|
||||
"""
|
||||
if not self.proxmox:
|
||||
return {"success": False, "error": "Not connected"}
|
||||
|
||||
try:
|
||||
params = {
|
||||
"zone": zone,
|
||||
"type": zone_type,
|
||||
}
|
||||
params.update(kwargs)
|
||||
|
||||
self.proxmox.cluster.sdn.zones.post(**params)
|
||||
logger.info(f"Created SDN zone: {zone} (type={zone_type})")
|
||||
return {"success": True, "zone": zone}
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating SDN zone {zone}: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
def create_sdn_vnet(self, vnet: str, zone: str, tag: int = None,
|
||||
alias: str = None, vlanaware: bool = False) -> Dict:
|
||||
"""
|
||||
Create a new SDN VNet.
|
||||
|
||||
Args:
|
||||
vnet: VNet name (alphanumeric, max 8 chars)
|
||||
zone: Zone where to create the VNet
|
||||
tag: VLAN/VNI tag (optional)
|
||||
alias: Human-readable alias
|
||||
vlanaware: Enable VLAN-aware bridge
|
||||
|
||||
Returns:
|
||||
Result dict with success status
|
||||
"""
|
||||
if not self.proxmox:
|
||||
return {"success": False, "error": "Not connected"}
|
||||
|
||||
try:
|
||||
params = {
|
||||
"vnet": vnet,
|
||||
"zone": zone,
|
||||
}
|
||||
if tag is not None:
|
||||
params["tag"] = tag
|
||||
if alias:
|
||||
params["alias"] = alias
|
||||
if vlanaware:
|
||||
params["vlanaware"] = 1
|
||||
|
||||
self.proxmox.cluster.sdn.vnets.post(**params)
|
||||
logger.info(f"Created SDN vnet: {vnet} in zone {zone}")
|
||||
return {"success": True, "vnet": vnet}
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating SDN vnet {vnet}: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
def create_sdn_subnet(self, vnet: str, subnet: str, gateway: str = None,
|
||||
snat: bool = False, dnszoneprefix: str = None) -> Dict:
|
||||
"""
|
||||
Create a subnet in a VNet.
|
||||
|
||||
Args:
|
||||
vnet: VNet name
|
||||
subnet: Subnet CIDR (e.g., "10.0.0.0/24")
|
||||
gateway: Gateway IP for the subnet
|
||||
snat: Enable SNAT for outgoing traffic
|
||||
dnszoneprefix: DNS zone prefix
|
||||
|
||||
Returns:
|
||||
Result dict with success status
|
||||
"""
|
||||
if not self.proxmox:
|
||||
return {"success": False, "error": "Not connected"}
|
||||
|
||||
try:
|
||||
params = {
|
||||
"subnet": subnet,
|
||||
"type": "subnet",
|
||||
}
|
||||
if gateway:
|
||||
params["gateway"] = gateway
|
||||
if snat:
|
||||
params["snat"] = 1
|
||||
if dnszoneprefix:
|
||||
params["dnszoneprefix"] = dnszoneprefix
|
||||
|
||||
self.proxmox.cluster.sdn.vnets(vnet).subnets.post(**params)
|
||||
logger.info(f"Created subnet {subnet} in vnet {vnet}")
|
||||
return {"success": True, "subnet": subnet}
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating subnet {subnet} in vnet {vnet}: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
221
backend/app/proxmox/mixins/snapshot.py
Normal file
221
backend/app/proxmox/mixins/snapshot.py
Normal file
@@ -0,0 +1,221 @@
|
||||
from typing import List, Dict, Optional, Union, Any
|
||||
import time
|
||||
import urllib3
|
||||
from loguru import logger
|
||||
|
||||
class SnapshotMixin:
|
||||
def get_vm_snapshots(self, node: str, vmid: int) -> List[Dict]:
|
||||
"""
|
||||
Get all snapshots for a VM.
|
||||
|
||||
Args:
|
||||
node: Node name
|
||||
vmid: VM ID
|
||||
|
||||
Returns:
|
||||
List of snapshots
|
||||
"""
|
||||
if not self.proxmox:
|
||||
return []
|
||||
|
||||
try:
|
||||
snapshots = self.proxmox.nodes(node).qemu(vmid).snapshot.get()
|
||||
return snapshots if isinstance(snapshots, list) else []
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting VM {vmid} snapshots: {e}")
|
||||
return []
|
||||
|
||||
def create_vm_snapshot(self, node: str, vmid: int, snapname: str,
|
||||
description: str = None, vmstate: bool = False) -> Dict:
|
||||
"""
|
||||
Create a snapshot of a VM.
|
||||
|
||||
Args:
|
||||
node: Node name
|
||||
vmid: VM ID
|
||||
snapname: Snapshot name (alphanumeric, max 40 chars)
|
||||
description: Optional description
|
||||
vmstate: Include VM RAM state (requires more storage)
|
||||
|
||||
Returns:
|
||||
Result dict with UPID or error
|
||||
"""
|
||||
if not self.proxmox:
|
||||
return {"success": False, "error": "Not connected"}
|
||||
|
||||
try:
|
||||
params = {"snapname": snapname}
|
||||
if description:
|
||||
params["description"] = description
|
||||
if vmstate:
|
||||
params["vmstate"] = 1
|
||||
|
||||
upid = self.proxmox.nodes(node).qemu(vmid).snapshot.post(**params)
|
||||
logger.info(f"Creating snapshot {snapname} for VM {vmid}")
|
||||
return {"success": True, "upid": upid, "snapname": snapname}
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating VM {vmid} snapshot: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
def rollback_vm_snapshot(self, node: str, vmid: int, snapname: str, start: bool = False) -> Dict:
|
||||
"""
|
||||
Rollback a VM to a snapshot.
|
||||
|
||||
Args:
|
||||
node: Node name
|
||||
vmid: VM ID
|
||||
snapname: Snapshot name to rollback to
|
||||
start: Start VM after rollback
|
||||
|
||||
Returns:
|
||||
Result dict with UPID or error
|
||||
"""
|
||||
if not self.proxmox:
|
||||
return {"success": False, "error": "Not connected"}
|
||||
|
||||
try:
|
||||
params = {}
|
||||
if start:
|
||||
params["start"] = 1
|
||||
|
||||
upid = self.proxmox.nodes(node).qemu(vmid).snapshot(snapname).rollback.post(**params)
|
||||
logger.info(f"Rolling back VM {vmid} to snapshot {snapname}")
|
||||
return {"success": True, "upid": upid}
|
||||
except Exception as e:
|
||||
logger.error(f"Error rolling back VM {vmid} to snapshot {snapname}: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
def delete_vm_snapshot(self, node: str, vmid: int, snapname: str, force: bool = False) -> Dict:
|
||||
"""
|
||||
Delete a VM snapshot.
|
||||
|
||||
Args:
|
||||
node: Node name
|
||||
vmid: VM ID
|
||||
snapname: Snapshot name to delete
|
||||
force: Force deletion even if snapshot is locked
|
||||
|
||||
Returns:
|
||||
Result dict with success status
|
||||
"""
|
||||
if not self.proxmox:
|
||||
return {"success": False, "error": "Not connected"}
|
||||
|
||||
try:
|
||||
params = {}
|
||||
if force:
|
||||
params["force"] = 1
|
||||
|
||||
upid = self.proxmox.nodes(node).qemu(vmid).snapshot(snapname).delete(**params)
|
||||
logger.info(f"Deleting snapshot {snapname} for VM {vmid}")
|
||||
return {"success": True, "upid": upid}
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting VM {vmid} snapshot {snapname}: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
def get_container_snapshots(self, node: str, vmid: int) -> List[Dict]:
|
||||
"""
|
||||
Get all snapshots for a container.
|
||||
|
||||
Args:
|
||||
node: Node name
|
||||
vmid: Container ID
|
||||
|
||||
Returns:
|
||||
List of snapshots
|
||||
"""
|
||||
if not self.proxmox:
|
||||
return []
|
||||
|
||||
try:
|
||||
snapshots = self.proxmox.nodes(node).lxc(vmid).snapshot.get()
|
||||
return snapshots if isinstance(snapshots, list) else []
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting container {vmid} snapshots: {e}")
|
||||
return []
|
||||
|
||||
def create_container_snapshot(self, node: str, vmid: int, snapname: str,
|
||||
description: str = None) -> Dict:
|
||||
"""
|
||||
Create a snapshot of a container.
|
||||
|
||||
Args:
|
||||
node: Node name
|
||||
vmid: Container ID
|
||||
snapname: Snapshot name
|
||||
description: Optional description
|
||||
|
||||
Returns:
|
||||
Result dict with UPID or error
|
||||
"""
|
||||
if not self.proxmox:
|
||||
return {"success": False, "error": "Not connected"}
|
||||
|
||||
try:
|
||||
params = {"snapname": snapname}
|
||||
if description:
|
||||
params["description"] = description
|
||||
|
||||
upid = self.proxmox.nodes(node).lxc(vmid).snapshot.post(**params)
|
||||
logger.info(f"Creating snapshot {snapname} for container {vmid}")
|
||||
return {"success": True, "upid": upid, "snapname": snapname}
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating container {vmid} snapshot: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
def rollback_container_snapshot(self, node: str, vmid: int, snapname: str, start: bool = False) -> Dict:
|
||||
"""
|
||||
Rollback a container to a snapshot.
|
||||
|
||||
Args:
|
||||
node: Node name
|
||||
vmid: Container ID
|
||||
snapname: Snapshot name to rollback to
|
||||
start: Start container after rollback
|
||||
|
||||
Returns:
|
||||
Result dict with UPID or error
|
||||
"""
|
||||
if not self.proxmox:
|
||||
return {"success": False, "error": "Not connected"}
|
||||
|
||||
try:
|
||||
params = {}
|
||||
if start:
|
||||
params["start"] = 1
|
||||
|
||||
upid = self.proxmox.nodes(node).lxc(vmid).snapshot(snapname).rollback.post(**params)
|
||||
logger.info(f"Rolling back container {vmid} to snapshot {snapname}")
|
||||
return {"success": True, "upid": upid}
|
||||
except Exception as e:
|
||||
logger.error(f"Error rolling back container {vmid} to snapshot {snapname}: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
def delete_container_snapshot(self, node: str, vmid: int, snapname: str, force: bool = False) -> Dict:
|
||||
"""
|
||||
Delete a container snapshot.
|
||||
|
||||
Args:
|
||||
node: Node name
|
||||
vmid: Container ID
|
||||
snapname: Snapshot name to delete
|
||||
force: Force deletion
|
||||
|
||||
Returns:
|
||||
Result dict with success status
|
||||
"""
|
||||
if not self.proxmox:
|
||||
return {"success": False, "error": "Not connected"}
|
||||
|
||||
try:
|
||||
params = {}
|
||||
if force:
|
||||
params["force"] = 1
|
||||
|
||||
upid = self.proxmox.nodes(node).lxc(vmid).snapshot(snapname).delete(**params)
|
||||
logger.info(f"Deleting snapshot {snapname} for container {vmid}")
|
||||
return {"success": True, "upid": upid}
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting container {vmid} snapshot {snapname}: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
145
backend/app/proxmox/mixins/storage.py
Normal file
145
backend/app/proxmox/mixins/storage.py
Normal file
@@ -0,0 +1,145 @@
|
||||
from typing import List, Dict, Optional, Union, Any
|
||||
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 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 []
|
||||
|
||||
241
backend/app/proxmox/mixins/vm.py
Normal file
241
backend/app/proxmox/mixins/vm.py
Normal file
@@ -0,0 +1,241 @@
|
||||
from typing import List, Dict, Optional, Union, Any
|
||||
import time
|
||||
import urllib3
|
||||
from loguru import logger
|
||||
|
||||
class VmMixin:
|
||||
def start_vm(self, node: str, vmid: int) -> Optional[str]:
|
||||
"""Запустить VM. Возвращает UPID задачи или None при ошибке."""
|
||||
if not self.proxmox:
|
||||
return None
|
||||
|
||||
try:
|
||||
upid = self.proxmox.nodes(node).qemu(vmid).status.start.post()
|
||||
return upid if isinstance(upid, str) else None
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка запуска VM {vmid} на {node}: {e}")
|
||||
return None
|
||||
|
||||
def stop_vm(self, node: str, vmid: int, force: bool = False) -> Optional[str]:
|
||||
"""Остановить VM. Возвращает UPID задачи или None при ошибке."""
|
||||
if not self.proxmox:
|
||||
return None
|
||||
|
||||
try:
|
||||
upid = self.proxmox.nodes(node).qemu(vmid).status.stop.post()
|
||||
return upid if isinstance(upid, str) else None
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка остановки VM {vmid} на {node}: {e}")
|
||||
return None
|
||||
|
||||
def restart_vm(self, node: str, vmid: int) -> Optional[str]:
|
||||
"""Перезапустить VM. Возвращает UPID задачи или None при ошибке."""
|
||||
if not self.proxmox:
|
||||
return None
|
||||
|
||||
try:
|
||||
upid = self.proxmox.nodes(node).qemu(vmid).status.reboot.post()
|
||||
return upid if isinstance(upid, str) else None
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка перезапуска VM {vmid} на {node}: {e}")
|
||||
return None
|
||||
|
||||
def shutdown_vm(self, node: str, vmid: int, timeout: int = 60) -> Optional[str]:
|
||||
"""Корректно выключить VM (ACPI). Возвращает UPID задачи."""
|
||||
if not self.proxmox:
|
||||
return None
|
||||
try:
|
||||
upid = self.proxmox.nodes(node).qemu(vmid).status.shutdown.post(timeout=timeout)
|
||||
return upid if isinstance(upid, str) else None
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка корректного выключения VM {vmid} на {node}: {e}")
|
||||
return None
|
||||
|
||||
def clone_vm(self, node: str, vmid: int, new_vmid: int, name: str,
|
||||
full: bool = True, target_node: Optional[str] = None,
|
||||
target_storage: Optional[str] = None,
|
||||
description: Optional[str] = None) -> Optional[str]:
|
||||
"""Клонировать VM (qemu). Работает и для шаблонов, и для обычных VM."""
|
||||
if not self.proxmox:
|
||||
return None
|
||||
try:
|
||||
params = {'newid': new_vmid, 'name': name, 'full': 1 if full 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
|
||||
upid = self.proxmox.nodes(node).qemu(vmid).clone.post(**params)
|
||||
return upid if isinstance(upid, str) else None
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка клонирования VM {vmid} -> {new_vmid}: {e}")
|
||||
return None
|
||||
|
||||
def change_vm_password(self, node: str, vmid: int, username: str, password: str) -> Dict:
|
||||
"""
|
||||
Сменить пароль пользователя на VM через QEMU guest agent.
|
||||
Требует установленный qemu-guest-agent в VM.
|
||||
"""
|
||||
if not self.proxmox:
|
||||
return {'success': False, 'error': 'Proxmox connection not initialized'}
|
||||
try:
|
||||
# Proxmox API: POST /nodes/{node}/qemu/{vmid}/agent/set-user-password
|
||||
self.proxmox.nodes(node).qemu(vmid).agent('set-user-password').post(
|
||||
username=username,
|
||||
password=password,
|
||||
)
|
||||
return {'success': True}
|
||||
except Exception as e:
|
||||
logger.error(f"Не удалось сменить пароль на VM {vmid}: {e}")
|
||||
return {'success': False, 'error': str(e)}
|
||||
|
||||
def set_vm_notes(self, node: str, vmid: int, description: str) -> bool:
|
||||
"""Установить description (заметки) у VM."""
|
||||
if not self.proxmox:
|
||||
return False
|
||||
try:
|
||||
self.proxmox.nodes(node).qemu(vmid).config.put(description=description or '')
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обновления заметок VM {vmid}: {e}")
|
||||
return False
|
||||
|
||||
def attach_iso(self, node: str, vmid: int, iso_volid: str, device: str = 'ide2') -> bool:
|
||||
"""Подключить ISO образ к VM (только KVM)."""
|
||||
if not self.proxmox:
|
||||
return False
|
||||
try:
|
||||
value = f"{iso_volid},media=cdrom"
|
||||
self.proxmox.nodes(node).qemu(vmid).config.put(**{device: value})
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка подключения ISO {iso_volid} к VM {vmid}: {e}")
|
||||
return False
|
||||
|
||||
def detach_iso(self, node: str, vmid: int, device: str = 'ide2') -> bool:
|
||||
"""Отключить ISO образ (CD-ROM) у VM."""
|
||||
if not self.proxmox:
|
||||
return False
|
||||
try:
|
||||
self.proxmox.nodes(node).qemu(vmid).config.put(**{device: 'none,media=cdrom'})
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка отключения ISO у VM {vmid}: {e}")
|
||||
return False
|
||||
|
||||
def force_stop_vm(self, node: str, vmid: int) -> bool:
|
||||
"""Принудительно остановить ВМ (через SSH - аналог kill -9)"""
|
||||
if not self.proxmox or not self.host:
|
||||
return False
|
||||
|
||||
try:
|
||||
# Пробуем остановить через SSH команду
|
||||
# Сначала получаем информацию о ВМ
|
||||
vm_info = self.proxmox.nodes(node).qemu(vmid).status.current.get()
|
||||
if vm_info.get('status') != 'running':
|
||||
logger.info(f"VM {vmid} на {node} уже остановлена")
|
||||
return True
|
||||
|
||||
# Используем qm sendkey или qm stop с timeout=0
|
||||
try:
|
||||
# Пробуем обычный stop - может сработать
|
||||
self.proxmox.nodes(node).qemu(vmid).status.stop.post()
|
||||
logger.info(f"Принудительная остановка VM {vmid} на {node} выполнена")
|
||||
return True
|
||||
except Exception as api_error:
|
||||
logger.warning(f"Прямой stop не сработал, ошибка: {api_error}")
|
||||
# Если прямой stop не сработал, это просто значит что ВМ не бежит или API не поддерживает
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка принудительной остановки VM {vmid}: {e}")
|
||||
return False
|
||||
|
||||
def get_vm_status(self, node: str, vmid: int) -> Optional[Dict]:
|
||||
"""Получить статус конкретной VM"""
|
||||
if not self.proxmox:
|
||||
return None
|
||||
|
||||
try:
|
||||
status = self.proxmox.nodes(node).qemu(vmid).status.current.get()
|
||||
return status
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения статуса VM {vmid} на {node}: {e}")
|
||||
return None
|
||||
|
||||
def get_vm_stats(self, node: str, vmid: int) -> Optional[Dict]:
|
||||
"""Получить статистику VM (CPU, память, диск)"""
|
||||
return self.get_vm_status(node, vmid)
|
||||
|
||||
def get_vm_rrddata(self, node: str, vmid: int, timeframe: str = "hour") -> Dict:
|
||||
"""
|
||||
Получить исторические данные VM для графиков (CPU, Memory, Network, Disk IO)
|
||||
|
||||
Args:
|
||||
node: Имя ноды
|
||||
vmid: ID виртуальной машины
|
||||
timeframe: Период времени (hour, day, week, month, year)
|
||||
|
||||
Returns:
|
||||
Dict с временными рядами данных
|
||||
"""
|
||||
if not self.proxmox:
|
||||
return {}
|
||||
|
||||
try:
|
||||
rrddata = self.proxmox.nodes(node).qemu(vmid).rrddata.get(timeframe=timeframe)
|
||||
return {'data': rrddata, 'timeframe': timeframe}
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения RRD данных VM {vmid} на {node}: {e}")
|
||||
return {}
|
||||
|
||||
def get_vm_vnc(self, node: str, vmid: int) -> Dict:
|
||||
"""
|
||||
Получить данные для VNC подключения к VM
|
||||
|
||||
Args:
|
||||
node: Имя ноды
|
||||
vmid: ID виртуальной машины
|
||||
|
||||
Returns:
|
||||
Dict с данными VNC (port, ticket, cert, upid)
|
||||
"""
|
||||
if not self.proxmox:
|
||||
return {}
|
||||
|
||||
try:
|
||||
# Создать VNC прокси сессию
|
||||
vnc_data = self.proxmox.nodes(node).qemu(vmid).vncproxy.post(websocket=1)
|
||||
return vnc_data
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения VNC для VM {vmid} на {node}: {e}")
|
||||
return {}
|
||||
|
||||
def resize_vm_disk(self, node: str, vmid: int, disk: str, size: str) -> bool:
|
||||
"""
|
||||
Изменить размер диска VM
|
||||
|
||||
Args:
|
||||
node: Имя ноды
|
||||
vmid: ID виртуальной машины
|
||||
disk: Имя диска (scsi0, virtio0 и т.д.)
|
||||
size: Новый размер (+10G, 50G)
|
||||
|
||||
Returns:
|
||||
True при успехе
|
||||
"""
|
||||
if not self.proxmox:
|
||||
return False
|
||||
|
||||
try:
|
||||
resize_upid = self.proxmox.nodes(node).qemu(vmid).resize.put(disk=disk, size=size)
|
||||
# Wait for resize task to complete to avoid lock conflicts
|
||||
if isinstance(resize_upid, str):
|
||||
self.wait_for_task(node, resize_upid, timeout=120)
|
||||
logger.info(f"Размер диска {disk} VM {vmid} изменен на {size}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка изменения размера диска VM {vmid}: {e}")
|
||||
return False
|
||||
|
||||
Reference in New Issue
Block a user