feat: add firewall, pools, access, disks, node admin and VM hardware management
All checks were successful
Backend Tests / pytest (Python 3.12) (push) Successful in 1m0s
Deploy to Production / Trigger production update (push) Successful in 7s

Backend: new proxmox mixins and API routes for firewall, pools, access
control, node admin and disks; extend storage and VM endpoints.

Frontend: node managers (firewall, pool, access, disk, node admin), VM
firewall and hardware tabs, PCI/USB device dialogs, related hooks and
locale strings.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
markmorado
2026-07-04 00:02:15 +05:00
parent c12fbf13e5
commit 5c8baefb3f
37 changed files with 6627 additions and 10 deletions

View File

@@ -0,0 +1,110 @@
from typing import List, Dict, Optional
from loguru import logger
class AccessMixin:
"""
PVE Access: realms (домены аутентификации, /access/domains) и
API-токены пользователей (/access/users/{userid}/token).
Геттеры возвращают данные как есть, мутации — {"success": bool, ...}.
"""
# ---------- Realms (auth domains) ----------
def get_realms(self) -> List[Dict]:
"""Список realms: [{realm, type, comment, tfa?}]."""
if not self.proxmox:
return []
try:
return list(self.proxmox.access.domains.get() or [])
except Exception as e:
logger.error(f"Error getting realms: {e}")
return []
def create_realm(self, realm: str, realm_type: str, **kwargs) -> Dict:
if not self.proxmox:
return {"success": False, "error": "Not connected"}
try:
self.proxmox.access.domains.post(realm=realm, type=realm_type, **kwargs)
return {"success": True, "realm": realm}
except Exception as e:
logger.error(f"Error creating realm {realm}: {e}")
return {"success": False, "error": str(e)}
def update_realm(self, realm: str, **kwargs) -> Dict:
if not self.proxmox:
return {"success": False, "error": "Not connected"}
try:
self.proxmox.access.domains(realm).put(**kwargs)
return {"success": True}
except Exception as e:
logger.error(f"Error updating realm {realm}: {e}")
return {"success": False, "error": str(e)}
def delete_realm(self, realm: str) -> Dict:
if not self.proxmox:
return {"success": False, "error": "Not connected"}
try:
self.proxmox.access.domains(realm).delete()
return {"success": True}
except Exception as e:
logger.error(f"Error deleting realm {realm}: {e}")
return {"success": False, "error": str(e)}
# ---------- Users & API tokens ----------
def get_access_users(self) -> List[Dict]:
"""Список пользователей PVE: [{userid, enable, comment, ...}]."""
if not self.proxmox:
return []
try:
return list(self.proxmox.access.users.get() or [])
except Exception as e:
logger.error(f"Error getting access users: {e}")
return []
def get_user_tokens(self, userid: str) -> List[Dict]:
"""Токены пользователя: [{tokenid, comment, expire, privsep}]."""
if not self.proxmox:
return []
try:
return list(self.proxmox.access.users(userid).token.get() or [])
except Exception as e:
logger.error(f"Error getting tokens for {userid}: {e}")
return []
def create_user_token(self, userid: str, tokenid: str,
comment: Optional[str] = None, privsep: bool = True,
expire: Optional[int] = None) -> Dict:
"""
Создать API-токен. Возвращает секрет (value) — показывается один раз.
"""
if not self.proxmox:
return {"success": False, "error": "Not connected"}
try:
params: Dict = {"privsep": 1 if privsep else 0}
if comment:
params["comment"] = comment
if expire:
params["expire"] = expire
result = self.proxmox.access.users(userid).token(tokenid).post(**params)
data = dict(result or {})
return {
"success": True,
"value": data.get("value"),
"full_tokenid": data.get("full-tokenid") or f"{userid}!{tokenid}",
"info": data.get("info"),
}
except Exception as e:
logger.error(f"Error creating token {tokenid} for {userid}: {e}")
return {"success": False, "error": str(e)}
def delete_user_token(self, userid: str, tokenid: str) -> Dict:
if not self.proxmox:
return {"success": False, "error": "Not connected"}
try:
self.proxmox.access.users(userid).token(tokenid).delete()
return {"success": True}
except Exception as e:
logger.error(f"Error deleting token {tokenid} for {userid}: {e}")
return {"success": False, "error": str(e)}

