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