Refactor backend: Split models and proxmox_client into modules, add alembic
Some checks failed
Backend Tests / pytest (Python 3.12) (push) Failing after 2m27s

This commit is contained in:
2026-06-13 03:53:27 +05:00
parent 8a879f36fa
commit bfd92102cf
54 changed files with 6026 additions and 5137 deletions

View File

@@ -50,7 +50,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
COPY --from=builder /root/.local /root/.local
COPY app ./app
COPY migrations ./migrations
COPY alembic ./alembic
COPY alembic.ini .
COPY init_admin.py .
HEALTHCHECK --interval=5s --timeout=3s --start-period=5s --retries=5 \

55
backend/alembic.ini Normal file
View File

@@ -0,0 +1,55 @@
# A generic, single database configuration.
[alembic]
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# file_template = %%(rev)s_%%(slug)s
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version location specification; This defaults
# to alembic/versions.
script_location = alembic
# sqlalchemy.url = postgresql://user:pass@localhost/dbname
# The URL will be injected dynamically in env.py from settings.
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

81
backend/alembic/env.py Normal file
View File

@@ -0,0 +1,81 @@
import os
import sys
from logging.config import fileConfig
from sqlalchemy import engine_from_config
from sqlalchemy import pool
from alembic import context
# Добавляем корень проекта (папку backend) в sys.path
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
from app.config import settings
from app.models import Base
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# add your model's MetaData object here
# for 'autogenerate' support
target_metadata = Base.metadata
# Set sqlalchemy.url dynamically
config.set_main_option("sqlalchemy.url", settings.SQLALCHEMY_DATABASE_URI)
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(
connection=connection, target_metadata=target_metadata
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@@ -0,0 +1,26 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

View File

@@ -0,0 +1 @@
# This directory holds alembic migrations.

View File

@@ -12,7 +12,7 @@ from sqlalchemy.orm import Session
from ..db import get_db
from ..models import IPAMNetwork, IPAMPool, IPAMAllocation, IPAMHistory, ProxmoxServer, User, VMInstance
from ..proxmox_client import ProxmoxClient
from ..proxmox import ProxmoxClient
from ..schemas import (
IPAMNetworkCreate, IPAMNetworkUpdate, IPAMNetworkResponse, IPAMNetworkStats,
IPAMPoolCreate, IPAMPoolUpdate, IPAMPoolResponse,

View File

@@ -12,7 +12,7 @@ import websockets
from ...db import get_db
from ...models import ProxmoxServer, VMInstance, User, IPAMAllocation, IPAMNetwork, VMSnapshotArchive
from ...schemas import ProxmoxServerCreate, ProxmoxServerUpdate, ProxmoxServerResponse
from ...proxmox_client import ProxmoxClient, get_proxmox_resources
from ...proxmox import ProxmoxClient, get_proxmox_resources
from ...auth import get_current_user, PermissionChecker, require_permission, check_permission
from ...logging_service import LoggingService
from ...ipam_service import IPAMService
@@ -72,15 +72,19 @@ def require_vm_access(db: Session, current_user: User, server_id: int, vmid: int
def _get_proxmox_client(server: ProxmoxServer) -> ProxmoxClient:
"""Build a ProxmoxClient from a ProxmoxServer model (password or token auth)."""
if server.use_password:
host = server.ip_address or getattr(server, 'hostname', '')
if hasattr(server, 'port') and server.port and server.port != 8006:
host = f"{host}:{server.port}"
if getattr(server, 'use_password', False) and getattr(server, 'password', ''):
return ProxmoxClient(
host=server.ip_address,
host=host,
user=server.api_user,
password=server.password,
verify_ssl=server.verify_ssl
)
return ProxmoxClient(
host=server.ip_address,
host=host,
user=server.api_user,
token_name=server.api_token_name,
token_value=server.api_token_value,
@@ -110,7 +114,7 @@ def get_next_vmid(db: Session, server_id: int) -> int:
# Fallback: get from DB if Proxmox not available
used_vmids = set(
row[0] for row in db.query(VMInstance.vmid)
.filter(VMInstance.server_id == server_id, VMInstance.deleted_at == None)
.filter(VMInstance.server_id == server_id, VMInstance.deleted_at.is_(None))
.all()
)
@@ -291,7 +295,7 @@ def save_vm_instance(
existing = db.query(VMInstance).filter(
VMInstance.server_id == server_id,
VMInstance.vmid == vmid,
VMInstance.deleted_at == None
VMInstance.deleted_at.is_(None)
).first()
if existing:
@@ -322,7 +326,7 @@ def save_vm_instance(
deleted_existing = db.query(VMInstance).filter(
VMInstance.server_id == server_id,
VMInstance.vmid == vmid,
VMInstance.deleted_at != None
VMInstance.deleted_at.isnot(None)
).first()
if deleted_existing:
@@ -386,7 +390,7 @@ def get_vm_instance(db: Session, server_id: int, vmid: int) -> VMInstance:
return db.query(VMInstance).filter(
VMInstance.server_id == server_id,
VMInstance.vmid == vmid,
VMInstance.deleted_at == None
VMInstance.deleted_at.is_(None)
).first()

View File

@@ -14,7 +14,7 @@ from loguru import logger
from ...db import SessionLocal
from ...models import ProxmoxServer, VMInstance, OSTemplate, DeployTask, User
from ...proxmox_client import ProxmoxClient
from ...proxmox import ProxmoxClient
from ...logging_service import LoggingService
from ._helpers import _get_proxmox_client, get_next_vmid

View File

@@ -18,6 +18,7 @@ from ...models import BackupJob, ProxmoxServer, User
from ...auth import PermissionChecker
from ...logging_service import LoggingService
from ._helpers import _get_proxmox_client
from ...proxmox import _run_in_executor
router = APIRouter()
@@ -63,7 +64,7 @@ async def create_storage(
try:
client = _get_proxmox_client(server)
result = client.create_storage(storage_id, storage_type, **data)
result = await _run_in_executor(client.create_storage, storage_id, storage_type, **data)
if result.get("success"):
LoggingService.log_proxmox_action(
db=db, action="storage_create", resource_type="storage",
@@ -94,7 +95,7 @@ async def update_storage(
data = await request.json()
try:
client = _get_proxmox_client(server)
result = client.update_storage(storage_id, **data)
result = await _run_in_executor(client.update_storage, storage_id, **data)
if result.get("success"):
return JSONResponse(content={"success": True})
raise HTTPException(status_code=400, detail=result.get("error", "Failed"))
@@ -216,7 +217,7 @@ async def delete_backup(
try:
client = _get_proxmox_client(server)
result = client.delete_backup(node, storage, volid)
result = await _run_in_executor(client.delete_backup, node, storage, volid)
if result.get("success"):
LoggingService.log_proxmox_action(
db=db, action="backup_delete", resource_type="backup",
@@ -262,7 +263,8 @@ async def create_backup(
try:
client = _get_proxmox_client(server)
result = client.create_backup(
result = await _run_in_executor(
client.create_backup,
node=node, vmid=int(vmid), storage=storage,
mode=mode, compress=compress, remove=remove,
keep_last=keep_last, notes=notes,
@@ -339,13 +341,15 @@ async def restore_backup(
try:
client = _get_proxmox_client(server)
if vm_type == "lxc":
result = client.restore_lxc(
result = await _run_in_executor(
client.restore_lxc,
node=node, vmid=int(vmid), archive=archive,
storage=storage, new_vmid=int(new_vmid) if new_vmid else None,
start=start,
)
else:
result = client.restore_vm(
result = await _run_in_executor(
client.restore_vm,
node=node, vmid=int(vmid), archive=archive,
storage=storage, new_vmid=int(new_vmid) if new_vmid else None,
start=start, unique=unique,

View File

@@ -15,8 +15,9 @@ from typing import Optional
from ...db import get_db
from ...models import ProxmoxServer, User
from ...auth import PermissionChecker
from ...proxmox_client import ProxmoxClient
from ...proxmox import ProxmoxClient
from ._helpers import _get_proxmox_client
from ...proxmox import _run_in_executor
router = APIRouter()
@@ -136,13 +137,13 @@ async def create_cluster(
client = _get_password_client(server, rootpw)
# Verify server is not already in a cluster on Proxmox side
if client.is_cluster():
if await _run_in_executor(client.is_cluster):
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Нода уже является частью кластера Proxmox. Используйте join для добавления в существующий кластер."
)
result = client.create_cluster(cluster_name=cluster_name, link0=link0_ip)
result = await _run_in_executor(client.create_cluster, cluster_name=cluster_name, link0=link0_ip)
if not result.get("success"):
raise HTTPException(
@@ -316,12 +317,12 @@ async def prepare_join(
server = _get_server_or_404(db, node_server_id)
client = _get_proxmox_client(server)
nodes = client.get_nodes()
nodes = await _run_in_executor(client.get_nodes)
node_name = nodes[0].get("node") if nodes else server.hostname
# Collect all guests
vms = client.get_vms(node=node_name)
containers = client.get_containers(node=node_name)
vms = await _run_in_executor(client.get_vms, node=node_name)
containers = await _run_in_executor(client.get_containers, node=node_name)
all_guests = [
{"vmid": v.get("vmid"), "type": "qemu"} for v in vms
] + [
@@ -344,7 +345,8 @@ async def prepare_join(
upid_map: dict = {} # vmid -> upid
for guest in all_guests:
vmid = guest["vmid"]
result = client.vzdump_guest(
result = await _run_in_executor(
client.vzdump_guest,
node=node_name,
vmid=vmid,
storage=backup_storage,
@@ -367,7 +369,7 @@ async def prepare_join(
finished_vmids = []
for vmid, upid in list(upid_map.items()):
try:
task_status = client.proxmox.nodes(node_name).tasks(upid).status.get()
task_status = await _run_in_executor(client.proxmox.nodes(node_name).tasks(upid).status.get)
if task_status.get("status") == "stopped":
finished_vmids.append(vmid)
if task_status.get("exitstatus") == "OK":
@@ -400,17 +402,17 @@ async def prepare_join(
try:
if guest["type"] == "qemu":
# Stop first if running
qemu_info = client.proxmox.nodes(node_name).qemu(vmid).status.current.get()
qemu_info = await _run_in_executor(client.proxmox.nodes(node_name).qemu(vmid).status.current.get)
if qemu_info.get("status") == "running":
client.proxmox.nodes(node_name).qemu(vmid).status.stop.post()
await _run_in_executor(client.proxmox.nodes(node_name).qemu(vmid).status.stop.post)
await asyncio.sleep(3)
client.proxmox.nodes(node_name).qemu(vmid).delete()
await _run_in_executor(client.proxmox.nodes(node_name).qemu(vmid).delete)
else:
ct_info = client.proxmox.nodes(node_name).lxc(vmid).status.current.get()
ct_info = await _run_in_executor(client.proxmox.nodes(node_name).lxc(vmid).status.current.get)
if ct_info.get("status") == "running":
client.proxmox.nodes(node_name).lxc(vmid).status.stop.post()
await _run_in_executor(client.proxmox.nodes(node_name).lxc(vmid).status.stop.post)
await asyncio.sleep(3)
client.proxmox.nodes(node_name).lxc(vmid).delete()
await _run_in_executor(client.proxmox.nodes(node_name).lxc(vmid).delete)
deleted.append(vmid)
logger.info(f"Deleted guest vmid {vmid} on {server.name} during prepare-join")
@@ -490,11 +492,11 @@ async def join_cluster(
# --- Pre-check: ноды не должно содержать гестов ---
node_client = _get_proxmox_client(node_server)
nodes = node_client.get_nodes()
nodes = await _run_in_executor(node_client.get_nodes)
node_name = nodes[0].get("node") if nodes else node_server.hostname
vms = node_client.get_vms(node=node_name)
containers = node_client.get_containers(node=node_name)
vms = await _run_in_executor(node_client.get_vms, node=node_name)
containers = await _run_in_executor(node_client.get_containers, node=node_name)
if vms or containers:
raise HTTPException(
@@ -513,7 +515,7 @@ async def join_cluster(
# --- Получить join-info с кластерной ноды ---
cluster_client = _get_proxmox_client(cluster_server)
join_info_result = cluster_client.get_cluster_join_info()
join_info_result = await _run_in_executor(cluster_client.get_cluster_join_info)
if not join_info_result.get("success"):
raise HTTPException(
@@ -534,7 +536,8 @@ async def join_cluster(
# --- Выполнить join на присоединяемой ноде (используем пароль из запроса) ---
joining_client = _get_password_client(node_server, rootpw)
result = joining_client.join_cluster(
result = await _run_in_executor(
joining_client.join_cluster,
cluster_host=cluster_server.ip_address,
rootpw=rootpw,
fingerprint=fingerprint,

View File

@@ -20,7 +20,7 @@ from ...db import get_db, SessionLocal
from ...ipam_service import IPAMService
from ...logging_service import LoggingService
from ...models import DeployTask, IPAMNetwork, ProxmoxServer, User, VMInstance
from ...proxmox_client import ProxmoxClient
from ...proxmox import ProxmoxClient
from ...websocket_manager import broadcast_task_update
from ._helpers import _get_proxmox_client, get_next_vmid, save_vm_instance

View File

@@ -7,6 +7,7 @@ from ...db import get_db
from ...models import ProxmoxServer, User, IPAMNetwork
from ...auth import PermissionChecker
from ._helpers import _get_proxmox_client
from ...proxmox import _run_in_executor
router = APIRouter()
@@ -101,7 +102,7 @@ async def create_node_network(
try:
client = _get_proxmox_client(server)
result = client.create_node_network(node, iface_type, **data)
result = await _run_in_executor(client.create_node_network, node, iface_type, **data)
if result.get("success"):
logger.info(
f"User {current_user.username} created {iface_type} interface "
@@ -133,7 +134,7 @@ async def update_node_network(
data = await request.json()
try:
client = _get_proxmox_client(server)
result = client.update_node_network(node, iface, **data)
result = await _run_in_executor(client.update_node_network, node, iface, **data)
if result.get("success"):
logger.info(
f"User {current_user.username} updated interface {iface} "

View File

@@ -9,6 +9,7 @@ from ...models import ProxmoxServer, User, IPAMNetwork
from ...auth import PermissionChecker
from ...logging_service import LoggingService
from ._helpers import _get_proxmox_client
from ...proxmox import _run_in_executor
router = APIRouter()
@@ -92,7 +93,7 @@ async def create_sdn_zone(
# Pass additional options from request
kwargs = {k: v for k, v in data.items() if k not in ['zone', 'type']}
result = client.create_sdn_zone(zone_name, zone_type, **kwargs)
result = await _run_in_executor(client.create_sdn_zone, zone_name, zone_type, **kwargs)
if result.get('success'):
logger.info(f"User {current_user.username} created SDN zone: {zone_name}")
@@ -181,7 +182,7 @@ async def create_sdn_vnet(
try:
client = _get_proxmox_client(server)
result = client.create_sdn_vnet(vnet_name, zone, tag=tag, alias=alias, vlanaware=vlanaware)
result = await _run_in_executor(client.create_sdn_vnet, vnet_name, zone, tag=tag, alias=alias, vlanaware=vlanaware)
if result.get('success'):
logger.info(f"User {current_user.username} created SDN vnet: {vnet_name} in zone {zone}")
@@ -271,7 +272,7 @@ async def create_sdn_subnet(
try:
client = _get_proxmox_client(server)
result = client.create_sdn_subnet(vnet, subnet, gateway=gateway, snat=snat, dnszoneprefix=dnszoneprefix)
result = await _run_in_executor(client.create_sdn_subnet, vnet, subnet, gateway=gateway, snat=snat, dnszoneprefix=dnszoneprefix)
if result.get('success'):
logger.info(f"User {current_user.username} created subnet {subnet} in vnet {vnet}")
@@ -356,7 +357,7 @@ async def update_sdn_zone(
data = await request.json()
try:
client = _get_proxmox_client(server)
result = client.update_sdn_zone(zone, **data)
result = await _run_in_executor(client.update_sdn_zone, zone, **data)
if result.get('success'):
logger.info(f"User {current_user.username} updated SDN zone: {zone}")
return JSONResponse(content=result)
@@ -385,7 +386,7 @@ async def update_sdn_vnet(
data = await request.json()
try:
client = _get_proxmox_client(server)
result = client.update_sdn_vnet(vnet, **data)
result = await _run_in_executor(client.update_sdn_vnet, vnet, **data)
if result.get('success'):
logger.info(f"User {current_user.username} updated SDN vnet: {vnet}")
return JSONResponse(content=result)

View File

@@ -11,8 +11,8 @@ import websockets
from ...db import get_db
from ...models import ProxmoxServer, VMInstance, User, IPAMAllocation, IPAMNetwork, VMSnapshotArchive
from ...schemas import ProxmoxServerCreate, ProxmoxServerUpdate, ProxmoxServerResponse
from ...proxmox_client import ProxmoxClient, get_proxmox_resources
from app.schemas import ProxmoxServerCreate, ProxmoxServerUpdate, ProxmoxServerResponse
from ...proxmox import ProxmoxClient, get_proxmox_resources, _run_in_executor
from ...auth import get_current_user, PermissionChecker, require_permission, check_permission
from ...logging_service import LoggingService
from ...ipam_service import IPAMService
@@ -193,7 +193,8 @@ async def auto_setup_proxmox_server(
# 1. Получить auth ticket
logger.info(f"Attempting to connect to Proxmox at {base_url}")
try:
auth_response = http_requests.post(
auth_response = await _run_in_executor(
http_requests.post,
f"{base_url}/api2/json/access/ticket",
data={"username": api_user, "password": password},
verify=verify_ssl,
@@ -239,7 +240,8 @@ async def auto_setup_proxmox_server(
try:
# Создаем токен через API
token_response = http_requests.post(
token_response = await _run_in_executor(
http_requests.post,
f"{base_url}/api2/json/access/users/{api_user}/token/{token_name}",
headers=headers,
data={"privsep": "0"}, # Полные права как у пользователя
@@ -269,7 +271,8 @@ async def auto_setup_proxmox_server(
try:
# Получаем список нод
nodes_response = http_requests.get(
nodes_response = await _run_in_executor(
http_requests.get,
f"{base_url}/api2/json/nodes",
headers=headers,
cookies={"PVEAuthCookie": ticket},
@@ -283,7 +286,8 @@ async def auto_setup_proxmox_server(
# Получаем IP адреса нод из cluster/status
if len(cluster_nodes) > 1:
cluster_status_response = http_requests.get(
cluster_status_response = await _run_in_executor(
http_requests.get,
f"{base_url}/api2/json/cluster/status",
headers=headers,
cookies={"PVEAuthCookie": ticket},
@@ -493,7 +497,7 @@ def update_proxmox_server(
current_user: User = Depends(PermissionChecker("proxmox.servers.edit"))
):
"""Обновить Proxmox сервер"""
from ...proxmox_client import clear_server_cache
from ...proxmox import clear_server_cache
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
if not server:
@@ -567,7 +571,7 @@ def delete_proxmox_server(
current_user: User = Depends(PermissionChecker("proxmox.servers.delete"))
):
"""Удалить Proxmox сервер и связанные OS Templates"""
from ...proxmox_client import clear_server_cache
from ...proxmox import clear_server_cache
from ...models import OSTemplate, OSTemplateGroup, VMInstance, Notification
from ...workers import monitoring_worker

View File

@@ -12,7 +12,7 @@ import websockets
from ...db import get_db
from ...models import ProxmoxServer, VMInstance, User, IPAMAllocation, IPAMNetwork, VMSnapshotArchive
from ...schemas import ProxmoxServerCreate, ProxmoxServerUpdate, ProxmoxServerResponse
from ...proxmox_client import ProxmoxClient, get_proxmox_resources
from ...proxmox import ProxmoxClient, get_proxmox_resources, _run_in_executor
from ...auth import get_current_user, PermissionChecker, require_permission, check_permission
from ...logging_service import LoggingService
from ...ipam_service import IPAMService
@@ -81,7 +81,7 @@ async def create_vm_snapshot(
try:
client = _get_proxmox_client(server)
result = client.create_vm_snapshot(node, vmid, snapname, description, vmstate)
result = await _run_in_executor(client.create_vm_snapshot, node, vmid, snapname, description, vmstate)
if result.get('success'):
LoggingService.log_proxmox_action(
@@ -257,7 +257,7 @@ async def create_container_snapshot(
try:
client = _get_proxmox_client(server)
result = client.create_container_snapshot(node, vmid, snapname, description)
result = await _run_in_executor(client.create_container_snapshot, node, vmid, snapname, description)
if result.get('success'):
LoggingService.log_proxmox_action(

View File

@@ -12,7 +12,7 @@ import websockets
from ...db import get_db
from ...models import ProxmoxServer, VMInstance, User, IPAMAllocation, IPAMNetwork, VMSnapshotArchive, ProxmoxTask, DeployTask
from ...schemas import ProxmoxServerCreate, ProxmoxServerUpdate, ProxmoxServerResponse
from ...proxmox_client import ProxmoxClient, get_proxmox_resources
from ...proxmox import ProxmoxClient, get_proxmox_resources
from ...auth import get_current_user, PermissionChecker, require_permission, check_permission
from ...logging_service import LoggingService
from ...ipam_service import IPAMService

View File

@@ -12,7 +12,7 @@ import websockets
from ...db import get_db
from ...models import ProxmoxServer, VMInstance, User, IPAMAllocation, IPAMNetwork, VMSnapshotArchive
from ...schemas import ProxmoxServerCreate, ProxmoxServerUpdate, ProxmoxServerResponse
from ...proxmox_client import ProxmoxClient, get_proxmox_resources
from ...proxmox import ProxmoxClient, get_proxmox_resources
from ...auth import get_current_user, PermissionChecker, require_permission, check_permission
from ...logging_service import LoggingService
from ...ipam_service import IPAMService
@@ -22,6 +22,13 @@ from ._helpers import (check_vm_access, require_vm_access, _get_proxmox_client,
router = APIRouter()
import time as time_lib
import threading
_live_metrics_cache = {}
_live_metrics_lock = threading.Lock()
METRICS_TTL = 10 # seconds
from ...models import TaskQueue
from ...services.task_queue_service import TaskQueueService, process_task_queue
from ...api.proxmox.tasks import ProxmoxTaskService
@@ -154,21 +161,39 @@ def get_all_virtual_machines(
cached_vms = query.order_by(VMInstance.name).all()
# ── Live metrics from Proxmox cluster/resources (CPU, RAM, disk, uptime) ──
# One HTTP call per server returns metrics for all VMs/LXC in that cluster.
# One HTTP call per cluster returns metrics for all VMs/LXC.
# Map: (server_id_in_db, vmid) -> live data dict
live_metrics: dict[tuple[int, int], dict] = {}
for server in servers:
try:
client = _get_proxmox_client(server)
if not client.is_connected():
with _live_metrics_lock:
now = time_lib.time()
for server in servers:
cache_key = server.id
if cache_key in _live_metrics_cache and (now - _live_metrics_cache[cache_key]['time']) < METRICS_TTL:
# Use cached metrics
for vmid, res in _live_metrics_cache[cache_key]['data'].items():
live_metrics[(server.id, vmid)] = res
continue
for res in client.get_cluster_resources(type_='vm'):
vmid = res.get('vmid')
if vmid is None:
try:
client = _get_proxmox_client(server)
if not client.is_connected():
continue
live_metrics[(server.id, vmid)] = res
except Exception as e:
logger.debug(f"Could not fetch live metrics from {server.name}: {e}")
server_metrics = {}
for res in client.get_cluster_resources(type_='vm'):
vmid = res.get('vmid')
if vmid is None:
continue
live_metrics[(server.id, vmid)] = res
server_metrics[vmid] = res
_live_metrics_cache[cache_key] = {
'time': now,
'data': server_metrics
}
except Exception as e:
logger.debug(f"Could not fetch live metrics from {server.name}: {e}")
result = []
@@ -738,9 +763,8 @@ async def delete_vm(
client.stop_vm(node, vmid, force=True)
except Exception as _se:
logger.warning(f"stop_vm({vmid}) failed: {_se}")
import time as _t
for _ in range(30):
_t.sleep(0.5)
await asyncio.sleep(0.5)
st = client.get_vm_status(node, vmid)
if not st or not isinstance(st, dict) or st.get('status') != 'running':
break
@@ -1147,9 +1171,8 @@ async def delete_container(
except Exception as _se:
logger.warning(f"stop_container({vmid}) failed: {_se}")
# Wait up to 15s for it to actually stop
import time as _t
for _ in range(30):
_t.sleep(0.5)
await asyncio.sleep(0.5)
st = client.get_container_status(node, vmid)
if not st or not isinstance(st, dict) or st.get('status') != 'running':
break

View File

@@ -21,7 +21,7 @@ from ..schemas import (
OSTemplateCreate, OSTemplateUpdate, OSTemplateResponse, OSTemplateWithGroup,
VMDeployRequest, VMDeployResponse
)
from ..proxmox_client import ProxmoxClient
from ..proxmox import ProxmoxClient
from ..api.proxmox import get_next_vmid, save_vm_instance, get_vm_instance
from ..auth import get_current_user, PermissionChecker
from ..ipam_service import IPAMService

View File

@@ -96,3 +96,12 @@ def utcnow() -> datetime:
with PostgreSQL columns that have timezone=True.
"""
return datetime.now(timezone.utc)
def make_tz_aware(dt: Optional[datetime]) -> Optional[datetime]:
"""Ensure datetime is timezone-aware. Defaults to UTC if naive."""
if dt is None:
return None
if dt.tzinfo is None:
return dt.replace(tzinfo=timezone.utc)
return dt

View File

@@ -114,6 +114,16 @@ async def lifespan(app: FastAPI):
# Setup logging
setup_logging()
# Security checks
if not settings.SECRET_KEY or settings.SECRET_KEY == "your-secret-key-here":
logger.error("CRITICAL: SECRET_KEY is missing or uses the default insecure value!")
logger.error("Please set a secure SECRET_KEY in your .env file.")
raise Exception("Insecure SECRET_KEY")
if not settings.FERNET_KEY:
logger.warning("FERNET_KEY is not set. Sensitive fields (like passwords) will be stored as plaintext.")
logger.warning("To enable encryption, generate a key and set FERNET_KEY in your .env file.")
# Check database connection
if not check_db_connection():
logger.error("Database connection failed!")
@@ -138,14 +148,9 @@ async def lifespan(app: FastAPI):
# Initialize default admin user
try:
import subprocess
import sys
result = subprocess.run([sys.executable, '/app/init_admin.py'],
capture_output=True, text=True, timeout=30)
if result.returncode == 0:
logger.info("Default admin user initialization completed")
else:
logger.error(f"Admin initialization failed: {result.stderr}")
import init_admin
init_admin.create_default_admin()
logger.info("Default admin user initialization completed")
except Exception as e:
logger.error(f"Error running admin initialization: {e}")

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,17 @@
from .base import *
from .auth import Role, user_servers, User, ActiveSession, LoginAttempt, BlockedIP, UserSSHKey
from .proxmox import OSTemplateGroup, OSTemplate, ProxmoxServer, VMInstance, VMSnapshotArchive
from .ipam import IPAMNetwork, IPAMPool, IPAMAllocation, IPAMHistory
from .tasks import TaskQueue, ProxmoxTask, DeployTask
from .settings import PanelSettings, SecuritySetting, Workspace, WorkspaceServer, WorkspaceUser
from .misc import Notification, NotificationPreference, BackupJob, AuditLog
__all__ = [
'Base', 'utcnow',
'Role', 'user_servers', 'User', 'ActiveSession', 'LoginAttempt', 'BlockedIP', 'UserSSHKey',
'OSTemplateGroup', 'OSTemplate', 'ProxmoxServer', 'VMInstance', 'VMSnapshotArchive',
'IPAMNetwork', 'IPAMPool', 'IPAMAllocation', 'IPAMHistory',
'TaskQueue', 'ProxmoxTask', 'DeployTask',
'PanelSettings', 'SecuritySetting', 'Workspace', 'WorkspaceServer', 'WorkspaceUser',
'Notification', 'NotificationPreference', 'BackupJob', 'AuditLog'
]

204
backend/app/models/auth.py Normal file
View File

@@ -0,0 +1,204 @@
from .base import *
# Association table: User <-> ProxmoxServer (assigned servers)
user_servers = Table(
'user_servers',
Base.metadata,
Column('user_id', Integer, ForeignKey('users.id', ondelete='CASCADE'), primary_key=True),
Column('server_id', Integer, ForeignKey('proxmox_servers.id', ondelete='CASCADE'), primary_key=True)
)
class Role(Base):
"""User roles with permissions"""
__tablename__ = "roles"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(50), unique=True, nullable=False, index=True)
display_name = Column(String(100), nullable=False)
description = Column(Text, nullable=True)
permissions = Column(JSON, nullable=False, default={})
is_system = Column(Boolean, default=False, nullable=False) # System roles can't be deleted
is_active = Column(Boolean, default=True, nullable=False)
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
# Relationships
users = relationship("User", back_populates="role")
def has_permission(self, permission: str) -> bool:
"""Check if role has specific permission"""
if not self.permissions:
return False
return self.permissions.get(permission, False)
def __repr__(self):
return f"<Role(id={self.id}, name='{self.name}')>"
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True, index=True)
username = Column(String(50), unique=True, nullable=False, index=True)
email = Column(String(255), unique=True, nullable=False, index=True)
hashed_password = Column(String(255), nullable=False)
full_name = Column(String(100), nullable=True)
is_active = Column(Boolean, default=True, nullable=False)
is_admin = Column(Boolean, default=False, nullable=False) # Legacy, use role instead
# Role-based access
role_id = Column(Integer, ForeignKey("roles.id"), nullable=True, index=True)
role = relationship("Role", back_populates="users", lazy="joined")
# SSH Public Key for VM/LXC deployment
ssh_public_key = Column(Text, nullable=True)
# Security fields
failed_login_attempts = Column(Integer, default=0, nullable=False)
locked_until = Column(DateTime(timezone=True), nullable=True)
last_password_change = Column(DateTime(timezone=True), nullable=True)
require_password_change = Column(Boolean, default=False, nullable=False)
two_factor_enabled = Column(Boolean, default=False, nullable=False)
two_factor_secret = Column(String(100), nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
last_login = Column(DateTime(timezone=True), nullable=True)
# Relationships
sessions = relationship("ActiveSession", back_populates="user", cascade="all, delete-orphan")
assigned_servers = relationship("ProxmoxServer", secondary=user_servers, back_populates="assigned_users", lazy="select")
def has_permission(self, permission: str) -> bool:
"""
Check if user has specific permission.
Uses new RBAC engine with legacy format support.
"""
try:
from ..rbac import PermissionEngine
return PermissionEngine.has_permission(self, permission)
except ImportError:
if self.is_admin:
return True
if self.role:
return self.role.has_permission(permission)
return False
def get_permissions(self) -> set:
"""Get all effective permissions for this user"""
try:
from ..rbac import PermissionEngine
return PermissionEngine.get_user_permissions(self)
except ImportError:
if self.is_admin:
return set() # Admin has all
if self.role and self.role.permissions:
return {k for k, v in self.role.permissions.items() if v}
return set()
def is_locked(self) -> bool:
"""Check if account is locked"""
if self.locked_until and make_tz_aware(self.locked_until) > utcnow():
return True
return False
@property
def role_name(self) -> str:
"""Get role name"""
if self.role:
return self.role.name
return "admin" if self.is_admin else "user"
def __repr__(self):
return f"<User(id={self.id}, username='{self.username}', is_admin={self.is_admin})>"
class ActiveSession(Base):
"""Active user sessions for single-session enforcement"""
__tablename__ = "active_sessions"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
session_token = Column(String(64), unique=True, nullable=False, index=True)
ip_address = Column(String(45), nullable=True)
user_agent = Column(String(500), nullable=True)
device_info = Column(String(200), nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
expires_at = Column(DateTime(timezone=True), nullable=False)
last_activity = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
is_active = Column(Boolean, default=True, nullable=False)
# Relationships
user = relationship("User", back_populates="sessions")
def is_expired(self) -> bool:
"""Check if session is expired"""
from datetime import timezone
now = datetime.now(timezone.utc)
if self.expires_at is None:
return True
return now > make_tz_aware(self.expires_at)
def __repr__(self):
return f"<ActiveSession(id={self.id}, user_id={self.user_id}, active={self.is_active})>"
class LoginAttempt(Base):
"""Login attempts for brute-force protection"""
__tablename__ = "login_attempts"
id = Column(Integer, primary_key=True, index=True)
username = Column(String(100), nullable=True, index=True)
ip_address = Column(String(45), nullable=False, index=True)
user_agent = Column(String(500), nullable=True)
success = Column(Boolean, default=False, nullable=False)
failure_reason = Column(String(200), nullable=True)
attempted_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False, index=True)
def __repr__(self):
return f"<LoginAttempt(id={self.id}, ip={self.ip_address}, success={self.success})>"
class BlockedIP(Base):
"""Blocked IP addresses"""
__tablename__ = "blocked_ips"
id = Column(Integer, primary_key=True, index=True)
ip_address = Column(String(45), unique=True, nullable=False, index=True)
reason = Column(String(500), nullable=True)
blocked_by = Column(String(100), nullable=True)
blocked_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
expires_at = Column(DateTime(timezone=True), nullable=True)
is_permanent = Column(Boolean, default=False, nullable=False)
attempts_count = Column(Integer, default=0, nullable=False)
def is_blocked(self) -> bool:
"""Check if IP is currently blocked"""
if self.is_permanent:
return True
if self.expires_at and make_tz_aware(self.expires_at) > utcnow():
return True
return False
def __repr__(self):
return f"<BlockedIP(id={self.id}, ip={self.ip_address})>"
class UserSSHKey(Base):
"""SSH key attached to a user account."""
__tablename__ = "user_ssh_keys"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
name = Column(String(100), nullable=False)
public_key = Column(Text, nullable=False)
private_key = Column(EncryptedString(8000), nullable=True)
fingerprint = Column(String(100), nullable=True, index=True)
comment = Column(String(255), nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
user = relationship("User", backref="ssh_keys_pool")
__table_args__ = (
Index('idx_user_ssh_keys_user', 'user_id'),
)
def __repr__(self):
return f"<UserSSHKey(id={self.id}, user_id={self.user_id}, name='{self.name}')>"

View File

@@ -0,0 +1,16 @@
from sqlalchemy import Boolean, Column, Integer, BigInteger, String, DateTime, Text, Index, JSON, ForeignKey, Table
from sqlalchemy.sql import func
from sqlalchemy.orm import relationship
from datetime import datetime, timezone
from typing import Optional
from ..db import Base
from ..crypto import EncryptedString
from ..config import utcnow, make_tz_aware
# Just exposing them for the other models to import easily
__all__ = [
'Base', 'EncryptedString', 'utcnow', 'make_tz_aware',
'Boolean', 'Column', 'Integer', 'BigInteger', 'String', 'DateTime', 'Text', 'Index', 'JSON', 'ForeignKey', 'Table',
'func', 'relationship', 'datetime', 'timezone', 'Optional'
]

139
backend/app/models/ipam.py Normal file
View File

@@ -0,0 +1,139 @@
from .base import *
class IPAMNetwork(Base):
"""Networks/subnets for IPAM"""
__tablename__ = "ipam_networks"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(100), nullable=False)
description = Column(Text, nullable=True)
network = Column(String(18), nullable=False, index=True)
gateway = Column(String(45), nullable=True)
vlan_id = Column(Integer, nullable=True)
dns_primary = Column(String(45), nullable=True)
dns_secondary = Column(String(45), nullable=True)
dns_domain = Column(String(255), nullable=True)
proxmox_server_id = Column(Integer, nullable=True, index=True)
proxmox_node = Column(String(100), nullable=True)
proxmox_bridge = Column(String(20), nullable=True)
is_active = Column(Boolean, default=True, nullable=False)
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
__table_args__ = (
Index('idx_ipam_network_name', 'name'),
Index('idx_ipam_network_active', 'is_active'),
)
def __repr__(self):
return f"<IPAMNetwork(id={self.id}, name='{self.name}', network='{self.network}')>"
class IPAMPool(Base):
"""IP pools inside a network"""
__tablename__ = "ipam_pools"
id = Column(Integer, primary_key=True, index=True)
network_id = Column(Integer, nullable=False, index=True)
name = Column(String(100), nullable=False)
pool_type = Column(String(20), default="static", nullable=False)
range_start = Column(String(45), nullable=False)
range_end = Column(String(45), nullable=False)
auto_assign = Column(Boolean, default=True, nullable=False)
description = Column(Text, nullable=True)
is_active = Column(Boolean, default=True, nullable=False)
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
__table_args__ = (
Index('idx_ipam_pool_network', 'network_id'),
Index('idx_ipam_pool_type', 'pool_type'),
)
def __repr__(self):
return f"<IPAMPool(id={self.id}, name='{self.name}', range='{self.range_start}-{self.range_end}')>"
class IPAMAllocation(Base):
"""Allocated IP addresses"""
__tablename__ = "ipam_allocations"
id = Column(Integer, primary_key=True, index=True)
network_id = Column(Integer, nullable=False, index=True)
pool_id = Column(Integer, nullable=True, index=True)
ip_address = Column(String(45), nullable=False, unique=True, index=True)
mac_address = Column(String(17), nullable=True)
resource_type = Column(String(20), nullable=True)
resource_id = Column(Integer, nullable=True)
resource_name = Column(String(100), nullable=True)
proxmox_server_id = Column(Integer, nullable=True, index=True)
proxmox_vmid = Column(Integer, nullable=True)
proxmox_node = Column(String(100), nullable=True)
status = Column(String(20), default="allocated", nullable=False)
allocation_type = Column(String(20), default="static", nullable=False)
hostname = Column(String(255), nullable=True, index=True)
fqdn = Column(String(255), nullable=True)
dns_ptr_record = Column(Boolean, default=False, nullable=False)
allocated_by = Column(String(100), nullable=True)
allocated_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
expires_at = Column(DateTime(timezone=True), nullable=True)
last_seen = Column(DateTime(timezone=True), nullable=True)
notes = Column(Text, nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
__table_args__ = (
Index('idx_ipam_alloc_network', 'network_id'),
Index('idx_ipam_alloc_resource', 'resource_type', 'resource_id'),
Index('idx_ipam_alloc_proxmox', 'proxmox_server_id', 'proxmox_vmid'),
Index('idx_ipam_alloc_status', 'status'),
)
def __repr__(self):
return f"<IPAMAllocation(id={self.id}, ip='{self.ip_address}', resource='{self.resource_name}')>"
class IPAMHistory(Base):
"""History of IPAM changes"""
__tablename__ = "ipam_history"
id = Column(Integer, primary_key=True, index=True)
ip_address = Column(String(45), nullable=False, index=True)
network_id = Column(Integer, nullable=True, index=True)
action = Column(String(50), nullable=False)
old_value = Column(JSON, nullable=True)
new_value = Column(JSON, nullable=True)
resource_type = Column(String(20), nullable=True)
resource_id = Column(Integer, nullable=True)
resource_name = Column(String(100), nullable=True)
performed_by = Column(String(100), nullable=True)
performed_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
notes = Column(Text, nullable=True)
__table_args__ = (
Index('idx_ipam_history_ip', 'ip_address'),
Index('idx_ipam_history_action', 'action'),
Index('idx_ipam_history_date', 'performed_at'),
)
def __repr__(self):
return f"<IPAMHistory(id={self.id}, ip='{self.ip_address}', action='{self.action}')>"

200
backend/app/models/misc.py Normal file
View File

@@ -0,0 +1,200 @@
from .base import *
class Notification(Base):
"""User notifications"""
__tablename__ = "notifications"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
type = Column(String(50), nullable=False)
level = Column(String(20), nullable=False)
title = Column(String(255), nullable=False)
message = Column(Text)
data = Column(JSON)
link = Column(String(500))
source = Column(String(50))
source_id = Column(String(100))
read = Column(Boolean, default=False)
read_at = Column(DateTime(timezone=True))
created_at = Column(DateTime(timezone=True), server_default=func.now())
expires_at = Column(DateTime(timezone=True))
def to_dict(self):
return {
"id": self.id,
"user_id": self.user_id,
"type": self.type,
"level": self.level,
"title": self.title,
"message": self.message,
"data": self.data,
"link": self.link,
"source": self.source,
"source_id": self.source_id,
"read": self.read,
"read_at": self.read_at.isoformat() if self.read_at else None,
"created_at": self.created_at.isoformat() if self.created_at else None,
"expires_at": self.expires_at.isoformat() if self.expires_at else None,
}
class NotificationPreference(Base):
"""User notification preferences"""
__tablename__ = "notification_preferences"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), unique=True, nullable=False)
enabled = Column(Boolean, default=True)
email_enabled = Column(Boolean, default=False)
email_critical_only = Column(Boolean, default=True)
telegram_enabled = Column(Boolean, default=False)
telegram_chat_id = Column(String(100))
webhook_url = Column(String(500))
notification_levels = Column(JSON, default=["critical", "warning", "info", "success"])
notification_types = Column(JSON, default=["vm_status", "resource_alert", "system"])
quiet_hours_start = Column(String(5))
quiet_hours_end = Column(String(5))
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
def to_dict(self):
return {
"id": self.id,
"user_id": self.user_id,
"enabled": self.enabled,
"email_enabled": self.email_enabled,
"email_critical_only": self.email_critical_only,
"telegram_enabled": self.telegram_enabled,
"telegram_chat_id": self.telegram_chat_id,
"webhook_url": self.webhook_url,
"notification_levels": self.notification_levels,
"notification_types": self.notification_types,
"quiet_hours_start": self.quiet_hours_start,
"quiet_hours_end": self.quiet_hours_end,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
}
class BackupJob(Base):
"""Scheduled backup job"""
__tablename__ = "backup_jobs"
id = Column(Integer, primary_key=True, index=True)
server_id = Column(Integer, ForeignKey("proxmox_servers.id", ondelete="CASCADE"), nullable=False)
node = Column(String(100), nullable=False)
vmids = Column(JSON, nullable=False, default=[])
storage = Column(String(100), nullable=False)
mode = Column(String(20), default="snapshot", nullable=False)
compress = Column(String(20), default="zstd", nullable=False)
notes = Column(String(500), nullable=True)
keep_last = Column(Integer, default=3, nullable=False)
keep_daily = Column(Integer, default=7, nullable=False)
keep_weekly = Column(Integer, default=4, nullable=False)
keep_monthly = Column(Integer, default=6, nullable=False)
cron_expression = Column(String(100), nullable=False)
enabled = Column(Boolean, default=True, nullable=False)
owner_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
last_run_at = Column(DateTime(timezone=True), nullable=True)
last_status = Column(String(20), nullable=True)
last_error = Column(Text, nullable=True)
last_upid = Column(String(200), nullable=True)
server = relationship("ProxmoxServer", backref="backup_jobs")
owner = relationship("User", foreign_keys=[owner_id])
__table_args__ = (
Index('idx_backup_jobs_server', 'server_id'),
Index('idx_backup_jobs_enabled', 'enabled'),
Index('idx_backup_jobs_owner', 'owner_id'),
)
def __repr__(self):
return f"<BackupJob(id={self.id}, server_id={self.server_id}, cron='{self.cron_expression}')>"
def to_dict(self) -> dict:
return {
'id': self.id,
'server_id': self.server_id,
'node': self.node,
'vmids': self.vmids,
'storage': self.storage,
'mode': self.mode,
'compress': self.compress,
'notes': self.notes,
'keep_last': self.keep_last,
'keep_daily': self.keep_daily,
'keep_weekly': self.keep_weekly,
'keep_monthly': self.keep_monthly,
'cron_expression': self.cron_expression,
'enabled': self.enabled,
'owner_id': self.owner_id,
'created_at': self.created_at.isoformat() if self.created_at else None,
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
'last_run_at': self.last_run_at.isoformat() if self.last_run_at else None,
'last_status': self.last_status,
'last_error': self.last_error,
'last_upid': self.last_upid,
}
class AuditLog(Base):
"""Логи аудита системы"""
__tablename__ = "audit_logs"
id = Column(Integer, primary_key=True, index=True)
level = Column(String(20), nullable=False, index=True)
category = Column(String(50), nullable=False, index=True)
action = Column(String(100), nullable=False, index=True)
message = Column(Text, nullable=False)
request_id = Column(String(36), nullable=True, index=True)
session_id = Column(String(64), nullable=True, index=True)
user_id = Column(Integer, nullable=True, index=True)
username = Column(String(100), nullable=True, index=True)
ip_address = Column(String(45), nullable=True, index=True)
user_agent = Column(String(500), nullable=True)
geo_location = Column(String(100), nullable=True)
resource_type = Column(String(50), nullable=True, index=True)
resource_id = Column(String(100), nullable=True)
resource_name = Column(String(200), nullable=True)
server_id = Column(Integer, nullable=True, index=True)
server_name = Column(String(100), nullable=True)
node_name = Column(String(100), nullable=True)
details = Column(JSON, nullable=True)
request_body = Column(JSON, nullable=True)
response_body = Column(JSON, nullable=True)
request_method = Column(String(10), nullable=True)
request_path = Column(String(500), nullable=True)
query_params = Column(String(1000), nullable=True)
response_status = Column(Integer, nullable=True)
duration_ms = Column(Integer, nullable=True)
success = Column(Boolean, default=True, nullable=False)
error_message = Column(Text, nullable=True)
error_traceback = Column(Text, nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False, index=True)
__table_args__ = (
Index('idx_audit_level_category', 'level', 'category'),
Index('idx_audit_user_time', 'username', 'created_at'),
Index('idx_audit_resource', 'resource_type', 'resource_id'),
Index('idx_audit_created', 'created_at'),
Index('idx_audit_request_id', 'request_id'),
Index('idx_audit_ip', 'ip_address'),
Index('idx_audit_server', 'server_id'),
)
def __repr__(self):
return f"<AuditLog(id={self.id}, level='{self.level}', action='{self.action}')>"

View File

@@ -0,0 +1,220 @@
from .base import *
class OSTemplateGroup(Base):
"""Group of OS templates"""
__tablename__ = "os_template_groups"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(100), nullable=False, index=True)
icon = Column(String(200), nullable=True)
description = Column(Text, nullable=True)
sort_order = Column(Integer, nullable=False, default=0)
is_active = Column(Boolean, default=True, nullable=False)
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
def __repr__(self):
return f"<OSTemplateGroup(id={self.id}, name='{self.name}')>"
class OSTemplate(Base):
"""OS Template"""
__tablename__ = "os_templates"
id = Column(Integer, primary_key=True, index=True)
group_id = Column(Integer, nullable=False, index=True)
server_id = Column(Integer, nullable=False, index=True)
name = Column(String(100), nullable=False)
vmid = Column(Integer, nullable=True)
vm_type = Column(String(10), nullable=False, default="qemu", server_default="qemu")
volid = Column(String(500), nullable=True)
node = Column(String(100), nullable=True)
source_node = Column(String(100), nullable=True)
replicated_nodes = Column(JSON, nullable=True, default={})
default_cores = Column(Integer, nullable=False, default=1)
default_memory = Column(Integer, nullable=False, default=1024)
default_disk = Column(Integer, nullable=False, default=10)
min_cores = Column(Integer, nullable=False, default=1)
min_memory = Column(Integer, nullable=False, default=512)
min_disk = Column(Integer, nullable=False, default=5)
description = Column(Text, nullable=True)
is_active = Column(Boolean, default=True, nullable=False)
sort_order = Column(Integer, nullable=False, default=0)
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
__table_args__ = (
Index('idx_os_template_group', 'group_id'),
Index('idx_os_template_server', 'server_id'),
Index('idx_os_template_vmid', 'server_id', 'vmid'),
)
def get_source_node(self) -> str:
return self.source_node or self.node
def get_vmid_for_node(self, node_name: str) -> Optional[int]:
source = self.get_source_node()
if node_name == source:
return self.vmid
if self.replicated_nodes and node_name in self.replicated_nodes:
return self.replicated_nodes[node_name]
return None
def add_replicated_node(self, node_name: str, vmid: int):
if not self.replicated_nodes:
self.replicated_nodes = {}
self.replicated_nodes[node_name] = vmid
def __repr__(self):
return f"<OSTemplate(id={self.id}, name='{self.name}', vmid={self.vmid})>"
class ProxmoxServer(Base):
"""Proxmox VE server"""
__tablename__ = "proxmox_servers"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(100), nullable=False, index=True)
hostname = Column(String(255), nullable=False, index=True)
ip_address = Column(String(50), nullable=False, index=True)
port = Column(Integer, nullable=False, default=8006)
api_user = Column(String(100), nullable=False, default="root@pam")
api_token_name = Column(String(100), nullable=True)
api_token_value = Column(String(255), nullable=True)
use_password = Column(Boolean, nullable=False, default=False)
password = Column(EncryptedString(255), nullable=True)
verify_ssl = Column(Boolean, nullable=False, default=True)
cluster_name = Column(String(100), nullable=True, index=True)
description = Column(Text, nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
last_check = Column(DateTime(timezone=True), nullable=True)
is_online = Column(Boolean, nullable=True, default=None)
last_error = Column(Text, nullable=True)
__table_args__ = (
Index('idx_proxmox_status', 'is_online', 'last_check'),
)
def __repr__(self):
return f"<ProxmoxServer(id={self.id}, name='{self.name}', ip='{self.ip_address}')>"
@property
def connection_info(self) -> dict:
return {
'id': self.id,
'name': self.name,
'hostname': self.hostname,
'ip_address': self.ip_address,
'port': self.port,
'api_user': self.api_user,
'verify_ssl': self.verify_ssl,
'is_online': self.is_online,
'last_check': self.last_check
}
def update_status(self, is_online: bool, error: str = None):
self.is_online = is_online
self.last_check = utcnow()
if error:
self.last_error = error
elif is_online:
self.last_error = None
assigned_users = relationship("User", secondary="user_servers", back_populates="assigned_servers", lazy="select")
class VMInstance(Base):
"""VM/Container instance cache"""
__tablename__ = "vm_instances"
id = Column(Integer, primary_key=True, index=True)
server_id = Column(Integer, nullable=False, index=True)
vmid = Column(Integer, nullable=False, index=True)
node = Column(String(100), nullable=False)
vm_type = Column(String(20), nullable=False)
name = Column(String(100), nullable=False)
owner_id = Column(Integer, ForeignKey('users.id'), nullable=True, index=True)
owner = relationship("User", backref="instances", foreign_keys=[owner_id])
status = Column(String(20), default='unknown')
is_template = Column(Boolean, default=False)
cores = Column(Integer, nullable=True)
memory = Column(BigInteger, nullable=True)
disk_size = Column(BigInteger, nullable=True)
os_type = Column(String(50), nullable=True)
ip_address = Column(String(50), nullable=True)
ip_prefix = Column(Integer, nullable=True, default=24)
gateway = Column(String(50), nullable=True)
nameserver = Column(String(50), nullable=True)
cloud_init_user = Column(String(100), nullable=True)
cloud_init_password = Column(EncryptedString(255), nullable=True)
ssh_keys = Column(Text, nullable=True)
template_id = Column(Integer, nullable=True)
template_name = Column(String(100), nullable=True)
description = Column(Text, nullable=True)
tags = Column(String(255), nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
deleted_at = Column(DateTime(timezone=True), nullable=True)
last_sync_at = Column(DateTime(timezone=True), nullable=True)
extra_config = Column(JSON, nullable=True)
__table_args__ = (
Index('idx_vm_instance_server_vmid', 'server_id', 'vmid', unique=True),
Index('idx_vm_instance_active', 'server_id', 'deleted_at'),
Index('idx_vm_instance_owner', 'owner_id'),
)
def __repr__(self):
return f"<VMInstance(id={self.id}, server_id={self.server_id}, vmid={self.vmid}, name='{self.name}')>"
class VMSnapshotArchive(Base):
"""Archive of VM/Container snapshots"""
__tablename__ = "vm_snapshot_archives"
id = Column(Integer, primary_key=True, index=True)
server_id = Column(Integer, nullable=False, index=True)
server_name = Column(String(100), nullable=True)
vmid = Column(Integer, nullable=False, index=True)
vm_name = Column(String(100), nullable=True)
vm_type = Column(String(20), nullable=False)
node = Column(String(100), nullable=False)
snapname = Column(String(100), nullable=False)
description = Column(Text, nullable=True)
snaptime = Column(BigInteger, nullable=True)
parent = Column(String(100), nullable=True)
vmstate = Column(Boolean, default=False, nullable=False)
snapshot_config = Column(JSON, nullable=True)
deleted_by = Column(String(100), nullable=True)
deletion_reason = Column(Text, nullable=True)
archived_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
__table_args__ = (
Index('idx_snapshot_archive_server_vmid', 'server_id', 'vmid'),
Index('idx_snapshot_archive_archived', 'archived_at'),
)
def __repr__(self):
return f"<VMSnapshotArchive(id={self.id}, vmid={self.vmid}, snapname='{self.snapname}')>"

View File

@@ -0,0 +1,86 @@
from .base import *
class PanelSettings(Base):
"""Panel settings"""
__tablename__ = "panel_settings"
id = Column(Integer, primary_key=True, index=True)
key = Column(String(100), unique=True, nullable=False, index=True)
value = Column(String(500), nullable=False)
description = Column(Text, nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
def __repr__(self):
return f"<PanelSettings(id={self.id}, key='{self.key}', value='{self.value}')>"
class SecuritySetting(Base):
"""Security settings"""
__tablename__ = "security_settings"
id = Column(Integer, primary_key=True, index=True)
key = Column(String(100), unique=True, nullable=False, index=True)
value = Column(String(500), nullable=False)
description = Column(Text, nullable=True)
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
def __repr__(self):
return f"<SecuritySetting(key='{self.key}', value='{self.value}')>"
class Workspace(Base):
"""Workspace — named group of Proxmox servers"""
__tablename__ = "workspaces"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(100), unique=True, nullable=False, index=True)
description = Column(Text, nullable=True)
color = Column(String(20), nullable=True, default="#667eea")
is_default = Column(Boolean, default=False, nullable=False)
created_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
creator = relationship("User", foreign_keys=[created_by])
workspace_servers = relationship("WorkspaceServer", back_populates="workspace", cascade="all, delete-orphan")
workspace_users = relationship("WorkspaceUser", back_populates="workspace", cascade="all, delete-orphan")
def __repr__(self):
return f"<Workspace(id={self.id}, name='{self.name}')>"
class WorkspaceServer(Base):
"""Junction table: servers belonging to a workspace"""
__tablename__ = "workspace_servers"
id = Column(Integer, primary_key=True, index=True)
workspace_id = Column(Integer, ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False)
server_id = Column(Integer, ForeignKey("proxmox_servers.id", ondelete="CASCADE"), nullable=False)
workspace = relationship("Workspace", back_populates="workspace_servers")
server = relationship("ProxmoxServer")
__table_args__ = (
Index('idx_ws_servers_workspace', 'workspace_id'),
Index('idx_ws_servers_server', 'server_id'),
Index('idx_ws_servers_unique', 'workspace_id', 'server_id', unique=True),
)
def __repr__(self):
return f"<WorkspaceServer(workspace_id={self.workspace_id}, server_id={self.server_id})>"
class WorkspaceUser(Base):
"""Junction table: users with access to a workspace"""
__tablename__ = "workspace_users"
id = Column(Integer, primary_key=True, index=True)
workspace_id = Column(Integer, ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
workspace = relationship("Workspace", back_populates="workspace_users")
user = relationship("User")
__table_args__ = (
Index('idx_ws_users_workspace', 'workspace_id'),
Index('idx_ws_users_user', 'user_id'),
Index('idx_ws_users_unique', 'workspace_id', 'user_id', unique=True),
)

164
backend/app/models/tasks.py Normal file
View File

@@ -0,0 +1,164 @@
from .base import *
class TaskQueue(Base):
"""Background task queue for bulk operations"""
__tablename__ = "task_queue"
id = Column(Integer, primary_key=True, index=True)
task_type = Column(String(50), nullable=False, index=True)
status = Column(String(20), nullable=False, default='pending', index=True)
user_id = Column(Integer, nullable=False, index=True)
total_items = Column(Integer, nullable=False, default=0)
completed_items = Column(Integer, nullable=False, default=0)
failed_items = Column(Integer, nullable=False, default=0)
task_data = Column(JSON, nullable=False)
results = Column(JSON, nullable=True)
error_message = Column(Text, nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
started_at = Column(DateTime(timezone=True), nullable=True)
completed_at = Column(DateTime(timezone=True), nullable=True)
__table_args__ = (
Index('idx_task_status_created', 'status', 'created_at'),
Index('idx_task_user_status', 'user_id', 'status'),
)
def __repr__(self):
return f"<TaskQueue(id={self.id}, type='{self.task_type}', status='{self.status}')>"
@property
def progress_percent(self) -> int:
if self.total_items == 0:
return 0
return int((self.completed_items + self.failed_items) / self.total_items * 100)
def to_dict(self) -> dict:
_desc_map = {
'bulk_start': 'Массовый запуск ВМ/LXC',
'bulk_stop': 'Массовая остановка ВМ/LXC',
'bulk_restart': 'Массовый перезапуск ВМ/LXC',
'bulk_delete': 'Массовое удаление ВМ/LXC',
'bulk_shutdown': 'Массовое выключение ВМ/LXC',
}
return {
'id': self.id,
'kind': 'bulk',
'user_id': self.user_id,
'task_type': self.task_type,
'description': _desc_map.get(self.task_type, self.task_type),
'status': self.status,
'total_items': self.total_items,
'completed_items': self.completed_items,
'failed_items': self.failed_items,
'processed_items': self.completed_items + self.failed_items,
'progress_percent': self.progress_percent,
'results': self.results,
'error_message': self.error_message,
'created_at': self.created_at.isoformat() if self.created_at else None,
'started_at': self.started_at.isoformat() if self.started_at else None,
'completed_at': self.completed_at.isoformat() if self.completed_at else None,
}
class ProxmoxTask(Base):
"""Tracks long-running Proxmox tasks identified by UPID."""
__tablename__ = "proxmox_tasks"
id = Column(Integer, primary_key=True, index=True)
upid = Column(String(200), nullable=False, index=True)
server_id = Column(Integer, ForeignKey("proxmox_servers.id", ondelete="SET NULL"), nullable=True, index=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
node = Column(String(100), nullable=True)
vmid = Column(Integer, nullable=True)
vm_type = Column(String(10), nullable=True)
action = Column(String(100), nullable=False)
description = Column(String(500), nullable=True)
status = Column(String(20), nullable=False, default="running", index=True)
exit_status = Column(String(100), nullable=True)
log_text = Column(Text, nullable=True)
created_at = Column(DateTime(timezone=True), default=utcnow, nullable=False, index=True)
completed_at = Column(DateTime(timezone=True), nullable=True)
def to_dict(self) -> dict:
return {
'id': self.id,
'kind': 'proxmox',
'upid': self.upid,
'server_id': self.server_id,
'user_id': self.user_id,
'node': self.node,
'vmid': self.vmid,
'vm_type': self.vm_type,
'action': self.action,
'description': self.description,
'status': self.status,
'exit_status': self.exit_status,
'log_text': self.log_text,
'created_at': self.created_at.isoformat() if self.created_at else None,
'completed_at': self.completed_at.isoformat() if self.completed_at else None,
}
def __repr__(self):
return f"<ProxmoxTask(id={self.id}, upid='{self.upid[:30]}...', status='{self.status}')>"
class DeployTask(Base):
"""Tracks async VM deployment tasks from templates"""
__tablename__ = "deploy_tasks"
id = Column(Integer, primary_key=True, index=True)
status = Column(String(20), nullable=False, default='pending', index=True)
kind = Column(String(30), nullable=False, default='deploy', index=True)
step = Column(String(200), nullable=True)
progress = Column(Integer, nullable=False, default=0)
name = Column(String(100), nullable=False)
template_id = Column(Integer, nullable=True)
server_id = Column(Integer, nullable=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
vmid = Column(Integer, nullable=True)
node = Column(String(100), nullable=True)
error_message = Column(Text, nullable=True)
created_at = Column(DateTime(timezone=True), default=utcnow, nullable=False, index=True)
started_at = Column(DateTime(timezone=True), nullable=True)
completed_at = Column(DateTime(timezone=True), nullable=True)
__table_args__ = (
Index('idx_deploy_task_user_status', 'user_id', 'status'),
)
def to_dict(self) -> dict:
kind = self.kind or 'deploy'
descriptions = {
'deploy': f"Создание {self.name}",
'reinstall': f"Переустановка {self.name}",
'clone': f"Клонирование {self.name}",
'change_password': f"Смена пароля: {self.name}",
}
return {
'id': self.id,
'kind': kind,
'status': self.status,
'step': self.step,
'current_step': self.step,
'progress': self.progress,
'progress_percent': self.progress,
'description': descriptions.get(kind, self.name),
'name': self.name,
'template_id': self.template_id,
'server_id': self.server_id,
'vmid': self.vmid,
'node': self.node,
'error_message': self.error_message,
'created_at': self.created_at.isoformat() if self.created_at else None,
'started_at': self.started_at.isoformat() if self.started_at else None,
'completed_at': self.completed_at.isoformat() if self.completed_at else None,
}
def __repr__(self):
return f"<DeployTask(id={self.id}, kind='{self.kind}', name='{self.name}', status='{self.status}')>"

View File

@@ -0,0 +1,9 @@
from .client import ProxmoxClient, get_proxmox_resources, _run_in_executor, clear_all_cache, clear_server_cache
__all__ = [
'ProxmoxClient',
'get_proxmox_resources',
'_run_in_executor',
'clear_all_cache',
'clear_server_cache'
]

File diff suppressed because it is too large Load Diff

View 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)}

View 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 {}

View 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)}

View 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)}

View 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 []

View 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

File diff suppressed because it is too large Load Diff

View File

@@ -14,7 +14,7 @@ from loguru import logger
from ..db import SessionLocal
from ..models import BackupJob, ProxmoxServer
from ..proxmox_client import ProxmoxClient
from ..proxmox import ProxmoxClient
# Singleton scheduler instance

View File

@@ -64,7 +64,7 @@ def _fetch_server_resources_with_nodes(server) -> dict:
Pull VMs + containers + per-node CPU/RAM/Disk status + cached RRD slices.
Runs in a thread pool — safe to block.
"""
from ..proxmox_client import get_proxmox_resources
from ..proxmox import get_proxmox_resources
from ..api.proxmox._helpers import _get_proxmox_client
base: dict = {

View File

@@ -20,6 +20,15 @@ def utcnow() -> datetime:
return datetime.now(timezone.utc)
def make_tz_aware(dt: Optional[datetime]) -> Optional[datetime]:
"""Ensure datetime is timezone-aware. Defaults to UTC if naive."""
if dt is None:
return None
if dt.tzinfo is None:
return dt.replace(tzinfo=timezone.utc)
return dt
class SecurityService:
"""Service for security operations"""
@@ -103,7 +112,7 @@ class SecurityService:
# Compare timezone-aware datetimes
now = utcnow()
if blocked.expires_at and blocked.expires_at > now:
if blocked.expires_at and make_tz_aware(blocked.expires_at) > now:
return True, blocked.reason
# Block expired, remove it
@@ -305,7 +314,7 @@ class SecurityService:
# Check expiration
# Compare timezone-aware datetimes
now = utcnow()
if session.expires_at < now:
if make_tz_aware(session.expires_at) < now:
session.is_active = False
db.commit()
return None
@@ -403,7 +412,7 @@ class SecurityService:
return False, None
# Compare timezone-aware datetimes
if user.locked_until > utcnow():
if make_tz_aware(user.locked_until) > utcnow():
return True, user.locked_until
return False, None

View File

@@ -17,12 +17,12 @@ def utcnow() -> datetime:
try:
from backend.app.db import SessionLocal
from backend.app.models import TaskQueue, ProxmoxServer, User
from backend.app.proxmox_client import ProxmoxClient
from backend.app.proxmox import ProxmoxClient
from backend.app.websocket_manager import broadcast_task_update
except ImportError:
from app.db import SessionLocal
from app.models import TaskQueue, ProxmoxServer, User
from app.proxmox_client import ProxmoxClient
from app.proxmox import ProxmoxClient
from app.websocket_manager import broadcast_task_update

View File

@@ -9,9 +9,7 @@ from typing import Dict, Any, List
from loguru import logger
def utcnow() -> datetime:
"""Get current UTC time as timezone-aware datetime"""
return datetime.now(timezone.utc)
from ..config import utcnow
try:
@@ -19,7 +17,7 @@ try:
from backend.app.models import User, ProxmoxServer, PanelSettings, VMInstance, IPAMAllocation, Notification, ProxmoxTask
from backend.app.services.notification_service import NotificationService
from backend.app.logging_service import LoggingService
from backend.app.proxmox_client import ProxmoxClient
from backend.app.proxmox import ProxmoxClient
from backend.app.schemas import NotificationCreate
from backend.app.i18n import t
from backend.app.websocket_manager import broadcast_task_update
@@ -28,7 +26,7 @@ except ImportError:
from app.models import User, ProxmoxServer, PanelSettings, VMInstance, IPAMAllocation, Notification, ProxmoxTask
from app.services.notification_service import NotificationService
from app.logging_service import LoggingService
from app.proxmox_client import ProxmoxClient
from app.proxmox import ProxmoxClient
from app.schemas import NotificationCreate
from app.i18n import t
from app.websocket_manager import broadcast_task_update
@@ -639,9 +637,10 @@ class MonitoringWorker:
logger.warning(f"[VM SYNC] Server {server.name} not connected, skipping")
continue
# Get all VMs and containers
vms = client.get_vms()
containers = client.get_containers()
# Get all VMs and containers efficiently in one request
resources = client.get_cluster_resources(type_='vm')
vms = [res for res in resources if res.get('type') == 'qemu']
containers = [res for res in resources if res.get('type') == 'lxc']
cluster_id = cluster_map.get(server.id)
@@ -1182,7 +1181,7 @@ def start_monitoring_worker():
# Cleanup expired Proxmox connection cache - every hour
try:
from app.proxmox_client import cleanup_expired_connections
from app.proxmox import cleanup_expired_connections
scheduler.add_job(
cleanup_expired_connections,
trigger=IntervalTrigger(hours=1),

View File

@@ -49,7 +49,8 @@ def create_default_admin():
hashed_password=hashed_password,
is_admin=True,
is_active=True,
role_id=admin_role.id if admin_role else None
role_id=admin_role.id if admin_role else None,
require_password_change=(password == "admin123")
)
db.add(admin)

142
backend/refactor_proxmox.py Normal file
View File

@@ -0,0 +1,142 @@
import ast
import re
import os
source_file = 'app/proxmox_client.py'
with open(source_file, 'r', encoding='utf-8') as f:
source_code = f.read()
lines = source_code.split('\n')
class Extractor(ast.NodeVisitor):
def __init__(self):
self.methods = {}
self.module_funcs = []
def visit_ClassDef(self, node):
if node.name == 'ProxmoxClient':
for item in node.body:
if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)):
start = item.lineno - 1
end = item.end_lineno
if item.decorator_list:
start = item.decorator_list[0].lineno - 1
code = '\n'.join(lines[start:end])
self.methods[item.name] = code
self.generic_visit(node)
def visit_FunctionDef(self, node):
if node.col_offset == 0: # Module-level function
start = node.lineno - 1
end = node.end_lineno
if node.decorator_list:
start = node.decorator_list[0].lineno - 1
code = '\n'.join(lines[start:end])
self.module_funcs.append(code)
self.generic_visit(node)
def visit_AsyncFunctionDef(self, node):
if node.col_offset == 0: # Module-level async function
start = node.lineno - 1
end = node.end_lineno
if node.decorator_list:
start = node.decorator_list[0].lineno - 1
code = '\n'.join(lines[start:end])
self.module_funcs.append(code)
self.generic_visit(node)
tree = ast.parse(source_code)
extractor = Extractor()
extractor.visit(tree)
methods = extractor.methods
module_funcs = extractor.module_funcs
groups = {
'vm': ['start_vm', 'stop_vm', 'restart_vm', 'shutdown_vm', 'clone_vm', 'change_vm_password',
'set_vm_notes', 'attach_iso', 'detach_iso', 'force_stop_vm', 'get_vm_status', 'get_vm_stats',
'get_vm_rrddata', 'get_vm_vnc', 'get_vm_spice', 'qemu_agent_exec', 'resize_vm_disk', 'reinstall_vm_from_template'],
'lxc': ['start_container', 'stop_container', 'restart_container', 'shutdown_container', 'clone_container',
'change_container_password', 'set_container_notes', 'force_stop_container', 'get_container_status',
'get_container_stats', 'get_container_rrddata', 'get_container_vnc', 'get_container_spice', 'resize_lxc_disk', 'reinstall_lxc_from_template'],
'cluster': ['is_cluster', 'get_ha_resources', 'get_ha_groups', 'is_in_ha', 'add_to_ha', 'remove_from_ha', 'get_ha_status',
'create_cluster', 'get_cluster_join_info', 'join_cluster', 'delete_cluster_node', '_delete_cluster_node_via_ssh'],
'storage': ['get_node_isos', 'get_lxc_storage_templates', 'get_node_storages', 'vzdump_guest', 'get_backup_storages',
'get_backup_jobs', 'create_backup_job', 'update_backup_job', 'delete_backup_job', 'run_backup_job', 'get_backup_job_status'],
'network': ['get_sdn_vnets', 'get_sdn_zones', 'get_sdn_controllers', 'get_sdn_ipams', 'get_sdn_dns',
'apply_sdn', 'create_sdn_zone', 'create_sdn_vnet', 'create_sdn_subnet', 'get_sdn_vnet_subnets',
'get_node_interfaces'],
'snapshot': ['get_vm_snapshots', 'create_vm_snapshot', 'rollback_vm_snapshot', 'delete_vm_snapshot',
'get_container_snapshots', 'create_container_snapshot', 'rollback_container_snapshot', 'delete_container_snapshot',
'get_guest_snapshots', 'create_guest_snapshot', 'rollback_guest_snapshot', 'delete_guest_snapshot']
}
mixins_code = {}
for group, group_methods in groups.items():
mixin_name = group.capitalize() + 'Mixin'
code = f"from typing import List, Dict, Optional, Union, Any\nimport time\nimport urllib3\nfrom loguru import logger\n\nclass {mixin_name}:\n"
has_methods = False
for m in group_methods:
if m in methods:
# indent
method_code = '\n'.join([' ' + l for l in methods[m].split('\n')])
code += method_code + "\n\n"
has_methods = True
if has_methods:
mixins_code[group] = code
os.makedirs('app/proxmox/mixins', exist_ok=True)
for group, code in mixins_code.items():
with open(f'app/proxmox/mixins/{group}.py', 'w') as f:
f.write(code)
print("Mixins created successfully.")
core_methods = []
for m, code in methods.items():
found = False
for group_methods in groups.values():
if m in group_methods:
found = True
break
if not found:
core_methods.append(code)
imports = """import time
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 .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
proxmox_executor = ThreadPoolExecutor(max_workers=20, thread_name_prefix="proxmox_")
connection_cache = {}
connection_cache_lock = threading.Lock()
MAX_CACHE_SIZE = 50
"""
client_code = imports
for fcode in module_funcs:
client_code += fcode + "\n\n"
client_code += "class ProxmoxClient(VmMixin, LxcMixin, ClusterMixin, StorageMixin, NetworkMixin, SnapshotMixin):\n"
for mcode in core_methods:
client_code += '\n'.join([' ' + l for l in mcode.split('\n')]) + "\n\n"
with open('app/proxmox/client.py', 'w') as f:
f.write(client_code)
print("Client created successfully.")

141
backend/refactor_vms.py Normal file
View File

@@ -0,0 +1,141 @@
import ast
import os
import re
source_file = 'app/api/proxmox/vms.py'
with open(source_file, 'r', encoding='utf-8') as f:
source_code = f.read()
lines = source_code.split('\n')
class RouterExtractor(ast.NodeVisitor):
def __init__(self):
self.routes = []
self.models = []
self.imports = []
self.other = []
def visit_Import(self, node):
if node.col_offset == 0:
start = node.lineno - 1
end = node.end_lineno
self.imports.append('\n'.join(lines[start:end]))
self.generic_visit(node)
def visit_ImportFrom(self, node):
if node.col_offset == 0:
start = node.lineno - 1
end = node.end_lineno
self.imports.append('\n'.join(lines[start:end]))
self.generic_visit(node)
def visit_ClassDef(self, node):
if node.col_offset == 0:
start = node.lineno - 1
end = node.end_lineno
if node.decorator_list:
start = node.decorator_list[0].lineno - 1
code = '\n'.join(lines[start:end])
self.models.append((node.name, code))
self.generic_visit(node)
def visit_FunctionDef(self, node):
if node.col_offset == 0:
start = node.lineno - 1
end = node.end_lineno
if node.decorator_list:
start = node.decorator_list[0].lineno - 1
code = '\n'.join(lines[start:end])
# Check if it's a router
is_router = False
for dec in node.decorator_list:
if isinstance(dec, ast.Call) and isinstance(dec.func, ast.Attribute):
if hasattr(dec.func.value, 'id') and dec.func.value.id == 'router':
is_router = True
break
if is_router:
self.routes.append((node.name, code))
else:
self.other.append((node.name, code))
self.generic_visit(node)
def visit_AsyncFunctionDef(self, node):
if node.col_offset == 0:
start = node.lineno - 1
end = node.end_lineno
if node.decorator_list:
start = node.decorator_list[0].lineno - 1
code = '\n'.join(lines[start:end])
is_router = False
for dec in node.decorator_list:
if isinstance(dec, ast.Call) and isinstance(dec.func, ast.Attribute):
if hasattr(dec.func.value, 'id') and dec.func.value.id == 'router':
is_router = True
break
if is_router:
self.routes.append((node.name, code))
else:
self.other.append((node.name, code))
self.generic_visit(node)
extractor = RouterExtractor()
tree = ast.parse(source_code)
extractor.visit(tree)
groups = {
'crud': ['sync_vms', 'get_virtual_machines', 'delete_vm', 'delete_container'],
'actions': ['clone_vm', 'clone_container', 'reinstall_vm', 'reinstall_lxc', 'vm_action', 'container_action', 'execute_vm_command', 'execute_vm_script', 'container_exec'],
'ha': ['get_ha_status', 'get_ha_details', 'add_to_ha', 'remove_from_ha'],
'console': ['get_vm_vnc', 'get_container_vnc', 'vnc_websocket', 'get_vm_terminal', 'get_container_terminal', 'terminal_websocket'],
'config': ['change_vm_password', 'change_container_password', 'get_vm_config', 'update_vm_config', 'resize_vm_disk', 'resize_lxc_disk',
'get_container_config', 'update_container_config', 'get_vm_owner', 'update_vm_owner', 'update_vm_notes', 'attach_vm_iso', 'detach_vm_iso',
'get_nodes', 'get_vm_status', 'get_container_status', 'get_vm_interfaces', 'get_container_interfaces', 'get_vm_rrddata', 'get_container_rrddata',
'get_vm_saved_config'],
'lxc': ['get_lxc_templates', 'get_all_lxc_templates', 'get_available_lxc_templates', 'download_lxc_template', 'create_lxc', 'create_lxc_smart', 'clone_lxc'],
'bulk': ['bulk_operation']
}
mixins_code = {}
imports_code = '\n'.join(extractor.imports)
imports_code = re.sub(r'from \.\.\.api', 'from ....api', imports_code)
imports_code = re.sub(r'from \.\.\.models', 'from ....models', imports_code)
imports_code = re.sub(r'from \.\.\.services', 'from ....services', imports_code)
imports_code = re.sub(r'from \.\.\.db', 'from ....db', imports_code)
imports_code = re.sub(r'from \.\.\.auth', 'from ....auth', imports_code)
imports_code = re.sub(r'from \.\.\.proxmox', 'from ....proxmox', imports_code)
imports_code = re.sub(r'from \.\.\.logging', 'from ....logging', imports_code)
imports_code = re.sub(r'from \.\.\.ipam', 'from ....ipam', imports_code)
imports_code = imports_code.replace('from ._helpers', 'from .._helpers')
models_code = '\n\n'.join([m[1] for m in extractor.models])
other_code = '\n\n'.join([o[1] for o in extractor.other])
base_code = f"{imports_code}\n\nrouter = APIRouter()\n\n{models_code}\n\n{other_code}\n\n"
for group, methods in groups.items():
code = base_code
for r_name, r_code in extractor.routes:
if r_name in methods:
code += r_code + "\n\n"
mixins_code[group] = code
for group, code in mixins_code.items():
with open(f'app/api/proxmox/vms/{group}.py', 'w') as f:
f.write(code)
init_code = "from fastapi import APIRouter\n"
for group in groups.keys():
init_code += f"from .{group} import router as {group}_router\n"
init_code += "\nrouter = APIRouter()\n"
for group in groups.keys():
init_code += f"router.include_router({group}_router)\n"
with open('app/api/proxmox/vms/__init__.py', 'w') as f:
f.write(init_code)
print("VMS routes split successfully.")

View File

@@ -0,0 +1,22 @@
import os
for root, dirs, files in os.walk('app'):
for file in files:
if file.endswith('.py'):
filepath = os.path.join(root, file)
with open(filepath, 'r') as f:
content = f.read()
# replace absolute imports
new_content = content.replace('app.proxmox_client', 'app.proxmox')
new_content = new_content.replace('backend.app.proxmox_client', 'backend.app.proxmox')
# replace relative imports
new_content = new_content.replace('..proxmox_client', '..proxmox')
new_content = new_content.replace('...proxmox_client', '...proxmox')
new_content = new_content.replace('from .proxmox_client', 'from .proxmox')
if new_content != content:
with open(filepath, 'w') as f:
f.write(new_content)
print(f"Updated {filepath}")

View File

@@ -21,3 +21,4 @@ pyjwt==2.9.0
email-validator==2.2.0
websockets==12.0
apscheduler==3.10.4
alembic==1.13.2

View File

@@ -1,10 +1,8 @@
"""
Shared pytest fixtures.
Shared pytest fixtures and test setup.
The DB engine is constructed at import time in ``app.db``. To keep tests
hermetic we set environment variables BEFORE importing the application.
The engine itself is created lazily-connected, so as long as no test issues
a real query the configured URL is irrelevant.
This file overrides the production PostgreSQL database setup with an in-memory
SQLite database and configures mocks for all Proxmox operations.
"""
from __future__ import annotations
@@ -17,16 +15,15 @@ from types import SimpleNamespace
from unittest.mock import MagicMock
# --- Environment must be set BEFORE importing app.* ---------------------------
os.environ.setdefault("DATABASE_URL", "postgresql://test:test@localhost:5432/test")
os.environ.setdefault("SECRET_KEY", "test-secret-key-do-not-use-in-prod")
os.environ.setdefault("ADMIN_PASSWORD", "test-admin-password")
os.environ.setdefault("LOG_LEVEL", "WARNING")
# Use a PostgreSQL URL during import to avoid SQLAlchemy pool parameter TypeErrors (like max_overflow) with SQLite.
os.environ["DATABASE_URL"] = "postgresql://test:test@localhost:5432/test"
os.environ["SECRET_KEY"] = "test-secret-key-do-not-use-in-prod"
os.environ["ADMIN_PASSWORD"] = "test-admin-password"
os.environ["LOG_LEVEL"] = "WARNING"
# Generate a deterministic Fernet key for tests so encrypt/decrypt are stable.
from cryptography.fernet import Fernet # noqa: E402
os.environ.setdefault("FERNET_KEY", Fernet.generate_key().decode())
os.environ["FERNET_KEY"] = Fernet.generate_key().decode()
# Ensure the backend root is on sys.path so ``import app`` works regardless of
# the working directory pytest is launched from.
@@ -35,36 +32,220 @@ if str(BACKEND_ROOT) not in sys.path:
sys.path.insert(0, str(BACKEND_ROOT))
# pydantic-settings auto-loads ``.env`` from the current working directory.
# A developer .env may contain operational keys (POSTGRES_PASSWORD, ALLOWED_HOSTS,
# ...) that are not declared on the Settings model and would raise
# ``extra_forbidden``. Run from a clean temp dir so only env vars are honoured.
# Run from a clean temp dir so only env vars are honoured.
_TEST_CWD = Path(tempfile.mkdtemp(prefix="pvemanager-tests-"))
os.chdir(_TEST_CWD)
import pytest # noqa: E402
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
# Create an in-memory SQLite engine for testing
test_engine = create_engine(
"sqlite://",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
TestSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=test_engine)
# Import app.db (this constructs PostgreSQL engine, avoiding SQLite pool parameter TypeError)
import app.db
# Monkeypatch engine and SessionLocal in app.db with SQLite test engine
app.db.engine = test_engine
app.db.SessionLocal = TestSessionLocal
# Update environment and settings to reflect SQLite for any other components
os.environ["DATABASE_URL"] = "sqlite:///:memory:"
from app.config import settings
settings.DATABASE_URL = "sqlite:///:memory:"
# Import all models to register them with SQLAlchemy Base
import app.models # noqa: F401
# Disable lifespan to avoid starting background workers and database migration checks during tests
from app.main import app as fastapi_app
from contextlib import asynccontextmanager
@asynccontextmanager
async def dummy_lifespan(app):
yield
fastapi_app.router.lifespan_context = dummy_lifespan
# --- Reusable fixtures --------------------------------------------------------
@pytest.fixture(autouse=True)
def clean_database():
"""Recreates the SQLite database tables and seeds default data before each test, dropping them after."""
from app.db import Base, init_default_settings
from app.models import Role
Base.metadata.create_all(bind=test_engine)
init_default_settings()
db = TestSessionLocal()
try:
admin_role = Role(
name="admin",
display_name="Administrator",
permissions={"*": True},
is_system=True
)
user_role = Role(
name="user",
display_name="User",
permissions={"proxmox.view": True, "vms.view": True},
is_system=True
)
db.add_all([admin_role, user_role])
db.commit()
finally:
db.close()
yield
Base.metadata.drop_all(bind=test_engine)
@pytest.fixture
def db_session():
"""Provides a database session for each test."""
session = TestSessionLocal()
yield session
session.close()
@pytest.fixture
def client(db_session):
"""Provides a TestClient with get_db dependency overridden to the test transaction."""
from app.db import get_db
def _get_db_override():
yield db_session
fastapi_app.dependency_overrides[get_db] = _get_db_override
from fastapi.testclient import TestClient
with TestClient(fastapi_app) as c:
yield c
fastapi_app.dependency_overrides.pop(get_db, None)
@pytest.fixture
def seed_users(db_session):
"""Seeds a test administrator and a standard user into the test database."""
from app.models import User, Role
from app.auth import get_password_hash
admin_role = db_session.query(Role).filter(Role.name == "admin").first()
user_role = db_session.query(Role).filter(Role.name == "user").first()
admin_user = User(
username="testadmin",
email="testadmin@example.com",
hashed_password=get_password_hash("AdminPass123!"),
is_active=True,
is_admin=True,
role=admin_role,
)
test_user = User(
username="testuser",
email="testuser@example.com",
hashed_password=get_password_hash("UserPass123!"),
is_active=True,
is_admin=False,
role=user_role,
)
db_session.add_all([admin_user, test_user])
db_session.commit() # Commit so the records are visible to all connections/sessions
return {"admin": admin_user, "user": test_user}
def _get_headers_for_user(db_session, user):
"""Helper to generate JWT headers and create an active session in the database."""
from app.services.security_service import SecurityService
from app.auth import create_access_token
session, _ = SecurityService.create_session(
db_session, user, ip_address="127.0.0.1", user_agent="test-agent"
)
token = create_access_token(
data={"sub": user.username},
session_token=session.session_token
)
return {"Authorization": f"Bearer {token}"}
@pytest.fixture
def admin_headers(db_session, seed_users):
"""Returns authorization headers for the test admin user."""
return _get_headers_for_user(db_session, seed_users["admin"])
@pytest.fixture
def user_headers(db_session, seed_users):
"""Returns authorization headers for the test standard user."""
return _get_headers_for_user(db_session, seed_users["user"])
@pytest.fixture
def mock_proxmox():
"""Mocks ProxmoxClient and get_proxmox_resources globally for tests."""
from unittest.mock import patch
mock_client_instance = MagicMock()
mock_client_instance.is_connected.return_value = True
mock_client_instance.get_next_vmid.return_value = 101
mock_client_instance.get_nodes.return_value = [{"node": "pve1", "uptime": 99999}]
mock_client_instance.get_all_resources.return_value = {
"vms": [
{"vmid": 100, "name": "vm100", "status": "running", "node": "pve1", "template": 0, "type": "qemu"}
],
"containers": [
{"vmid": 200, "name": "ct200", "status": "stopped", "node": "pve1", "template": 0, "type": "lxc"}
]
}
mock_client_instance.get_vm_status.return_value = {"status": "running", "uptime": 12345}
mock_client_instance.get_container_status.return_value = {"status": "stopped"}
mock_client_instance.get_vm_config.return_value = {"cores": 2, "memory": 2048, "name": "vm100", "node": "pve1"}
mock_client_instance.get_container_config.return_value = {"cores": 1, "memory": 1024, "name": "ct200", "node": "pve1"}
mock_client_instance.get_vm_interfaces.return_value = [{"name": "net0", "ip": "192.168.1.100"}]
mock_client_instance.delete_vm.return_value = "UPID:pve1:00001D4C:00000000:666:qvmdelete:100:testadmin:"
mock_client_instance.delete_container.return_value = "UPID:pve1:00001D4C:00000000:666:ctdelete:200:testadmin:"
with patch("app.proxmox.client.ProxmoxClient", return_value=mock_client_instance), \
patch("app.proxmox.ProxmoxClient", return_value=mock_client_instance), \
patch("app.api.proxmox._helpers.ProxmoxClient", return_value=mock_client_instance), \
patch("app.api.proxmox._helpers._get_proxmox_client", return_value=mock_client_instance), \
patch("app.proxmox.get_proxmox_resources") as mock_resources:
mock_resources.return_value = mock_client_instance.get_all_resources()
yield {
"client": mock_client_instance,
"get_proxmox_resources": mock_resources
}
# --- Legacy fixtures for compatibility with existing unit tests ---------------
@pytest.fixture
def fake_role_factory():
"""Build a stand-in Role object with a permissions dict."""
def _make(permissions: dict | None = None, name: str = "tester"):
return SimpleNamespace(
name=name,
permissions=permissions or {},
has_permission=lambda perm: bool((permissions or {}).get(perm)),
)
return _make
@pytest.fixture
def fake_user_factory(fake_role_factory):
"""Build a stand-in User object compatible with auth/rbac code paths."""
def _make(
username: str = "alice",
is_admin: bool = False,
@@ -82,7 +263,6 @@ def fake_user_factory(fake_role_factory):
role=role,
locked_until=None,
)
return _make

View File

@@ -0,0 +1,111 @@
import pytest
from app.models import User, ActiveSession
from app.auth import verify_password
@pytest.mark.integration
class TestAPIAuthentication:
def test_login_success(self, client, seed_users):
response = client.post(
"/api/auth/login",
json={"username": "testuser", "password": "UserPass123!"}
)
assert response.status_code == 200
data = response.json()
assert "access_token" in data
assert data["token_type"] == "bearer"
def test_login_wrong_password(self, client, seed_users):
response = client.post(
"/api/auth/login",
json={"username": "testuser", "password": "wrongpassword"}
)
assert response.status_code == 401
assert "Incorrect username or password" in response.json()["detail"]
def test_login_user_not_found(self, client, seed_users):
response = client.post(
"/api/auth/login",
json={"username": "nonexistent", "password": "somepassword"}
)
assert response.status_code == 401
def test_get_me(self, client, user_headers):
response = client.get("/api/auth/me", headers=user_headers)
assert response.status_code == 200
data = response.json()
assert data["username"] == "testuser"
assert data["email"] == "testuser@example.com"
assert data["is_admin"] is False
assert "permissions" in data
def test_logout(self, client, user_headers, db_session):
# Verify authenticated endpoint works before logout
me_resp = client.get("/api/auth/me", headers=user_headers)
assert me_resp.status_code == 200
# Logout
logout_resp = client.post("/api/auth/logout", headers=user_headers)
assert logout_resp.status_code == 200
assert logout_resp.json()["message"] == "Successfully logged out"
# Verify request is rejected after logout because session is terminated
me_resp2 = client.get("/api/auth/me", headers=user_headers)
assert me_resp2.status_code == 401
def test_change_password(self, client, user_headers, db_session, seed_users):
response = client.post(
"/api/auth/change-password",
headers=user_headers,
data={"old_password": "UserPass123!", "new_password": "NewSecurePass123!"}
)
assert response.status_code == 200
assert response.json()["message"] == "Password changed successfully"
db_session.refresh(seed_users["user"])
assert verify_password("NewSecurePass123!", seed_users["user"].hashed_password)
def test_register_user_by_admin(self, client, admin_headers, db_session):
response = client.post(
"/api/auth/register",
headers=admin_headers,
json={
"username": "newuser",
"email": "newuser@example.com",
"full_name": "New User",
"password": "NewUserPassword123!"
}
)
assert response.status_code == 201
data = response.json()
assert data["username"] == "newuser"
assert data["email"] == "newuser@example.com"
user = db_session.query(User).filter(User.username == "newuser").first()
assert user is not None
def test_register_user_denied_for_standard_user(self, client, user_headers):
response = client.post(
"/api/auth/register",
headers=user_headers,
json={
"username": "anotheruser",
"email": "another@example.com",
"full_name": "Another User",
"password": "AnotherPassword123!"
}
)
assert response.status_code == 403
def test_list_users_by_admin(self, client, admin_headers):
response = client.get("/api/users", headers=admin_headers)
assert response.status_code == 200
data = response.json()
assert len(data) >= 2
def test_delete_user_by_admin(self, client, admin_headers, db_session, seed_users):
target_user = seed_users["user"]
response = client.delete(f"/api/users/{target_user.id}", headers=admin_headers)
assert response.status_code == 204
deleted_user = db_session.query(User).filter(User.id == target_user.id).first()
assert deleted_user is None

View File

@@ -0,0 +1,172 @@
import pytest
from app.models import ProxmoxServer, Workspace, WorkspaceServer, WorkspaceUser
@pytest.mark.integration
class TestAPIServers:
def test_list_servers_admin_sees_all(self, client, admin_headers, db_session):
# Create a test server
server = ProxmoxServer(
name="pve-admin-test",
hostname="pve1.example.com",
ip_address="192.168.1.50",
port=8006,
api_user="root@pam",
use_password=True,
password="securepassword",
verify_ssl=False
)
db_session.add(server)
db_session.commit()
# Admin lists servers
response = client.get("/proxmox/api/servers", headers=admin_headers)
assert response.status_code == 200
servers = response.json()
assert len(servers) >= 1
assert any(s["name"] == "pve-admin-test" for s in servers)
def test_list_servers_user_isolation(self, client, user_headers, seed_users, db_session):
# Create two test servers
server_assigned = ProxmoxServer(
name="pve-assigned",
hostname="pve2.example.com",
ip_address="192.168.1.51",
port=8006,
api_user="root@pam",
use_password=True,
password="securepassword",
verify_ssl=False
)
server_unassigned = ProxmoxServer(
name="pve-unassigned",
hostname="pve3.example.com",
ip_address="192.168.1.52",
port=8006,
api_user="root@pam",
use_password=True,
password="securepassword",
verify_ssl=False
)
db_session.add_all([server_assigned, server_unassigned])
db_session.commit()
# Non-assigned check: standard user lists servers, gets empty list
response = client.get("/proxmox/api/servers", headers=user_headers)
assert response.status_code == 200
assert len(response.json()) == 0
# Set up a shared Workspace
workspace = Workspace(name="Test Workspace", is_default=True)
db_session.add(workspace)
db_session.commit()
# Link the assigned server and the standard user to the workspace
ws_server = WorkspaceServer(workspace_id=workspace.id, server_id=server_assigned.id)
ws_user = WorkspaceUser(workspace_id=workspace.id, user_id=seed_users["user"].id)
db_session.add_all([ws_server, ws_user])
# Directly assign the server to the user as well
seed_users["user"].assigned_servers.append(server_assigned)
db_session.commit()
# Now standard user should see only the assigned server in their workspace
response2 = client.get("/proxmox/api/servers", headers=user_headers)
assert response2.status_code == 200
servers = response2.json()
assert len(servers) == 1
assert servers[0]["name"] == "pve-assigned"
def test_create_server_success(self, client, admin_headers, db_session, mock_proxmox):
response = client.post(
"/proxmox/api/servers",
headers=admin_headers,
json={
"name": "new-pve-server",
"hostname": "pve-new.example.com",
"ip_address": "192.168.1.53",
"port": 8006,
"api_user": "root@pam",
"use_password": True,
"password": "securepassword",
"verify_ssl": False,
"description": "Newly created test server"
}
)
assert response.status_code == 201
data = response.json()
assert data["name"] == "new-pve-server"
assert data["is_online"] is True # Set true because mock_proxmox connects successfully
# Verify added to database
server = db_session.query(ProxmoxServer).filter(ProxmoxServer.name == "new-pve-server").first()
assert server is not None
def test_create_server_duplicate_name(self, client, admin_headers, db_session, mock_proxmox):
# Create first server
server = ProxmoxServer(
name="duplicate-pve",
hostname="pve-dup1.example.com",
ip_address="192.168.1.54",
port=8006,
api_user="root@pam",
use_password=True,
password="securepassword",
verify_ssl=False
)
db_session.add(server)
db_session.commit()
# Try creating another server with the same name
response = client.post(
"/proxmox/api/servers",
headers=admin_headers,
json={
"name": "duplicate-pve",
"hostname": "pve-dup2.example.com",
"ip_address": "192.168.1.55",
"port": 8006,
"api_user": "root@pam",
"use_password": True,
"password": "securepassword",
"verify_ssl": False
}
)
assert response.status_code == 400
assert "already exists" in response.json()["detail"]
def test_create_server_permission_denied(self, client, user_headers):
response = client.post(
"/proxmox/api/servers",
headers=user_headers,
json={
"name": "unauthorized-pve",
"hostname": "pve-unauth.example.com",
"ip_address": "192.168.1.56",
"port": 8006,
"api_user": "root@pam",
"use_password": True,
"password": "securepassword",
"verify_ssl": False
}
)
assert response.status_code == 403
def test_get_server_detail(self, client, admin_headers, db_session):
server = ProxmoxServer(
name="pve-detail-test",
hostname="pve-detail.example.com",
ip_address="192.168.1.57",
port=8006,
api_user="root@pam",
use_password=True,
password="securepassword",
verify_ssl=False
)
db_session.add(server)
db_session.commit()
response = client.get(f"/proxmox/api/servers/{server.id}", headers=admin_headers)
assert response.status_code == 200
data = response.json()
assert data["name"] == "pve-detail-test"
assert "password" not in data # Sensitive fields must be excluded

View File

@@ -0,0 +1,126 @@
import pytest
from app.models import ProxmoxServer, VMInstance
@pytest.mark.integration
class TestAPIVirtualMachines:
@pytest.fixture(autouse=True)
def setup_servers_and_vms(self, db_session, seed_users):
"""Seed a Proxmox server and some VMInstances for testing."""
# Create server
self.server = ProxmoxServer(
name="pve-vm-test",
hostname="pve-vm.example.com",
ip_address="192.168.1.60",
port=8006,
api_user="root@pam",
use_password=True,
password="securepassword",
verify_ssl=False,
is_online=True
)
db_session.add(self.server)
db_session.commit()
db_session.refresh(self.server)
# Create user VM
self.user_vm = VMInstance(
server_id=self.server.id,
vmid=100,
node="pve1",
vm_type="qemu",
name="test-user-vm",
owner_id=seed_users["user"].id,
status="running"
)
# Create admin VM
self.admin_vm = VMInstance(
server_id=self.server.id,
vmid=101,
node="pve1",
vm_type="qemu",
name="test-admin-vm",
owner_id=seed_users["admin"].id,
status="running"
)
db_session.add_all([self.user_vm, self.admin_vm])
db_session.commit()
def test_get_vm_status_success(self, client, user_headers, mock_proxmox):
response = client.get(
f"/proxmox/api/{self.server.id}/vm/100/status?node=pve1",
headers=user_headers
)
assert response.status_code == 200
data = response.json()
assert data["status"] == "running"
mock_proxmox["client"].get_vm_status.assert_called_once_with("pve1", 100)
def test_get_vm_status_access_denied(self, client, user_headers, mock_proxmox, db_session, seed_users):
# Limited user tries to access admin's VM
# First restrict user's permissions to 'vms.view.own'
user_role = seed_users["user"].role
old_perms = dict(user_role.permissions)
# We need both 'vms.view' (to pass the endpoint route dependency)
# and 'vms.view.own' (to fail the `require_vm_access` owner check).
user_role.permissions = {"proxmox.view": True, "vms.view": True, "vms.view.own": True}
db_session.commit()
response = client.get(
f"/proxmox/api/{self.server.id}/vm/101/status?node=pve1",
headers=user_headers
)
# Restore permissions
user_role.permissions = old_perms
db_session.commit()
assert response.status_code == 403
assert "access to this virtual machine" in response.json()["detail"]
def test_get_vm_status_admin_bypass(self, client, admin_headers, mock_proxmox):
# Admin can access standard user's VM
response = client.get(
f"/proxmox/api/{self.server.id}/vm/100/status?node=pve1",
headers=admin_headers
)
assert response.status_code == 200
data = response.json()
assert data["status"] == "running"
def test_get_vm_config_success(self, client, user_headers, mock_proxmox):
response = client.get(
f"/proxmox/api/{self.server.id}/vm/100/config?node=pve1",
headers=user_headers
)
assert response.status_code == 200
data = response.json()
assert data["cores"] == 2
assert data["memory"] == 2048
mock_proxmox["client"].get_vm_config.assert_called_once_with("pve1", 100)
def test_get_vm_interfaces(self, client, user_headers, mock_proxmox):
response = client.get(
f"/proxmox/api/{self.server.id}/vm/100/interfaces?node=pve1",
headers=user_headers
)
assert response.status_code == 200
data = response.json()
assert len(data["interfaces"]) == 1
assert data["interfaces"][0]["ip"] == "192.168.1.100"
def test_delete_vm_success(self, client, admin_headers, mock_proxmox):
# Mock status check to indicate VM is stopped, otherwise it will try to stop it
mock_proxmox["client"].get_vm_status.return_value = {"status": "stopped"}
response = client.delete(
f"/proxmox/api/{self.server.id}/vm/100?node=pve1&force=true",
headers=admin_headers
)
assert response.status_code == 200
data = response.json()
assert data["status"] == "success"
# Verify ProxmoxClient delete method called
mock_proxmox["client"].delete_vm.assert_called_once_with("pve1", 100)

View File

@@ -2,8 +2,6 @@ services:
db:
image: postgres:16-alpine
container_name: pvemanager-db
ports:
- "5432:5432"
environment:
POSTGRES_DB: pvemanager
POSTGRES_USER: pvemanager
@@ -57,7 +55,6 @@ services:
- /etc/localtime:/etc/localtime:ro
# For system updates
- .:/project:rw
- /var/run/docker.sock:/var/run/docker.sock
restart: unless-stopped
environment:
- DATABASE_URL=postgresql://pvemanager:${POSTGRES_PASSWORD:-pvemanager_secure_password}@db:5432/pvemanager
@@ -97,7 +94,7 @@ services:
pull_policy: build
container_name: pvemanager-frontend
ports:
- "3000:80"
- "3001:80"
depends_on:
app:
condition: service_healthy

20
fix_crud.py Normal file
View File

@@ -0,0 +1,20 @@
import sys
with open('backend/app/api/proxmox/vms.py.bak', 'r') as f:
lines = f.readlines()
cache_lines = lines[27:30]
func_lines = lines[55:277]
with open('backend/app/api/proxmox/vms/crud.py', 'r') as f:
crud_content = f.read()
# Insert before the first route, or at the end. Let's insert after _get_client_or_503
insert_idx = crud_content.find('@router.delete("/api/{server_id}/vm/{vmid}")')
new_content = crud_content[:insert_idx] + "".join(cache_lines) + "\n" + "".join(func_lines) + "\n\n" + crud_content[insert_idx:]
with open('backend/app/api/proxmox/vms/crud.py', 'w') as f:
f.write(new_content)
print("Fixed crud.py")