View File

@@ -0,0 +1,119 @@
from typing import List, Dict, Optional
from loguru import logger
class DisksMixin:
"""
Физические диски ноды, SMART и ZFS-пулы (/nodes/{node}/disks/*).
Геттеры возвращают данные как есть, мутации — {"success": bool, "upid"?, "error"?}.
"""
# ---------- Physical disks ----------
def get_node_disks(self, node: str) -> List[Dict]:
"""
Список физических дисков ноды: devpath, size, model, serial, type
(ssd/hdd/nvme), health (SMART), wearout, used, vendor и т.д.
"""
if not self.proxmox:
return []
try:
return list(self.proxmox.nodes(node).disks.list.get() or [])
except Exception as e:
logger.error(f"Error getting disks for node {node}: {e}")
return []
def get_disk_smart(self, node: str, disk: str) -> Dict:
"""SMART-данные диска (health + attributes)."""
if not self.proxmox:
return {}
try:
return dict(self.proxmox.nodes(node).disks.smart.get(disk=disk) or {})
except Exception as e:
logger.error(f"Error getting SMART for {disk} on {node}: {e}")
return {"error": str(e)}
def wipe_disk(self, node: str, disk: str) -> Dict:
"""Очистить диск (wipe). ВНИМАНИЕ: уничтожает данные."""
if not self.proxmox:
return {"success": False, "error": "Not connected"}
try:
upid = self.proxmox.nodes(node).disks.wipedisk.put(disk=disk)
return {"success": True, "upid": upid}
except Exception as e:
logger.error(f"Error wiping disk {disk} on {node}: {e}")
return {"success": False, "error": str(e)}
# ---------- ZFS pools ----------
def get_zfs_pools(self, node: str) -> List[Dict]:
"""Список ZFS-пулов ноды: name, size, alloc, free, frag, dedup, health."""
if not self.proxmox:
return []
try:
return list(self.proxmox.nodes(node).disks.zfs.get() or [])
except Exception as e:
logger.error(f"Error getting ZFS pools for node {node}: {e}")
return []
def get_zfs_pool(self, node: str, name: str) -> Dict:
"""Детали ZFS-пула, включая дерево устройств (children)."""
if not self.proxmox:
return {}
try:
return dict(self.proxmox.nodes(node).disks.zfs(name).get() or {})
except Exception as e:
logger.error(f"Error getting ZFS pool {name} on {node}: {e}")
return {}
def create_zfs_pool(self, node: str, name: str, devices: str,
raidlevel: str = "single", ashift: int = 12,
compression: str = "on", add_storage: bool = True) -> Dict:
"""
Создать ZFS-пул из устройств.
Args:
devices: список devpath через запятую ("/dev/sdb,/dev/sdc")
raidlevel: single | mirror | raid10 | raidz | raidz2 | raidz3
ashift: 9..16 (обычно 12 для 4K-секторов)
compression: on | off | lz4 | zstd | gzip
add_storage: сразу зарегистрировать пул как storage в PVE
"""
if not self.proxmox:
return {"success": False, "error": "Not connected"}
try:
upid = self.proxmox.nodes(node).disks.zfs.post(
name=name,
devices=devices,
raidlevel=raidlevel,
ashift=ashift,
compression=compression,
add_storage=1 if add_storage else 0,
)
return {"success": True, "upid": upid}
except Exception as e:
logger.error(f"Error creating ZFS pool {name} on {node}: {e}")
return {"success": False, "error": str(e)}
def destroy_zfs_pool(self, node: str, name: str,
cleanup_config: bool = True, cleanup_disks: bool = False) -> Dict:
"""
Уничтожить ZFS-пул.
Args:
cleanup_config: убрать связанную запись storage из PVE
cleanup_disks: затереть диски (wipe) после уничтожения пула
"""
if not self.proxmox:
return {"success": False, "error": "Not connected"}
try:
params = {}
if cleanup_config:
params["cleanup-config"] = 1
if cleanup_disks:
params["cleanup-disks"] = 1
upid = self.proxmox.nodes(node).disks.zfs(name).delete(**params)
return {"success": True, "upid": upid}
except Exception as e:
logger.error(f"Error destroying ZFS pool {name} on {node}: {e}")
return {"success": False, "error": str(e)}

View File

@@ -0,0 +1,436 @@
from typing import List, Dict, Optional
from loguru import logger
class FirewallMixin:
"""
Datacenter-level Proxmox firewall: options, rules, security groups,
IP sets and aliases. Все методы работают с /cluster/firewall/*.
Геттеры возвращают списки/словари как есть, мутации — dict
{"success": bool, "error"?: str}, по образцу SDN-миксина.
"""
# ---------- Options ----------
def get_cluster_firewall_options(self) -> Dict:
"""Опции датацентр-фаервола (enable, policy_in/out, log_ratelimit, …)."""
if not self.proxmox:
return {}
try:
return dict(self.proxmox.cluster.firewall.options.get() or {})
except Exception as e:
logger.error(f"Error getting cluster firewall options: {e}")
return {}
def update_cluster_firewall_options(self, **opts) -> Dict:
"""Обновить опции датацентр-фаервола."""
if not self.proxmox:
return {"success": False, "error": "Not connected"}
try:
self.proxmox.cluster.firewall.options.put(**opts)
return {"success": True}
except Exception as e:
logger.error(f"Error updating cluster firewall options: {e}")
return {"success": False, "error": str(e)}
# ---------- Datacenter rules ----------
def get_cluster_firewall_rules(self) -> List[Dict]:
if not self.proxmox:
return []
try:
return list(self.proxmox.cluster.firewall.rules.get() or [])
except Exception as e:
logger.error(f"Error getting cluster firewall rules: {e}")
return []
def create_cluster_firewall_rule(self, **rule) -> Dict:
if not self.proxmox:
return {"success": False, "error": "Not connected"}
try:
self.proxmox.cluster.firewall.rules.post(**rule)
return {"success": True}
except Exception as e:
logger.error(f"Error creating cluster firewall rule: {e}")
return {"success": False, "error": str(e)}
def update_cluster_firewall_rule(self, pos: int, **rule) -> Dict:
if not self.proxmox:
return {"success": False, "error": "Not connected"}
try:
self.proxmox.cluster.firewall.rules(pos).put(**rule)
return {"success": True}
except Exception as e:
logger.error(f"Error updating cluster firewall rule {pos}: {e}")
return {"success": False, "error": str(e)}
def delete_cluster_firewall_rule(self, pos: int) -> Dict:
if not self.proxmox:
return {"success": False, "error": "Not connected"}
try:
self.proxmox.cluster.firewall.rules(pos).delete()
return {"success": True}
except Exception as e:
logger.error(f"Error deleting cluster firewall rule {pos}: {e}")
return {"success": False, "error": str(e)}
# ---------- Security groups ----------
def get_firewall_groups(self) -> List[Dict]:
"""Список security groups: [{group, comment, digest}]."""
if not self.proxmox:
return []
try:
return list(self.proxmox.cluster.firewall.groups.get() or [])
except Exception as e:
logger.error(f"Error getting firewall groups: {e}")
return []
def create_firewall_group(self, group: str, comment: Optional[str] = None) -> Dict:
if not self.proxmox:
return {"success": False, "error": "Not connected"}
try:
params = {"group": group}
if comment:
params["comment"] = comment
self.proxmox.cluster.firewall.groups.post(**params)
return {"success": True, "group": group}
except Exception as e:
logger.error(f"Error creating firewall group {group}: {e}")
return {"success": False, "error": str(e)}
def delete_firewall_group(self, group: str) -> Dict:
"""Удалить security group целиком (должна быть пустой в PVE)."""
if not self.proxmox:
return {"success": False, "error": "Not connected"}
try:
self.proxmox.cluster.firewall.groups(group).delete()
return {"success": True}
except Exception as e:
logger.error(f"Error deleting firewall group {group}: {e}")
return {"success": False, "error": str(e)}
def get_firewall_group_rules(self, group: str) -> List[Dict]:
if not self.proxmox:
return []
try:
return list(self.proxmox.cluster.firewall.groups(group).get() or [])
except Exception as e:
logger.error(f"Error getting rules for firewall group {group}: {e}")
return []
def create_firewall_group_rule(self, group: str, **rule) -> Dict:
if not self.proxmox:
return {"success": False, "error": "Not connected"}
try:
self.proxmox.cluster.firewall.groups(group).post(**rule)
return {"success": True}
except Exception as e:
logger.error(f"Error creating rule in firewall group {group}: {e}")
return {"success": False, "error": str(e)}
def update_firewall_group_rule(self, group: str, pos: int, **rule) -> Dict:
if not self.proxmox:
return {"success": False, "error": "Not connected"}
try:
self.proxmox.cluster.firewall.groups(group)(pos).put(**rule)
return {"success": True}
except Exception as e:
logger.error(f"Error updating rule {pos} in group {group}: {e}")
return {"success": False, "error": str(e)}
def delete_firewall_group_rule(self, group: str, pos: int) -> Dict:
if not self.proxmox:
return {"success": False, "error": "Not connected"}
try:
self.proxmox.cluster.firewall.groups(group)(pos).delete()
return {"success": True}
except Exception as e:
logger.error(f"Error deleting rule {pos} in group {group}: {e}")
return {"success": False, "error": str(e)}
# ---------- IP sets ----------
def get_firewall_ipsets(self) -> List[Dict]:
"""Список IP set'ов: [{name, comment, digest}]."""
if not self.proxmox:
return []
try:
return list(self.proxmox.cluster.firewall.ipset.get() or [])
except Exception as e:
logger.error(f"Error getting firewall ipsets: {e}")
return []
def create_firewall_ipset(self, name: str, comment: Optional[str] = None) -> Dict:
if not self.proxmox:
return {"success": False, "error": "Not connected"}
try:
params = {"name": name}
if comment:
params["comment"] = comment
self.proxmox.cluster.firewall.ipset.post(**params)
return {"success": True, "name": name}
except Exception as e:
logger.error(f"Error creating firewall ipset {name}: {e}")
return {"success": False, "error": str(e)}
def delete_firewall_ipset(self, name: str) -> Dict:
"""Удалить IP set целиком (должен быть пустым в PVE)."""
if not self.proxmox:
return {"success": False, "error": "Not connected"}
try:
self.proxmox.cluster.firewall.ipset(name).delete()
return {"success": True}
except Exception as e:
logger.error(f"Error deleting firewall ipset {name}: {e}")
return {"success": False, "error": str(e)}
def get_firewall_ipset_entries(self, name: str) -> List[Dict]:
if not self.proxmox:
return []
try:
return list(self.proxmox.cluster.firewall.ipset(name).get() or [])
except Exception as e:
logger.error(f"Error getting entries for ipset {name}: {e}")
return []
def add_firewall_ipset_entry(self, name: str, cidr: str,
comment: Optional[str] = None,
nomatch: bool = False) -> Dict:
if not self.proxmox:
return {"success": False, "error": "Not connected"}
try:
params = {"cidr": cidr}
if comment:
params["comment"] = comment
if nomatch:
params["nomatch"] = 1
self.proxmox.cluster.firewall.ipset(name).post(**params)
return {"success": True}
except Exception as e:
logger.error(f"Error adding entry {cidr} to ipset {name}: {e}")
return {"success": False, "error": str(e)}
def delete_firewall_ipset_entry(self, name: str, cidr: str) -> Dict:
if not self.proxmox:
return {"success": False, "error": "Not connected"}
try:
self.proxmox.cluster.firewall.ipset(name)(cidr).delete()
return {"success": True}
except Exception as e:
logger.error(f"Error deleting entry {cidr} from ipset {name}: {e}")
return {"success": False, "error": str(e)}
# ---------- Aliases ----------
def get_firewall_aliases(self) -> List[Dict]:
"""Список алиасов: [{name, cidr, comment, ipversion}]."""
if not self.proxmox:
return []
try:
return list(self.proxmox.cluster.firewall.aliases.get() or [])
except Exception as e:
logger.error(f"Error getting firewall aliases: {e}")
return []
def create_firewall_alias(self, name: str, cidr: str,
comment: Optional[str] = None) -> Dict:
if not self.proxmox:
return {"success": False, "error": "Not connected"}
try:
params = {"name": name, "cidr": cidr}
if comment:
params["comment"] = comment
self.proxmox.cluster.firewall.aliases.post(**params)
return {"success": True, "name": name}
except Exception as e:
logger.error(f"Error creating firewall alias {name}: {e}")
return {"success": False, "error": str(e)}
def update_firewall_alias(self, name: str, cidr: str,
comment: Optional[str] = None,
rename: Optional[str] = None) -> Dict:
if not self.proxmox:
return {"success": False, "error": "Not connected"}
try:
params = {"cidr": cidr}
if comment is not None:
params["comment"] = comment
if rename:
params["rename"] = rename
self.proxmox.cluster.firewall.aliases(name).put(**params)
return {"success": True}
except Exception as e:
logger.error(f"Error updating firewall alias {name}: {e}")
return {"success": False, "error": str(e)}
def delete_firewall_alias(self, name: str) -> Dict:
if not self.proxmox:
return {"success": False, "error": "Not connected"}
try:
self.proxmox.cluster.firewall.aliases(name).delete()
return {"success": True}
except Exception as e:
logger.error(f"Error deleting firewall alias {name}: {e}")
return {"success": False, "error": str(e)}
# ---------- Node-level: options ----------
def get_node_firewall_options(self, node: str) -> Dict:
"""Опции фаервола ноды (enable, log_level_in/out, nosmurfs, …)."""
if not self.proxmox:
return {}
try:
return dict(self.proxmox.nodes(node).firewall.options.get() or {})
except Exception as e:
logger.error(f"Error getting node {node} firewall options: {e}")
return {}
def update_node_firewall_options(self, node: str, **opts) -> Dict:
if not self.proxmox:
return {"success": False, "error": "Not connected"}
try:
self.proxmox.nodes(node).firewall.options.put(**opts)
return {"success": True}
except Exception as e:
logger.error(f"Error updating node {node} firewall options: {e}")
return {"success": False, "error": str(e)}
# ---------- Node-level: rules ----------
def get_node_firewall_rules(self, node: str) -> List[Dict]:
if not self.proxmox:
return []
try:
return list(self.proxmox.nodes(node).firewall.rules.get() or [])
except Exception as e:
logger.error(f"Error getting node {node} firewall rules: {e}")
return []
def create_node_firewall_rule(self, node: str, **rule) -> Dict:
if not self.proxmox:
return {"success": False, "error": "Not connected"}
try:
self.proxmox.nodes(node).firewall.rules.post(**rule)
return {"success": True}
except Exception as e:
logger.error(f"Error creating node {node} firewall rule: {e}")
return {"success": False, "error": str(e)}
def update_node_firewall_rule(self, node: str, pos: int, **rule) -> Dict:
if not self.proxmox:
return {"success": False, "error": "Not connected"}
try:
self.proxmox.nodes(node).firewall.rules(pos).put(**rule)
return {"success": True}
except Exception as e:
logger.error(f"Error updating node {node} firewall rule {pos}: {e}")
return {"success": False, "error": str(e)}
def delete_node_firewall_rule(self, node: str, pos: int) -> Dict:
if not self.proxmox:
return {"success": False, "error": "Not connected"}
try:
self.proxmox.nodes(node).firewall.rules(pos).delete()
return {"success": True}
except Exception as e:
logger.error(f"Error deleting node {node} firewall rule {pos}: {e}")
return {"success": False, "error": str(e)}
def get_node_firewall_log(self, node: str, limit: int = 100, start: int = 0) -> List[Dict]:
"""Строки firewall-лога ноды: [{n, t}]."""
if not self.proxmox:
return []
try:
return list(self.proxmox.nodes(node).firewall.log.get(limit=limit, start=start) or [])
except Exception as e:
logger.error(f"Error getting node {node} firewall log: {e}")
return []
# ---------- Guest-level (VM/LXC) ----------
def _guest_fw(self, node: str, vm_type: str, vmid: int):
"""Ресурс /nodes/{node}/{qemu|lxc}/{vmid}/firewall для proxmoxer."""
kind = "qemu" if vm_type in ("vm", "qemu") else "lxc"
return self.proxmox.nodes(node)(kind)(vmid).firewall
def get_guest_firewall_options(self, node: str, vm_type: str, vmid: int) -> Dict:
"""Опции фаервола VM/LXC (enable, dhcp, macfilter, ipfilter, policy_in/out, …)."""
if not self.proxmox:
return {}
try:
return dict(self._guest_fw(node, vm_type, vmid).options.get() or {})
except Exception as e:
logger.error(f"Error getting guest {vmid} firewall options: {e}")
return {}
def update_guest_firewall_options(self, node: str, vm_type: str, vmid: int, **opts) -> Dict:
if not self.proxmox:
return {"success": False, "error": "Not connected"}
try:
self._guest_fw(node, vm_type, vmid).options.put(**opts)
return {"success": True}
except Exception as e:
logger.error(f"Error updating guest {vmid} firewall options: {e}")
return {"success": False, "error": str(e)}
def get_guest_firewall_rules(self, node: str, vm_type: str, vmid: int) -> List[Dict]:
if not self.proxmox:
return []
try:
return list(self._guest_fw(node, vm_type, vmid).rules.get() or [])
except Exception as e:
logger.error(f"Error getting guest {vmid} firewall rules: {e}")
return []
def create_guest_firewall_rule(self, node: str, vm_type: str, vmid: int, **rule) -> Dict:
if not self.proxmox:
return {"success": False, "error": "Not connected"}
try:
self._guest_fw(node, vm_type, vmid).rules.post(**rule)
return {"success": True}
except Exception as e:
logger.error(f"Error creating guest {vmid} firewall rule: {e}")
return {"success": False, "error": str(e)}
def update_guest_firewall_rule(self, node: str, vm_type: str, vmid: int, pos: int, **rule) -> Dict:
if not self.proxmox:
return {"success": False, "error": "Not connected"}
try:
self._guest_fw(node, vm_type, vmid).rules(pos).put(**rule)
return {"success": True}
except Exception as e:
logger.error(f"Error updating guest {vmid} firewall rule {pos}: {e}")
return {"success": False, "error": str(e)}
def delete_guest_firewall_rule(self, node: str, vm_type: str, vmid: int, pos: int) -> Dict:
if not self.proxmox:
return {"success": False, "error": "Not connected"}
try:
self._guest_fw(node, vm_type, vmid).rules(pos).delete()
return {"success": True}
except Exception as e:
logger.error(f"Error deleting guest {vmid} firewall rule {pos}: {e}")
return {"success": False, "error": str(e)}
def get_guest_firewall_log(self, node: str, vm_type: str, vmid: int,
limit: int = 100, start: int = 0) -> List[Dict]:
if not self.proxmox:
return []
try:
return list(self._guest_fw(node, vm_type, vmid).log.get(limit=limit, start=start) or [])
except Exception as e:
logger.error(f"Error getting guest {vmid} firewall log: {e}")
return []
# ---------- Macros (read-only, для подсказок при создании правил) ----------
def get_firewall_macros(self) -> List[Dict]:
if not self.proxmox:
return []
try:
return list(self.proxmox.cluster.firewall.macros.get() or [])
except Exception as e:
logger.error(f"Error getting firewall macros: {e}")
return []

View File

@@ -0,0 +1,97 @@
from typing import List, Dict
from loguru import logger
class NodeAdminMixin:
"""
Администрирование ноды: systemd-сервисы (/nodes/{node}/services)
и APT (обновления и репозитории, /nodes/{node}/apt).
"""
# ---------- Services ----------
def get_node_services(self, node: str) -> List[Dict]:
"""Список сервисов ноды: [{service, name, desc, state, active-state}]."""
if not self.proxmox:
return []
try:
return list(self.proxmox.nodes(node).services.get() or [])
except Exception as e:
logger.error(f"Error getting services for {node}: {e}")
return []
def node_service_action(self, node: str, service: str, action: str) -> Dict:
"""
Управление сервисом: start | stop | restart | reload.
Возвращает UPID задачи.
"""
if action not in ("start", "stop", "restart", "reload"):
return {"success": False, "error": "invalid action"}
if not self.proxmox:
return {"success": False, "error": "Not connected"}
try:
endpoint = getattr(self.proxmox.nodes(node).services(service), action)
upid = endpoint.post()
return {"success": True, "upid": upid}
except Exception as e:
logger.error(f"Error {action} service {service} on {node}: {e}")
return {"success": False, "error": str(e)}
# ---------- APT: updates ----------
def get_apt_updates(self, node: str) -> List[Dict]:
"""Доступные обновления пакетов: [{Package, Version, OldVersion, Title}]."""
if not self.proxmox:
return []
try:
return list(self.proxmox.nodes(node).apt.update.get() or [])
except Exception as e:
logger.error(f"Error getting APT updates for {node}: {e}")
return []
def apt_refresh(self, node: str) -> Dict:
"""Обновить списки пакетов (apt-get update). Возвращает UPID."""
if not self.proxmox:
return {"success": False, "error": "Not connected"}
try:
upid = self.proxmox.nodes(node).apt.update.post()
return {"success": True, "upid": upid}
except Exception as e:
logger.error(f"Error refreshing APT on {node}: {e}")
return {"success": False, "error": str(e)}
# ---------- APT: repositories ----------
def get_apt_repositories(self, node: str) -> Dict:
"""Информация о репозиториях APT (files, standard-repos, errors)."""
if not self.proxmox:
return {}
try:
return dict(self.proxmox.nodes(node).apt.repositories.get() or {})
except Exception as e:
logger.error(f"Error getting APT repositories for {node}: {e}")
return {}
def set_apt_repository(self, node: str, path: str, index: int, enabled: bool) -> Dict:
"""Включить/выключить конкретный репозиторий (по файлу и индексу)."""
if not self.proxmox:
return {"success": False, "error": "Not connected"}
try:
self.proxmox.nodes(node).apt.repositories.post(
path=path, index=index, enabled=1 if enabled else 0,
)
return {"success": True}
except Exception as e:
logger.error(f"Error toggling APT repo {path}#{index} on {node}: {e}")
return {"success": False, "error": str(e)}
def add_apt_standard_repository(self, node: str, handle: str) -> Dict:
"""Добавить стандартный репозиторий PVE (handle из standard-repos)."""
if not self.proxmox:
return {"success": False, "error": "Not connected"}
try:
self.proxmox.nodes(node).apt.repositories.put(handle=handle)
return {"success": True}
except Exception as e:
logger.error(f"Error adding standard repo {handle} on {node}: {e}")
return {"success": False, "error": str(e)}

View File

@@ -0,0 +1,82 @@
from typing import List, Dict, Optional
from loguru import logger
class PoolsMixin:
"""
Resource pools (/pools) — группировка VM/LXC и хранилищ.
Геттеры возвращают данные как есть, мутации — {"success": bool, "error"?: str}.
"""
def get_pools(self) -> List[Dict]:
"""Список пулов: [{poolid, comment}]."""
if not self.proxmox:
return []
try:
return list(self.proxmox.pools.get() or [])
except Exception as e:
logger.error(f"Error getting pools: {e}")
return []
def get_pool(self, poolid: str) -> Dict:
"""Детали пула, включая members (VM/LXC/storage)."""
if not self.proxmox:
return {}
try:
return dict(self.proxmox.pools(poolid).get() or {})
except Exception as e:
logger.error(f"Error getting pool {poolid}: {e}")
return {}
def create_pool(self, poolid: str, comment: Optional[str] = None) -> Dict:
if not self.proxmox:
return {"success": False, "error": "Not connected"}
try:
params = {"poolid": poolid}
if comment:
params["comment"] = comment
self.proxmox.pools.post(**params)
return {"success": True, "poolid": poolid}
except Exception as e:
logger.error(f"Error creating pool {poolid}: {e}")
return {"success": False, "error": str(e)}
def update_pool(self, poolid: str, vms: Optional[str] = None,
storage: Optional[str] = None, comment: Optional[str] = None,
delete: bool = False) -> Dict:
"""
Добавить/убрать участников пула.
Args:
vms: список vmid через запятую ("100,101")
storage: список storage id через запятую
delete: True — убрать указанных участников, иначе добавить
"""
if not self.proxmox:
return {"success": False, "error": "Not connected"}
try:
params: Dict = {}
if vms:
params["vms"] = vms
if storage:
params["storage"] = storage
if comment is not None:
params["comment"] = comment
if delete:
params["delete"] = 1
self.proxmox.pools(poolid).put(**params)
return {"success": True}
except Exception as e:
logger.error(f"Error updating pool {poolid}: {e}")
return {"success": False, "error": str(e)}
def delete_pool(self, poolid: str) -> Dict:
"""Удалить пул (в PVE должен быть пустым)."""
if not self.proxmox:
return {"success": False, "error": "Not connected"}
try:
self.proxmox.pools(poolid).delete()
return {"success": True}
except Exception as e:
logger.error(f"Error deleting pool {poolid}: {e}")
return {"success": False, "error": str(e)}

View File

@@ -215,3 +215,43 @@ class StorageMixin:
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 []