Files
pvemanager/backend/migrations/migrations.py
Dilshod Rasulov 6796903712
Some checks failed
Backend Tests / pytest (Python 3.12) (push) Failing after 1m46s
Deploy to Production / Trigger production update (push) Failing after 10s
SonarCloud / SonarCloud Analysis (push) Failing after 3m7s
feat(ipam): several IP addresses per instance
A guest was implicitly limited to a single allocation: lookups were
"(server, vmid) -> one row", release_ip_by_vmid freed one address, and
the link tool skipped guests that already had one. An instance can now
hold a primary address plus any number of aliases, managed from the
Network tab of the instance and visible on the IPAM pages.

Schema and service:
- ipam_allocations gains is_primary, assignment_kind, target_interface
  and apply_* columns; migration 43 marks existing rows as primary
- find_allocations_by_resource/set_primary; release_ip_by_vmid now frees
  every address of a deleted guest instead of just the first one
- next-available search may scan the whole subnet when a network has no
  auto-assign pool, skipping node and cached guest addresses so it never
  hands out an address that is already live

Applying to the guest (services/guest_ip_service.py):
- reuses script_engine, so LXC goes through pct exec and VMs through the
  guest agent; the shell script synchronises the whole alias list of an
  interface, which makes add, remove and retry the same code path
- persists via ifupdown, netplan, NetworkManager or sysconfig and never
  restarts guest networking; an unknown stack degrades to runtime_only
  and an unreachable guest keeps the allocation as pending

API and UI:
- /ipam/api/guests/{server_id}/{vmid}/addresses: list, add, apply,
  primary, delete; allocations list accepts guest filters
- instance list exposes ips[] alongside the primary ip
- addresses card on the Network tab, "+N" badge in the IP column,
  primary badge in the allocations table

Fixes found along the way:
- link-allocations registers every address of a guest, carries the MAC
  over, refuses to take an address away from another live guest and no
  longer matches allocations by resource name (which leaked an address
  between same-named guests on different servers)
- IPAM history no longer fails to serialise datetime values, which used
  to abort re-linking with a 500
2026-08-21 23:48:20 +05:00

2327 lines
88 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
PVEmanager Database Migrations
==============================
Unified migration file for all database schema changes.
This file contains all migrations in order:
1. Notifications system (tables: notifications, notification_preferences)
2. RBAC and Security (tables: roles, active_sessions, login_attempts, blocked_ips, security_settings)
3. Cross-node Template Support (columns in os_templates)
4. VM Cache (columns in vm_instances)
5. Task Queue
6. OS Template Icons
7. VM Template Name
8. Enhanced Audit Logs
9. RBAC v2 - Migrate to new permission format
Usage:
from migrations.migrations import run_all_migrations
run_all_migrations(engine, db_session)
"""
import json
from sqlalchemy import text
from loguru import logger
# ==================== Default Data ====================
DEFAULT_ROLES = [
{
"name": "admin",
"display_name": "Administrator",
"description": "Full access to all features",
"is_system": True,
"permissions": {
"dashboard.view": True,
"proxmox.view": True,
"proxmox.manage": True,
"proxmox.servers.add": True,
"proxmox.servers.edit": True,
"proxmox.servers.delete": True,
"vms.view": True,
"vms.create": True,
"vms.start": True,
"vms.stop": True,
"vms.restart": True,
"vms.delete": True,
"vms.console": True,
"vms.migrate": True,
"templates.view": True,
"templates.manage": True,
"ipam.view": True,
"ipam.manage": True,
"logs.view": True,
"logs.export": True,
"logs.delete": True,
"settings.view": True,
"settings.panel": True,
"settings.security": True,
"users.view": True,
"users.create": True,
"users.edit": True,
"users.delete": True,
"quota:view": True,
"quota:manage": True,
"roles.view": True,
"roles.manage": True,
"notifications.manage": True,
"backup:view": True,
"backup:create": True,
"backup:restore": True,
"backup:delete": True,
"backup:manage": True,
}
},
{
"name": "moderator",
"display_name": "Moderator",
"description": "Can manage VMs and view logs",
"is_system": True,
"permissions": {
"dashboard.view": True,
"proxmox.view": True,
"proxmox.manage": False,
"proxmox.servers.add": False,
"proxmox.servers.edit": False,
"proxmox.servers.delete": False,
"vms.view": True,
"vms.create": True,
"vms.start": True,
"vms.stop": True,
"vms.restart": True,
"vms.delete": False,
"vms.console": True,
"vms.migrate": False,
"templates.view": True,
"templates.manage": False,
"ipam.view": True,
"ipam.manage": False,
"logs.view": True,
"logs.export": True,
"logs.delete": False,
"settings.view": True,
"settings.panel": False,
"settings.security": False,
"users.view": True,
"users.create": False,
"users.edit": False,
"users.delete": False,
"roles.view": False,
"roles.manage": False,
"notifications.manage": True,
"backup:view": True,
"backup:create": True,
"backup:restore": True,
"backup:delete": False,
"backup:manage": False,
}
},
{
"name": "user",
"display_name": "User",
"description": "VPS-style user: can only access their own instances",
"is_system": True,
"permissions": {
"dashboard.view": False,
"proxmox.view": True,
"proxmox.manage": False,
"proxmox.servers.add": False,
"proxmox.servers.edit": False,
"proxmox.servers.delete": False,
"vms.view": False,
"vms:view:own": True,
"vms.create": False,
"vms.start": True,
"vms:start:own": True,
"vms.stop": True,
"vms:stop:own": True,
"vms.restart": True,
"vms:restart:own": True,
"vms.delete": False,
"vms.console": True,
"vms:console:own": True,
"vms:snapshots:own": True,
"vms.migrate": False,
"templates.view": True,
"templates.manage": False,
"ipam.view": False,
"ipam.manage": False,
"logs.view": False,
"logs.export": False,
"logs.delete": False,
"settings.view": True,
"settings.panel": False,
"settings.security": False,
"users.view": False,
"users.create": False,
"users.edit": False,
"users.delete": False,
"roles.view": False,
"roles.manage": False,
"notifications.manage": True,
}
},
{
"name": "demo",
"display_name": "Demo User",
"description": "Read-only access for demonstration",
"is_system": True,
"permissions": {
"dashboard.view": True,
"proxmox.view": True,
"proxmox.manage": False,
"proxmox.servers.add": False,
"proxmox.servers.edit": False,
"proxmox.servers.delete": False,
"vms.view": True,
"vms.create": False,
"vms.start": False,
"vms.stop": False,
"vms.restart": False,
"vms.delete": False,
"vms.console": False,
"vms.migrate": False,
"templates.view": True,
"templates.manage": False,
"ipam.view": True,
"ipam.manage": False,
"logs.view": False,
"logs.export": False,
"logs.delete": False,
"settings.view": False,
"settings.panel": False,
"settings.security": False,
"users.view": False,
"users.create": False,
"users.edit": False,
"users.delete": False,
"roles.view": False,
"roles.manage": False,
"notifications.manage": False,
}
},
]
SECURITY_DEFAULTS = [
("max_login_attempts", "5", "Maximum failed login attempts before lockout"),
("lockout_duration_minutes", "30", "Account lockout duration in minutes"),
("session_timeout_minutes", "60", "Session timeout in minutes"),
("single_session_enabled", "false", "Allow only one active session per user"),
("ip_block_threshold", "10", "Failed attempts before IP is blocked"),
("ip_block_duration_minutes", "60", "IP block duration in minutes"),
("password_min_length", "8", "Minimum password length"),
("password_require_uppercase", "true", "Require uppercase letters in password"),
("password_require_lowercase", "true", "Require lowercase letters in password"),
("password_require_numbers", "true", "Require numbers in password"),
("password_require_special", "false", "Require special characters in password"),
("password_expiry_days", "0", "Password expiry in days (0 = never)"),
("api_rate_limit_per_minute", "60", "API rate limit per minute per user"),
]
# ==================== Helper Functions ====================
def table_exists(conn, table_name: str) -> bool:
"""Check if a table exists"""
result = conn.execute(text(f"""
SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_name = '{table_name}'
)
"""))
return result.scalar()
def column_exists(conn, table_name: str, column_name: str) -> bool:
"""Check if a column exists in a table"""
result = conn.execute(text(f"""
SELECT column_name
FROM information_schema.columns
WHERE table_name = '{table_name}' AND column_name = '{column_name}'
"""))
return result.fetchone() is not None
def add_column_if_not_exists(conn, table_name: str, column_name: str, column_type: str):
"""Add a column if it doesn't exist"""
if not column_exists(conn, table_name, column_name):
conn.execute(text(f"ALTER TABLE {table_name} ADD COLUMN {column_name} {column_type}"))
logger.debug(f"Added column {table_name}.{column_name}")
return True
return False
# ==================== Migration 1: Notifications ====================
def migrate_notifications(conn):
"""Create notifications system tables"""
if table_exists(conn, 'notifications'):
logger.info("✓ Notifications tables already exist")
return
logger.info("Creating notifications tables...")
conn.execute(text("""
CREATE TABLE IF NOT EXISTS notifications (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
type VARCHAR(50) NOT NULL,
level VARCHAR(20) NOT NULL CHECK (level IN ('critical', 'warning', 'info', 'success')),
title VARCHAR(255) NOT NULL,
message TEXT,
data JSONB,
link VARCHAR(500),
source VARCHAR(50),
source_id VARCHAR(100),
read BOOLEAN DEFAULT FALSE,
read_at TIMESTAMP,
created_at TIMESTAMP DEFAULT NOW(),
expires_at TIMESTAMP
)
"""))
conn.execute(text("""
CREATE INDEX IF NOT EXISTS idx_notifications_user ON notifications(user_id, read, created_at DESC)
"""))
conn.execute(text("""
CREATE INDEX IF NOT EXISTS idx_notifications_level ON notifications(level, created_at DESC)
"""))
conn.execute(text("""
CREATE INDEX IF NOT EXISTS idx_notifications_created ON notifications(created_at DESC)
"""))
conn.execute(text("""
CREATE TABLE IF NOT EXISTS notification_preferences (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id) UNIQUE ON DELETE CASCADE,
enabled BOOLEAN DEFAULT TRUE,
email_enabled BOOLEAN DEFAULT FALSE,
email_critical_only BOOLEAN DEFAULT TRUE,
telegram_enabled BOOLEAN DEFAULT FALSE,
telegram_chat_id VARCHAR(100),
webhook_url VARCHAR(500),
notification_levels JSONB DEFAULT '["critical", "warning", "info", "success"]',
notification_types JSONB DEFAULT '["vm_status", "resource_alert", "system", "ipam", "template"]',
quiet_hours_start TIME,
quiet_hours_end TIME,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
)
"""))
conn.execute(text("""
CREATE INDEX IF NOT EXISTS idx_notification_preferences_user ON notification_preferences(user_id)
"""))
logger.info("✓ Notifications tables created")
# ==================== Migration 2: RBAC & Security ====================
def migrate_rbac_security(conn):
"""Create RBAC and security tables"""
# Roles table
if not table_exists(conn, 'roles'):
logger.info("Creating roles table...")
conn.execute(text("""
CREATE TABLE IF NOT EXISTS roles (
id SERIAL PRIMARY KEY,
name VARCHAR(50) UNIQUE NOT NULL,
display_name VARCHAR(100) NOT NULL,
description TEXT,
permissions JSONB NOT NULL DEFAULT '{}',
is_system BOOLEAN DEFAULT FALSE,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
)
"""))
logger.info("✓ Roles table created")
# Active sessions table
if not table_exists(conn, 'active_sessions'):
logger.info("Creating active_sessions table...")
conn.execute(text("""
CREATE TABLE IF NOT EXISTS active_sessions (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
session_token VARCHAR(64) UNIQUE NOT NULL,
ip_address VARCHAR(45),
user_agent VARCHAR(500),
device_info VARCHAR(200),
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
last_activity TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
is_active BOOLEAN DEFAULT TRUE
)
"""))
logger.info("✓ Active sessions table created")
# Login attempts table
if not table_exists(conn, 'login_attempts'):
logger.info("Creating login_attempts table...")
conn.execute(text("""
CREATE TABLE IF NOT EXISTS login_attempts (
id SERIAL PRIMARY KEY,
username VARCHAR(100),
ip_address VARCHAR(45) NOT NULL,
user_agent VARCHAR(500),
success BOOLEAN DEFAULT FALSE,
failure_reason VARCHAR(200),
attempted_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
)
"""))
logger.info("✓ Login attempts table created")
# Blocked IPs table
if not table_exists(conn, 'blocked_ips'):
logger.info("Creating blocked_ips table...")
conn.execute(text("""
CREATE TABLE IF NOT EXISTS blocked_ips (
id SERIAL PRIMARY KEY,
ip_address VARCHAR(45) UNIQUE NOT NULL,
reason VARCHAR(500),
blocked_by VARCHAR(100),
blocked_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
expires_at TIMESTAMP WITH TIME ZONE,
is_permanent BOOLEAN DEFAULT FALSE,
attempts_count INTEGER DEFAULT 0
)
"""))
logger.info("✓ Blocked IPs table created")
# Security settings table
if not table_exists(conn, 'security_settings'):
logger.info("Creating security_settings table...")
conn.execute(text("""
CREATE TABLE IF NOT EXISTS security_settings (
id SERIAL PRIMARY KEY,
key VARCHAR(100) UNIQUE NOT NULL,
value VARCHAR(500) NOT NULL,
description TEXT,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
)
"""))
logger.info("✓ Security settings table created")
# Add columns to users table
user_columns = [
("role_id", "INTEGER REFERENCES roles(id)"),
("failed_login_attempts", "INTEGER DEFAULT 0"),
("locked_until", "TIMESTAMP WITH TIME ZONE"),
("last_password_change", "TIMESTAMP WITH TIME ZONE"),
("require_password_change", "BOOLEAN DEFAULT FALSE"),
("two_factor_enabled", "BOOLEAN DEFAULT FALSE"),
("two_factor_secret", "VARCHAR(100)"),
]
for col_name, col_type in user_columns:
try:
add_column_if_not_exists(conn, 'users', col_name, col_type)
except Exception as e:
logger.debug(f"Column users.{col_name}: {e}")
# Create indexes
indexes = [
("idx_sessions_user", "active_sessions(user_id)"),
("idx_sessions_token", "active_sessions(session_token)"),
("idx_sessions_expires", "active_sessions(expires_at)"),
("idx_login_attempts_ip", "login_attempts(ip_address)"),
("idx_login_attempts_time", "login_attempts(attempted_at)"),
("idx_blocked_ips_ip", "blocked_ips(ip_address)"),
("idx_blocked_ips_expires", "blocked_ips(expires_at)"),
("idx_users_role", "users(role_id)"),
]
for idx_name, idx_def in indexes:
try:
conn.execute(text(f"CREATE INDEX IF NOT EXISTS {idx_name} ON {idx_def}"))
except Exception as e:
logger.debug(f"Index {idx_name}: {e}")
# Insert default roles
for role in DEFAULT_ROLES:
conn.execute(text("""
INSERT INTO roles (name, display_name, description, permissions, is_system, is_active)
VALUES (:name, :display_name, :description, :permissions, :is_system, TRUE)
ON CONFLICT (name) DO NOTHING
"""), {
"name": role["name"],
"display_name": role["display_name"],
"description": role["description"],
"permissions": json.dumps(role["permissions"]),
"is_system": role["is_system"]
})
# Insert default security settings
for key, value, description in SECURITY_DEFAULTS:
conn.execute(text("""
INSERT INTO security_settings (key, value, description)
VALUES (:key, :value, :description)
ON CONFLICT (key) DO NOTHING
"""), {"key": key, "value": value, "description": description})
# Set admin role for existing admin users
conn.execute(text("""
UPDATE users
SET role_id = (SELECT id FROM roles WHERE name = 'admin')
WHERE is_admin = TRUE AND role_id IS NULL
"""))
# Set user role for non-admin users
conn.execute(text("""
UPDATE users
SET role_id = (SELECT id FROM roles WHERE name = 'user')
WHERE is_admin = FALSE AND role_id IS NULL
"""))
logger.info("✓ RBAC and security migration completed")
# ==================== Migration 3: Cross-node Templates ====================
def migrate_cross_node_templates(conn):
"""Add cross-node template support"""
if not table_exists(conn, 'os_templates'):
logger.info("Table os_templates does not exist, skipping cross-node migration")
return
# Add source_node column
if add_column_if_not_exists(conn, 'os_templates', 'source_node', 'VARCHAR(100)'):
# Copy existing node values to source_node
conn.execute(text("""
UPDATE os_templates
SET source_node = node
WHERE source_node IS NULL
"""))
logger.info("✓ Added source_node column to os_templates")
# Add replicated_nodes column
if add_column_if_not_exists(conn, 'os_templates', 'replicated_nodes', "JSONB DEFAULT '{}'::jsonb"):
logger.info("✓ Added replicated_nodes column to os_templates")
# Add vm_type column (qemu/lxc) — existing rows default to qemu
if add_column_if_not_exists(conn, 'os_templates', 'vm_type', "VARCHAR(10) NOT NULL DEFAULT 'qemu'"):
logger.info("✓ Added vm_type column to os_templates")
# Add volid column for LXC vztmpl file-based templates
if add_column_if_not_exists(conn, 'os_templates', 'volid', "VARCHAR(500)"):
logger.info("✓ Added volid column to os_templates")
# vmid must be nullable for vztmpl-based templates
try:
conn.execute(text("ALTER TABLE os_templates ALTER COLUMN vmid DROP NOT NULL"))
except Exception:
pass
# Make node column nullable
try:
conn.execute(text("""
ALTER TABLE os_templates
ALTER COLUMN node DROP NOT NULL
"""))
except Exception:
pass # Column might already be nullable
logger.info("✓ Cross-node template migration completed")
# ==================== Migration 4: VM Cache ====================
def migrate_vm_cache(conn):
"""Add VM cache fields to vm_instances table"""
if not table_exists(conn, 'vm_instances'):
logger.info("Table vm_instances does not exist, skipping VM cache migration")
return
# Add new columns
cache_columns = [
("status", "VARCHAR(20) DEFAULT 'unknown'"),
("is_template", "BOOLEAN DEFAULT FALSE"),
("os_type", "VARCHAR(50)"),
("last_sync_at", "TIMESTAMP WITH TIME ZONE"),
]
for col_name, col_type in cache_columns:
add_column_if_not_exists(conn, 'vm_instances', col_name, col_type)
# Alter memory and disk_size to BigInteger
try:
conn.execute(text("""
ALTER TABLE vm_instances
ALTER COLUMN memory TYPE BIGINT
"""))
except Exception:
pass
try:
conn.execute(text("""
ALTER TABLE vm_instances
ALTER COLUMN disk_size TYPE BIGINT
"""))
except Exception:
pass
logger.info("✓ VM cache migration completed")
# ==================== Migration 5: Task Queue ====================
def migrate_task_queue(conn):
"""Create task_queue table for bulk operations"""
if table_exists(conn, 'task_queue'):
logger.info("Table task_queue already exists, skipping")
return
conn.execute(text("""
CREATE TABLE task_queue (
id SERIAL PRIMARY KEY,
task_type VARCHAR(50) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'pending',
user_id INTEGER NOT NULL,
total_items INTEGER NOT NULL DEFAULT 0,
completed_items INTEGER NOT NULL DEFAULT 0,
failed_items INTEGER NOT NULL DEFAULT 0,
task_data JSONB NOT NULL,
results JSONB,
error_message TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL,
started_at TIMESTAMP WITH TIME ZONE,
completed_at TIMESTAMP WITH TIME ZONE
)
"""))
# Create indexes
conn.execute(text("""
CREATE INDEX idx_task_queue_task_type ON task_queue (task_type)
"""))
conn.execute(text("""
CREATE INDEX idx_task_queue_status ON task_queue (status)
"""))
conn.execute(text("""
CREATE INDEX idx_task_queue_user_id ON task_queue (user_id)
"""))
conn.execute(text("""
CREATE INDEX idx_task_status_created ON task_queue (status, created_at)
"""))
conn.execute(text("""
CREATE INDEX idx_task_user_status ON task_queue (user_id, status)
"""))
logger.info("✓ Task queue table created")
# ==================== Migration 6: Update OS Template Group Icons ====================
# Маппинг названий групп на новые CSS-адаптивные иконки
OS_ICON_MAPPING = {
'Ubuntu': '<i class="fa-brands fa-ubuntu os-icon os-icon-ubuntu"></i>',
'Debian': '<i class="fa-brands fa-debian os-icon os-icon-debian"></i>',
'CentOS': '<i class="fa-brands fa-centos os-icon os-icon-centos"></i>',
'Rocky Linux': '<span class="os-icon-rocky-svg"></span>',
'AlmaLinux': '<span class="os-icon-alma-svg"></span>',
'Fedora': '<i class="fa-brands fa-fedora os-icon os-icon-fedora"></i>',
'RHEL': '<i class="fa-brands fa-redhat os-icon os-icon-rhel"></i>',
'Oracle Linux': '<i class="fa-brands fa-linux os-icon os-icon-oracle"></i>',
'openSUSE': '<i class="fa-brands fa-suse os-icon os-icon-suse"></i>',
'Arch Linux': '<i class="fa-brands fa-linux os-icon os-icon-arch"></i>',
'Gentoo': '<i class="fa-brands fa-linux os-icon os-icon-gentoo"></i>',
'Alpine': '<i class="fa-brands fa-linux os-icon os-icon-alpine"></i>',
'Windows': '<i class="fa-brands fa-windows os-icon os-icon-windows"></i>',
'FreeBSD': '<i class="fa-brands fa-freebsd os-icon os-icon-freebsd"></i>',
'OpenBSD': '<i class="fa-brands fa-linux os-icon os-icon-openbsd"></i>',
'Other': '<i class="fa-solid fa-compact-disc os-icon os-icon-other"></i>',
}
def migrate_os_template_icons(conn):
"""Update OS template group icons to theme-adaptive CSS classes"""
logger.info("Migration 6: Updating OS template group icons...")
# Check if os_template_groups table exists
result = conn.execute(text("""
SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_name = 'os_template_groups'
)
"""))
if not result.scalar():
logger.info("os_template_groups table does not exist, skipping icon migration")
return
# First, ensure the icon column is large enough for HTML
try:
conn.execute(text("""
ALTER TABLE os_template_groups ALTER COLUMN icon TYPE VARCHAR(200)
"""))
logger.info("Extended icon column to VARCHAR(200)")
except Exception as e:
logger.debug(f"Icon column already extended or error: {e}")
# Update icons for each known group name
for group_name, icon_html in OS_ICON_MAPPING.items():
try:
conn.execute(text("""
UPDATE os_template_groups
SET icon = :icon
WHERE name = :name
"""), {"icon": icon_html, "name": group_name})
logger.debug(f"Updated icon for group: {group_name}")
except Exception as e:
logger.warning(f"Failed to update icon for {group_name}: {e}")
logger.info("✓ OS template group icons updated")
# ==================== Migration 7: VM Template Name ====================
def migrate_vm_template_name(conn):
"""Add template_name column to vm_instances for caching template name"""
logger.info("Migration 7: Adding template_name to vm_instances...")
if not table_exists(conn, 'vm_instances'):
logger.info("Table vm_instances does not exist, skipping")
return
add_column_if_not_exists(conn, 'vm_instances', 'template_name', 'VARCHAR(100)')
logger.info("✓ VM template_name migration completed")
# ==================== Migration 8: Enhanced Audit Logs ====================
def migrate_enhanced_audit_logs(conn):
"""Add enhanced logging columns to audit_logs table"""
logger.info("Migration 8: Enhancing audit_logs table...")
if not table_exists(conn, 'audit_logs'):
logger.info("Table audit_logs does not exist, skipping")
return
# New context columns
add_column_if_not_exists(conn, 'audit_logs', 'request_id', 'VARCHAR(36)')
add_column_if_not_exists(conn, 'audit_logs', 'session_id', 'VARCHAR(64)')
# Enhanced user tracking
add_column_if_not_exists(conn, 'audit_logs', 'geo_location', 'VARCHAR(100)')
# Proxmox server context
add_column_if_not_exists(conn, 'audit_logs', 'server_id', 'INTEGER')
add_column_if_not_exists(conn, 'audit_logs', 'server_name', 'VARCHAR(100)')
add_column_if_not_exists(conn, 'audit_logs', 'node_name', 'VARCHAR(100)')
# Request/Response details
add_column_if_not_exists(conn, 'audit_logs', 'request_body', 'JSONB')
add_column_if_not_exists(conn, 'audit_logs', 'response_body', 'JSONB')
add_column_if_not_exists(conn, 'audit_logs', 'query_params', 'VARCHAR(1000)')
# Error details
add_column_if_not_exists(conn, 'audit_logs', 'error_traceback', 'TEXT')
# Create new indexes
try:
conn.execute(text(
"CREATE INDEX IF NOT EXISTS idx_audit_request_id ON audit_logs(request_id)"
))
except:
pass
try:
conn.execute(text(
"CREATE INDEX IF NOT EXISTS idx_audit_ip ON audit_logs(ip_address)"
))
except:
pass
try:
conn.execute(text(
"CREATE INDEX IF NOT EXISTS idx_audit_server ON audit_logs(server_id)"
))
except:
pass
logger.info("✓ Enhanced audit_logs migration completed")
# ==================== Migration 9: RBAC v2 - New Permission Format ====================
def migrate_rbac_v2(conn):
"""
Migrate roles to new RBAC v2 permission format.
Converts old format (resource.action) to new format (resource:action)
"""
logger.info("Migration 9: Migrating to RBAC v2 permission format...")
if not table_exists(conn, 'roles'):
logger.info("Table roles does not exist, skipping RBAC v2 migration")
return
# Import migration utilities
try:
from app.rbac.migration import (
migrate_all_roles_to_new_format,
ensure_default_roles_new_format
)
except ImportError:
try:
from backend.app.rbac.migration import (
migrate_all_roles_to_new_format,
ensure_default_roles_new_format
)
except ImportError:
logger.warning("Could not import RBAC migration utilities, skipping v2 migration")
return
# Migrate existing roles
migrated_count = migrate_all_roles_to_new_format(conn)
logger.info(f"Migrated {migrated_count} roles to new permission format")
# Ensure default roles have new format
ensure_default_roles_new_format(conn)
logger.info("✓ RBAC v2 migration completed")
# ==================== Migration 10: VM Instance Owner ====================
def migrate_vm_instance_owner(conn):
"""
Add owner_id column to vm_instances for VPS-style user isolation.
Users with 'user' role will only see their own instances.
"""
logger.info("Migration 10: Adding VM instance owner support...")
if not table_exists(conn, 'vm_instances'):
logger.info("Table vm_instances does not exist, skipping owner migration")
return
# Add owner_id column
if not column_exists(conn, 'vm_instances', 'owner_id'):
logger.info("Adding owner_id column to vm_instances...")
try:
conn.execute(text(
"""ALTER TABLE vm_instances
ADD COLUMN owner_id INTEGER REFERENCES users(id) ON DELETE SET NULL"""
))
logger.info("✓ Added owner_id column")
except Exception as e:
logger.warning(f"Could not add owner_id column: {e}")
else:
logger.info("Column owner_id already exists")
# Create index for owner_id
try:
conn.execute(text(
"CREATE INDEX IF NOT EXISTS idx_vm_instance_owner ON vm_instances(owner_id)"
))
logger.info("✓ Created index on owner_id")
except Exception as e:
logger.warning(f"Could not create owner_id index: {e}")
# Add VPS-style permissions to user role (idempotent: only add missing keys)
try:
result = conn.execute(text("SELECT permissions FROM roles WHERE name = 'user'"))
row = result.fetchone()
if row:
import json
permissions = row[0] if isinstance(row[0], dict) else json.loads(row[0] or '{}')
vps_permissions = {
"vms:view:own": True, # View only own VMs
"vms:start:own": True, # Start own VMs
"vms:stop:own": True, # Stop own VMs
"vms:restart:own": True, # Restart own VMs
"vms:console:own": True, # Console to own VMs
"vms:snapshots:own": True, # Manage snapshots of own VMs
}
# Only add keys that are not already present — never overwrite existing values
missing = {k: v for k, v in vps_permissions.items() if k not in permissions}
if missing:
permissions.update(missing)
conn.execute(
text("UPDATE roles SET permissions = :perms, updated_at = NOW() WHERE name = 'user'"),
{"perms": json.dumps(permissions)}
)
logger.info(f"✓ Added missing VPS permissions to user role: {list(missing.keys())}")
else:
logger.info("✓ User role already has VPS instance ownership permissions, skipping")
except Exception as e:
logger.warning(f"Could not update user role permissions: {e}")
logger.info("✓ VM instance owner migration completed")
# ==================== Migration 11: Snapshot Archives ====================
def migrate_snapshot_archives(conn):
"""
Create vm_snapshot_archives table to store snapshot configurations
before VM/container deletion for audit and recovery purposes.
"""
logger.info("Migration 11: Creating snapshot archives table...")
if table_exists(conn, 'vm_snapshot_archives'):
logger.info("Table vm_snapshot_archives already exists, skipping")
return
conn.execute(text("""
CREATE TABLE vm_snapshot_archives (
id SERIAL PRIMARY KEY,
server_id INTEGER NOT NULL,
server_name VARCHAR(100),
vmid INTEGER NOT NULL,
vm_name VARCHAR(100),
vm_type VARCHAR(20) NOT NULL,
node VARCHAR(100) NOT NULL,
snapname VARCHAR(100) NOT NULL,
description TEXT,
snaptime BIGINT,
parent VARCHAR(100),
vmstate BOOLEAN NOT NULL DEFAULT FALSE,
snapshot_config JSONB,
deleted_by VARCHAR(100),
deletion_reason TEXT,
archived_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL
)
"""))
# Create indexes
conn.execute(text("""
CREATE INDEX idx_snapshot_archive_server_vmid ON vm_snapshot_archives(server_id, vmid)
"""))
conn.execute(text("""
CREATE INDEX idx_snapshot_archive_archived ON vm_snapshot_archives(archived_at)
"""))
conn.execute(text("""
CREATE INDEX idx_snapshot_archive_vmid ON vm_snapshot_archives(vmid)
"""))
logger.info("✓ Snapshot archives table created")
# ==================== Migration 12: User SSH Public Key ====================
def migrate_user_ssh_key(conn):
"""Add SSH public key column to users table"""
if column_exists(conn, 'users', 'ssh_public_key'):
logger.info("✓ User SSH public key column already exists")
return
logger.info("Adding SSH public key column to users table...")
add_column_if_not_exists(conn, 'users', 'ssh_public_key', 'TEXT')
logger.info("✓ User SSH public key column added")
# ==================== Migration 13: Git Repository URL Setting ====================
def migrate_git_repository_setting(conn):
"""Add git repository URL setting to panel_settings"""
logger.info("Adding git repository URL setting...")
# Check if setting already exists
result = conn.execute(text(
"SELECT COUNT(*) FROM panel_settings WHERE key = 'git_repository_url'"
)).scalar()
if result > 0:
logger.info("✓ Git repository URL setting already exists")
return
# Insert default repository setting
conn.execute(text("""
INSERT INTO panel_settings (key, value, description, created_at, updated_at)
VALUES (
'git_repository_url',
'https://git.tzim.uz/markmorado/pvemanager',
'Git repository URL for panel updates',
NOW(),
NOW()
)
"""))
logger.info("✓ Git repository URL setting added")
# ==================== Main Migration Function ====================
def migrate_encrypt_passwords(conn):
"""Migration 14: Encrypt plaintext passwords stored in ProxmoxServer and VMInstance.
Idempotent: already-encrypted values (valid Fernet tokens) are skipped.
Skipped entirely when FERNET_KEY env var is not set.
"""
import os
try:
from cryptography.fernet import Fernet, InvalidToken
except ImportError:
logger.warning("Migration 14: cryptography package not available, skipping")
return
fernet_key = os.environ.get("FERNET_KEY")
if not fernet_key:
logger.info("Migration 14: FERNET_KEY not set, skipping password encryption")
return
f = Fernet(fernet_key.encode())
def _is_encrypted(value: str) -> bool:
try:
f.decrypt(value.encode())
return True
except (InvalidToken, Exception):
return False
# --- ProxmoxServer.password ---
rows = conn.execute(
text("SELECT id, password FROM proxmox_servers WHERE password IS NOT NULL AND password != ''")
).fetchall()
count = 0
for row in rows:
if not _is_encrypted(row.password):
encrypted = f.encrypt(row.password.encode()).decode()
conn.execute(
text("UPDATE proxmox_servers SET password = :p WHERE id = :id"),
{"p": encrypted, "id": row.id}
)
count += 1
if count:
logger.info(f"Migration 14: Encrypted {count} Proxmox server password(s)")
# --- VMInstance.cloud_init_password ---
rows = conn.execute(
text("SELECT id, cloud_init_password FROM vm_instances WHERE cloud_init_password IS NOT NULL AND cloud_init_password != ''")
).fetchall()
count = 0
for row in rows:
if not _is_encrypted(row.cloud_init_password):
encrypted = f.encrypt(row.cloud_init_password.encode()).decode()
conn.execute(
text("UPDATE vm_instances SET cloud_init_password = :p WHERE id = :id"),
{"p": encrypted, "id": row.id}
)
count += 1
if count:
logger.info(f"Migration 14: Encrypted {count} VM cloud-init password(s)")
def migrate_backup_jobs(conn):
"""Migration 15: Create backup_jobs table for scheduled backups"""
logger.info("Migration 15: Creating backup_jobs table...")
if table_exists(conn, 'backup_jobs'):
logger.info("Table backup_jobs already exists, skipping")
return
conn.execute(text("""
CREATE TABLE backup_jobs (
id SERIAL PRIMARY KEY,
server_id INTEGER NOT NULL REFERENCES proxmox_servers(id) ON DELETE CASCADE,
node VARCHAR(100) NOT NULL,
vmids JSONB NOT NULL DEFAULT '[]',
storage VARCHAR(100) NOT NULL,
mode VARCHAR(20) NOT NULL DEFAULT 'snapshot',
compress VARCHAR(20) NOT NULL DEFAULT 'zstd',
notes VARCHAR(500),
keep_last INTEGER NOT NULL DEFAULT 3,
keep_daily INTEGER NOT NULL DEFAULT 7,
keep_weekly INTEGER NOT NULL DEFAULT 4,
keep_monthly INTEGER NOT NULL DEFAULT 6,
cron_expression VARCHAR(100) NOT NULL,
enabled BOOLEAN NOT NULL DEFAULT TRUE,
owner_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL,
last_run_at TIMESTAMP WITH TIME ZONE,
last_status VARCHAR(20),
last_error TEXT,
last_upid VARCHAR(200)
)
"""))
conn.execute(text("CREATE INDEX idx_backup_jobs_server ON backup_jobs(server_id)"))
conn.execute(text("CREATE INDEX idx_backup_jobs_enabled ON backup_jobs(enabled)"))
conn.execute(text("CREATE INDEX idx_backup_jobs_owner ON backup_jobs(owner_id)"))
logger.info("✓ backup_jobs table created")
# ==================== Migration 16: IPAM Network Node ====================
def migrate_ipam_network_node(conn):
"""Add proxmox_node column to ipam_networks table"""
if not table_exists(conn, 'ipam_networks'):
logger.info("Table ipam_networks does not exist, skipping")
return
if add_column_if_not_exists(conn, 'ipam_networks', 'proxmox_node', 'VARCHAR(100)'):
logger.info("✓ Added proxmox_node column to ipam_networks")
else:
logger.info("✓ ipam_networks.proxmox_node already exists")
def migrate_cluster_support(conn):
"""Migration 17: Add cluster_name column to proxmox_servers table"""
logger.info("Migration 17: Adding cluster support to proxmox_servers...")
if add_column_if_not_exists(conn, 'proxmox_servers', 'cluster_name', 'VARCHAR(100)'):
conn.execute(text("""
CREATE INDEX IF NOT EXISTS idx_proxmox_cluster_name
ON proxmox_servers(cluster_name)
"""))
logger.info("✓ Added cluster_name column and index to proxmox_servers")
else:
logger.info("✓ proxmox_servers.cluster_name already exists")
# Ensure admin role has cluster:manage (new format) and remove old proxmox.cluster.manage key
try:
result = conn.execute(text("SELECT permissions FROM roles WHERE name = 'admin'")).fetchone()
if result:
import json as _json
perms = _json.loads(result.permissions) if isinstance(result.permissions, str) else result.permissions
changed = False
# Remove old dot-format key that triggers full permission reset in Migration 9
if 'proxmox.cluster.manage' in perms:
del perms['proxmox.cluster.manage']
changed = True
# Add new-format key if missing
if not perms.get('cluster:manage'):
perms['cluster:manage'] = True
changed = True
if changed:
conn.execute(
text("UPDATE roles SET permissions = :p WHERE name = 'admin'"),
{"p": _json.dumps(perms)}
)
logger.info("✓ Migrated admin role: proxmox.cluster.manage → cluster:manage")
else:
logger.info("✓ Admin role cluster:manage already up to date")
except Exception as e:
logger.warning(f"Could not update admin role permissions: {e}")
# ==================== Migration 18: Proxmox Tasks (UPID) ====================
def migrate_proxmox_tasks(conn):
"""Create proxmox_tasks table for tracking long-running Proxmox UPID tasks."""
logger.info("Migration 18: Creating proxmox_tasks table...")
if table_exists(conn, 'proxmox_tasks'):
logger.info("Table proxmox_tasks already exists, skipping")
return
conn.execute(text("""
CREATE TABLE proxmox_tasks (
id SERIAL PRIMARY KEY,
upid VARCHAR(200) NOT NULL,
server_id INTEGER REFERENCES proxmox_servers(id) ON DELETE SET NULL,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
node VARCHAR(100),
vmid INTEGER,
vm_type VARCHAR(10),
action VARCHAR(100) NOT NULL,
description VARCHAR(500),
status VARCHAR(20) NOT NULL DEFAULT 'running',
exit_status VARCHAR(100),
log_text TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL,
completed_at TIMESTAMP WITH TIME ZONE
)
"""))
conn.execute(text("CREATE INDEX idx_proxmox_tasks_upid ON proxmox_tasks(upid)"))
conn.execute(text("CREATE INDEX idx_proxmox_tasks_user_id ON proxmox_tasks(user_id)"))
conn.execute(text("CREATE INDEX idx_proxmox_tasks_status ON proxmox_tasks(status)"))
conn.execute(text("CREATE INDEX idx_proxmox_tasks_user_status ON proxmox_tasks(user_id, status)"))
conn.execute(text("CREATE INDEX idx_proxmox_tasks_created ON proxmox_tasks(created_at DESC)"))
logger.info("✓ proxmox_tasks table created")
def migrate_workspaces(conn):
"""Migration 19: Create workspaces, workspace_servers, workspace_users tables."""
logger.info("Migration 19: Creating workspaces tables...")
if table_exists(conn, 'workspaces'):
logger.info("Table workspaces already exists, skipping")
return
# Create workspaces table
conn.execute(text("""
CREATE TABLE workspaces (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL UNIQUE,
description TEXT,
color VARCHAR(20) DEFAULT '#667eea',
is_default BOOLEAN NOT NULL DEFAULT FALSE,
created_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL
)
"""))
conn.execute(text("CREATE INDEX idx_workspaces_name ON workspaces(name)"))
# Create workspace_servers junction table
conn.execute(text("""
CREATE TABLE workspace_servers (
id SERIAL PRIMARY KEY,
workspace_id INTEGER NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
server_id INTEGER NOT NULL REFERENCES proxmox_servers(id) ON DELETE CASCADE,
UNIQUE(workspace_id, server_id)
)
"""))
conn.execute(text("CREATE INDEX idx_ws_servers_workspace ON workspace_servers(workspace_id)"))
conn.execute(text("CREATE INDEX idx_ws_servers_server ON workspace_servers(server_id)"))
# Create workspace_users junction table
conn.execute(text("""
CREATE TABLE workspace_users (
id SERIAL PRIMARY KEY,
workspace_id INTEGER NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
UNIQUE(workspace_id, user_id)
)
"""))
conn.execute(text("CREATE INDEX idx_ws_users_workspace ON workspace_users(workspace_id)"))
conn.execute(text("CREATE INDEX idx_ws_users_user ON workspace_users(user_id)"))
# Insert default workspace containing all existing servers
conn.execute(text("""
INSERT INTO workspaces (name, description, color, is_default)
VALUES ('Default', 'Default workspace containing all servers', '#667eea', TRUE)
"""))
# Add all existing servers to the default workspace
conn.execute(text("""
INSERT INTO workspace_servers (workspace_id, server_id)
SELECT w.id, s.id
FROM workspaces w, proxmox_servers s
WHERE w.is_default = TRUE
"""))
logger.info("✓ workspaces tables created, Default workspace populated")
def migrate_user_servers(conn):
"""Migration 20: Create user_servers association table for server-to-user assignments."""
logger.info("Migration 20: Creating user_servers table...")
if table_exists(conn, 'user_servers'):
logger.info("Table user_servers already exists, skipping")
return
conn.execute(text("""
CREATE TABLE user_servers (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
server_id INTEGER NOT NULL REFERENCES proxmox_servers(id) ON DELETE CASCADE,
PRIMARY KEY (user_id, server_id)
)
"""))
conn.execute(text("CREATE INDEX idx_user_servers_user ON user_servers(user_id)"))
conn.execute(text("CREATE INDEX idx_user_servers_server ON user_servers(server_id)"))
logger.info("✓ user_servers table created")
def migrate_user_role_remove_dashboard(conn):
"""Migration 21: Ensure dashboard:view is False for user role (new format only)."""
logger.info("Migration 21: Updating user role permissions (dashboard:view = false)...")
if not table_exists(conn, 'roles'):
logger.info("Table roles does not exist, skipping")
return
result = conn.execute(text("SELECT id, permissions FROM roles WHERE name = 'user'"))
row = result.fetchone()
if not row:
logger.info("Role 'user' not found, skipping")
return
import json as _json
perms = row[1] if isinstance(row[1], dict) else _json.loads(row[1] or '{}')
changed = False
# Remove old dot-format key if present (it triggers Migration 9 to "migrate" the role every start)
if 'dashboard.view' in perms:
del perms['dashboard.view']
changed = True
# Ensure new-format key is False
if perms.get('dashboard:view') is not False:
perms['dashboard:view'] = False
changed = True
if changed:
conn.execute(
text("UPDATE roles SET permissions = :p WHERE name = 'user'"),
{"p": _json.dumps(perms)}
)
logger.info("✓ user role: dashboard:view set to False (old dot-format key removed)")
else:
logger.info("✓ user role: dashboard:view already False, skipping")
def migrate_deploy_tasks(conn):
"""Migration 22: Create deploy_tasks table for async VM deployment tracking"""
conn.execute(text("""
CREATE TABLE IF NOT EXISTS deploy_tasks (
id SERIAL PRIMARY KEY,
status VARCHAR(20) NOT NULL DEFAULT 'pending',
step VARCHAR(200),
progress INTEGER NOT NULL DEFAULT 0,
name VARCHAR(100) NOT NULL,
template_id INTEGER NOT NULL,
server_id INTEGER,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
vmid INTEGER,
node VARCHAR(100),
error_message TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
started_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ
)
"""))
conn.execute(text("""
CREATE INDEX IF NOT EXISTS idx_deploy_task_user_status
ON deploy_tasks (user_id, status)
"""))
conn.execute(text("""
CREATE INDEX IF NOT EXISTS idx_deploy_tasks_created
ON deploy_tasks (created_at)
"""))
logger.info("✓ deploy_tasks table created")
def migrate_deploy_tasks_kind(conn):
"""Migration 23: Add 'kind' column to deploy_tasks (deploy/reinstall/clone/change_password)"""
# Check if column exists
result = conn.execute(text("""
SELECT column_name FROM information_schema.columns
WHERE table_name = 'deploy_tasks' AND column_name = 'kind'
"""))
if result.fetchone():
logger.info("✓ deploy_tasks.kind already exists")
return
conn.execute(text("""
ALTER TABLE deploy_tasks
ADD COLUMN kind VARCHAR(30) NOT NULL DEFAULT 'deploy'
"""))
conn.execute(text("""
CREATE INDEX IF NOT EXISTS idx_deploy_tasks_kind
ON deploy_tasks (kind)
"""))
# template_id may be NULL for non-deploy tasks (clone/reinstall don't always need it)
conn.execute(text("""
ALTER TABLE deploy_tasks ALTER COLUMN template_id DROP NOT NULL
"""))
logger.info("✓ deploy_tasks.kind column added")
def migrate_deploy_tasks_target_server(conn):
"""Migration 36: Add 'target_server_id' column to deploy_tasks (remote-migrate destination cluster)"""
add_column_if_not_exists(conn, 'deploy_tasks', 'target_server_id', 'INTEGER')
logger.info("✓ deploy_tasks.target_server_id column added")
def migrate_user_ssh_keys_table(conn):
"""Migration 24: Create user_ssh_keys table for SSH key library per user."""
if table_exists(conn, 'user_ssh_keys'):
logger.info("✓ user_ssh_keys table already exists")
return
logger.info("Creating user_ssh_keys table...")
conn.execute(text("""
CREATE TABLE user_ssh_keys (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name VARCHAR(100) NOT NULL,
public_key TEXT NOT NULL,
private_key VARCHAR(8000),
fingerprint VARCHAR(100),
comment VARCHAR(255),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
"""))
conn.execute(text("""
CREATE INDEX idx_user_ssh_keys_user ON user_ssh_keys(user_id)
"""))
conn.execute(text("""
CREATE INDEX idx_user_ssh_keys_fp ON user_ssh_keys(fingerprint)
"""))
logger.info("✓ user_ssh_keys table created")
def migrate_proxmox_task_progress(conn):
"""Migration 25: Add 'progress' column to proxmox_tasks (0-100, parsed from log)."""
if not table_exists(conn, 'proxmox_tasks'):
logger.info("Table proxmox_tasks does not exist, skipping progress migration")
return
if add_column_if_not_exists(conn, 'proxmox_tasks', 'progress', 'INTEGER'):
logger.info("✓ Added progress column to proxmox_tasks")
else:
logger.info("✓ proxmox_tasks.progress already exists")
def migrate_user_language(conn):
"""Migration 26: Add 'language' column to users table (per-user UI language)."""
if add_column_if_not_exists(conn, 'users', 'language', "VARCHAR(10) DEFAULT 'ru'"):
logger.info("✓ Added language column to users")
else:
logger.info("✓ users.language already exists")
def migrate_password_reset_tokens(conn):
"""Migration 27: Create password_reset_tokens table for self-service password reset."""
if table_exists(conn, 'password_reset_tokens'):
logger.info("✓ password_reset_tokens table already exists")
return
logger.info("Creating password_reset_tokens table...")
conn.execute(text("""
CREATE TABLE password_reset_tokens (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash VARCHAR(64) NOT NULL UNIQUE,
expires_at TIMESTAMPTZ NOT NULL,
used_at TIMESTAMPTZ,
ip_address VARCHAR(45),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
"""))
conn.execute(text("""
CREATE INDEX idx_password_reset_tokens_user ON password_reset_tokens(user_id)
"""))
conn.execute(text("""
CREATE INDEX idx_password_reset_tokens_hash ON password_reset_tokens(token_hash)
"""))
logger.info("✓ password_reset_tokens table created")
def migrate_user_quotas(conn):
"""Migration 28: Create user_quotas table for per-user resource limits.
No default rows are inserted — absence of a row (or a NULL column) means
unlimited for that metric.
"""
if table_exists(conn, 'user_quotas'):
logger.info("✓ user_quotas table already exists")
return
logger.info("Creating user_quotas table...")
conn.execute(text("""
CREATE TABLE user_quotas (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE,
max_instances INTEGER,
max_cores INTEGER,
max_memory_mb INTEGER,
max_disk_gb INTEGER,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
"""))
conn.execute(text("CREATE INDEX idx_user_quotas_user ON user_quotas(user_id)"))
logger.info("✓ user_quotas table created")
def migrate_granular_permissions(conn):
"""
Migration 29: Backfill granular RBAC permissions.
Splits umbrella permissions (server:view/manage, vm:view, user:manage) into
granular ones (firewall:*, network:*, node:*, storage:*, pool:*, access:*)
for existing roles, and ensures the default roles include the new keys.
"""
logger.info("Migration 29: Backfilling granular RBAC permissions...")
if not table_exists(conn, 'roles'):
logger.info("Table roles does not exist, skipping granular permissions migration")
return
try:
from app.rbac.migration import (
backfill_granular_permissions,
ensure_default_roles_new_format,
)
except ImportError:
try:
from backend.app.rbac.migration import (
backfill_granular_permissions,
ensure_default_roles_new_format,
)
except ImportError:
logger.warning("Could not import RBAC utilities, skipping granular permissions migration")
return
updated = backfill_granular_permissions(conn)
logger.info(f"Backfilled granular permissions for {updated} role(s)")
# Ensure default roles pick up the new granular keys as well
ensure_default_roles_new_format(conn)
logger.info("✓ Granular permissions migration completed")
def migrate_two_factor(conn):
"""Migration 30: Two-factor authentication columns on users.
Ensures the TOTP columns exist (older DBs predating init.sql) and adds the
``two_factor_backup_codes`` JSON column for single-use recovery codes.
"""
added = False
if add_column_if_not_exists(conn, 'users', 'two_factor_enabled', "BOOLEAN DEFAULT FALSE NOT NULL"):
added = True
if add_column_if_not_exists(conn, 'users', 'two_factor_secret', "VARCHAR(100)"):
added = True
if add_column_if_not_exists(conn, 'users', 'two_factor_backup_codes', "JSONB"):
added = True
if added:
logger.info("✓ Added two-factor authentication columns to users")
else:
logger.info("✓ users two-factor columns already exist")
def migrate_appstore_catalog(conn):
"""Create catalog_apps table for the App Store module (M1)."""
if table_exists(conn, 'catalog_apps'):
logger.info("Table catalog_apps already exists, skipping")
return
conn.execute(text("""
CREATE TABLE catalog_apps (
app_id VARCHAR(100) PRIMARY KEY,
name VARCHAR(200) NOT NULL,
version VARCHAR(50),
tipi_version INTEGER,
categories JSONB NOT NULL DEFAULT '[]'::jsonb,
short_desc TEXT,
description_md TEXT,
logo_path VARCHAR(300),
port INTEGER,
form_fields JSONB NOT NULL DEFAULT '[]'::jsonb,
compose_yaml TEXT,
architectures JSONB NOT NULL DEFAULT '[]'::jsonb,
available BOOLEAN NOT NULL DEFAULT TRUE,
unavailable_reason VARCHAR(200),
deprecated BOOLEAN NOT NULL DEFAULT FALSE,
dynamic_config BOOLEAN NOT NULL DEFAULT FALSE,
source_url VARCHAR(500),
author VARCHAR(200),
synced_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL
)
"""))
conn.execute(text("CREATE INDEX idx_catalog_apps_available ON catalog_apps (available)"))
conn.execute(text("CREATE INDEX idx_catalog_apps_name ON catalog_apps (name)"))
logger.info("✓ App Store catalog_apps table created")
def migrate_appstore_catalog_source(conn):
"""Add source column to catalog_apps (мультиисточник: runtipi | umbrel)."""
if not table_exists(conn, 'catalog_apps'):
return
if column_exists(conn, 'catalog_apps', 'source'):
return
conn.execute(text(
"ALTER TABLE catalog_apps ADD COLUMN source VARCHAR(50) NOT NULL DEFAULT 'runtipi'"
))
conn.execute(text(
"CREATE INDEX IF NOT EXISTS idx_catalog_apps_source ON catalog_apps (source)"
))
logger.info("✓ App Store catalog_apps.source column added")
def migrate_appstore_catalog_data_files(conn):
"""Add data_files column to catalog_apps (файлы приложения для bind-mount'ов)."""
if not table_exists(conn, 'catalog_apps'):
return
if column_exists(conn, 'catalog_apps', 'data_files'):
return
conn.execute(text("ALTER TABLE catalog_apps ADD COLUMN data_files JSONB"))
logger.info("✓ App Store catalog_apps.data_files column added")
def migrate_appstore_default_credentials(conn):
"""Add default_credentials column to catalog_apps (учётные данные из манифеста)."""
if not table_exists(conn, 'catalog_apps'):
return
if column_exists(conn, 'catalog_apps', 'default_credentials'):
return
conn.execute(text("ALTER TABLE catalog_apps ADD COLUMN default_credentials JSONB"))
logger.info("✓ App Store catalog_apps.default_credentials column added")
def migrate_server_error_kind(conn):
"""Add last_error_kind column to proxmox_servers ('auth' | 'connection')."""
if not table_exists(conn, 'proxmox_servers'):
return
if add_column_if_not_exists(conn, 'proxmox_servers', 'last_error_kind', 'VARCHAR(20)'):
logger.info("✓ proxmox_servers.last_error_kind column added")
def pick_backfill_owner(candidates):
"""Pick the owner for one instance from creation evidence, or None.
``candidates`` is an iterable of ``(server_id, user_id)`` pairs already
filtered to the instance's VMID. A ``server_id`` of ``None`` means the
source did not record one (deploy_tasks does not set it for LXC), so the
pair is treated as applicable to any server.
Ownership grants access, so a guess is worse than leaving the row alone:
the owner is returned only when every applicable pair names the same user.
"""
users = {user_id for _, user_id in candidates if user_id is not None}
return users.pop() if len(users) == 1 else None
def migrate_backfill_instance_owner(conn):
"""
Backfill vm_instances.owner_id for instances that were created through the
panel but ended up ownerless.
Until save_vm_instance() learned to set the owner when updating an existing
row, every deploy lost it: the cache sync registers a VM with
owner_id = NULL within ~10s of it appearing on Proxmox — while the clone is
still running — so the deploy's own save minutes later always took the
update path, which ignored owner_id.
The creator is recovered from two sources, in order of confidence:
1. deploy_tasks — user_id is a real foreign key stored when the deploy was
queued (server_id is absent for LXC deploys, so it may be NULL)
2. audit_logs — 'vm_create' / 'ct_create' / 'lxc_create' entries, which
only carry a username and so are matched by name
A row is updated only when the evidence names exactly one existing user.
Ambiguous and unmatched instances keep owner_id = NULL for an operator to
assign deliberately with backend/assign_instances_owner.py. Instances that
already have an owner are never touched, which also makes the migration
idempotent.
Caveat: this recovers who *created* the instance, not necessarily who it was
meant for. A VM an admin created on behalf of a user is attributed to the
admin, because before the fix the intended owner was never persisted
anywhere. Those need the CLI script to be corrected.
"""
if not table_exists(conn, 'vm_instances'):
return
if not column_exists(conn, 'vm_instances', 'owner_id'):
logger.info("vm_instances.owner_id missing, skipping owner backfill")
return
orphans = conn.execute(text(
"SELECT id, server_id, vmid, name FROM vm_instances "
"WHERE owner_id IS NULL AND deleted_at IS NULL"
)).fetchall()
if not orphans:
logger.info("✓ No ownerless instances to backfill")
return
logger.info(f"Migration 41: backfilling owner for {len(orphans)} ownerless instance(s)...")
known_user_ids = {row[0] for row in conn.execute(text("SELECT id FROM users"))}
user_id_by_name = {
row[1]: row[0]
for row in conn.execute(text("SELECT id, username FROM users"))
if row[1]
}
# vmid -> [(server_id, user_id)], strongest source first
from_deploys: dict = {}
from_logs: dict = {}
if table_exists(conn, 'deploy_tasks'):
for user_id, server_id, vmid in conn.execute(text(
"SELECT user_id, server_id, vmid FROM deploy_tasks WHERE vmid IS NOT NULL"
)):
if user_id in known_user_ids:
from_deploys.setdefault(vmid, []).append((server_id, user_id))
if table_exists(conn, 'audit_logs'):
for username, server_id, resource_id, success in conn.execute(text(
"SELECT username, server_id, resource_id, success FROM audit_logs "
"WHERE action IN ('vm_create', 'ct_create', 'lxc_create', 'container_create') "
"AND username IS NOT NULL AND resource_id IS NOT NULL"
)):
if not success:
continue
user_id = user_id_by_name.get(username)
if user_id is None:
continue
try:
vmid = int(resource_id)
except (TypeError, ValueError):
continue
from_logs.setdefault(vmid, []).append((server_id, user_id))
assigned = 0
ambiguous = 0
unmatched = 0
for inst_id, server_id, vmid, name in orphans:
owner_id = None
had_evidence = False
for source in (from_deploys, from_logs):
applicable = [
(sid, uid) for sid, uid in source.get(vmid, [])
if sid is None or sid == server_id
]
if not applicable:
continue
had_evidence = True
owner_id = pick_backfill_owner(applicable)
if owner_id is not None:
break
if owner_id is None:
# Evidence that names several users (a reused VMID, most likely) is
# not usable, but it is worth distinguishing from no evidence at all.
if had_evidence:
ambiguous += 1
else:
unmatched += 1
continue
conn.execute(
text("UPDATE vm_instances SET owner_id = :owner_id "
"WHERE id = :id AND owner_id IS NULL"),
{"owner_id": owner_id, "id": inst_id},
)
assigned += 1
logger.info(f" VM {vmid} ({name}) -> user_id={owner_id}")
logger.info(
f"✓ Instance owner backfill: {assigned} assigned, "
f"{ambiguous} ambiguous, {unmatched} without evidence"
)
if ambiguous or unmatched:
logger.info(
" Remaining instances stay ownerless on purpose — assign them with "
"backend/assign_instances_owner.py"
)
def migrate_script_repo_metadata_format(conn):
"""Add metadata_format column to script_git_repos (header-comment | community-scripts-ct)."""
if not table_exists(conn, 'script_git_repos'):
return
if add_column_if_not_exists(conn, 'script_git_repos', 'metadata_format',
"VARCHAR(30) NOT NULL DEFAULT 'header-comment'"):
logger.info("✓ Added metadata_format column to script_git_repos")
def migrate_appstore_installed(conn):
"""Create installed_apps and app_operations tables for the App Engine (M2)."""
if not table_exists(conn, 'installed_apps'):
conn.execute(text("""
CREATE TABLE installed_apps (
id SERIAL PRIMARY KEY,
app_id VARCHAR(100),
server_id INTEGER,
vmid INTEGER,
node VARCHAR(100),
name VARCHAR(200) NOT NULL,
version_installed VARCHAR(50),
tipi_version_installed INTEGER,
env_encrypted TEXT,
ip VARCHAR(64),
port INTEGER,
status VARCHAR(20) NOT NULL DEFAULT 'installing',
update_available BOOLEAN NOT NULL DEFAULT FALSE,
last_snapshot VARCHAR(200),
owner_id INTEGER,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL
)
"""))
conn.execute(text("CREATE INDEX idx_installed_apps_status ON installed_apps (status)"))
conn.execute(text("CREATE INDEX idx_installed_apps_owner ON installed_apps (owner_id)"))
conn.execute(text("CREATE INDEX idx_installed_apps_server_vmid ON installed_apps (server_id, vmid)"))
logger.info("✓ App Store installed_apps table created")
if not table_exists(conn, 'app_operations'):
conn.execute(text("""
CREATE TABLE app_operations (
id SERIAL PRIMARY KEY,
installed_app_id INTEGER REFERENCES installed_apps(id) ON DELETE CASCADE,
type VARCHAR(20) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'running',
progress INTEGER NOT NULL DEFAULT 0,
steps_log JSONB NOT NULL DEFAULT '[]'::jsonb,
error_text TEXT,
user_id INTEGER,
started_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL,
finished_at TIMESTAMP WITH TIME ZONE
)
"""))
conn.execute(text("CREATE INDEX idx_app_operations_installed ON app_operations (installed_app_id)"))
logger.info("✓ App Store app_operations table created")
# M4: версия, зафиксированная в pre-update снапшоте (для отката версии в БД)
add_column_if_not_exists(conn, 'installed_apps', 'snapshot_version', 'VARCHAR(50)')
add_column_if_not_exists(conn, 'installed_apps', 'snapshot_tipi_version', 'INTEGER')
# Параметры создания CT для корректного повтора установки (retry)
add_column_if_not_exists(conn, 'installed_apps', 'install_params', 'JSONB')
def migrate_ipam_network_workspace(conn):
"""Migration 33: Bind IPAM networks to a workspace + default-network flag.
Adds ipam_networks.workspace_id (FK workspaces) and ipam_networks.is_default.
Backfills workspace_id from the network's proxmox_server_id via workspace_servers
(each server belongs to a workspace), so existing server-bound networks land in
the right workspace automatically.
"""
if not table_exists(conn, 'ipam_networks'):
logger.info("Table ipam_networks does not exist, skipping workspace migration")
return
added = False
if add_column_if_not_exists(conn, 'ipam_networks', 'workspace_id', 'INTEGER REFERENCES workspaces(id) ON DELETE SET NULL'):
added = True
conn.execute(text(
"CREATE INDEX IF NOT EXISTS idx_ipam_network_workspace ON ipam_networks(workspace_id)"
))
if add_column_if_not_exists(conn, 'ipam_networks', 'is_default', 'BOOLEAN NOT NULL DEFAULT FALSE'):
added = True
# Backfill workspace_id from server binding (only for rows not yet assigned).
if table_exists(conn, 'workspace_servers'):
try:
conn.execute(text("""
UPDATE ipam_networks n
SET workspace_id = ws.workspace_id
FROM workspace_servers ws
WHERE n.workspace_id IS NULL
AND n.proxmox_server_id IS NOT NULL
AND ws.server_id = n.proxmox_server_id
"""))
except Exception as e:
logger.debug(f"IPAM workspace backfill: {e}")
if added:
logger.info("✓ Added workspace_id/is_default to ipam_networks")
else:
logger.info("✓ ipam_networks.workspace_id/is_default already exist")
def migrate_app_operations_server_id(conn):
"""Migration 37: app_operations.server_id (per-server golden-template tracking).
Нужен только для операций без installed_app_id (golden_template) — чтобы
прогресс/история сборки золотого шаблона различались по серверам Proxmox,
а не показывали последнюю сборку глобально независимо от того, для
какого сервера она запускалась.
"""
if not table_exists(conn, 'app_operations'):
logger.info("Table app_operations does not exist, skipping server_id migration")
return
if add_column_if_not_exists(
conn, 'app_operations', 'server_id',
'INTEGER REFERENCES proxmox_servers(id) ON DELETE CASCADE'
):
conn.execute(text(
"CREATE INDEX IF NOT EXISTS idx_app_operations_server ON app_operations(server_id)"
))
logger.info("✓ Added app_operations.server_id")
else:
logger.info("✓ app_operations.server_id already exists")
def migrate_ipam_multi_ip(conn):
"""Migration 43: несколько IP-адресов на одного гостя.
Раньше на (server_id, vmid) подразумевалась ровно одна аллокация. Теперь
их может быть несколько: один основной (net0/ipconfig0, попадает в колонку
IP) и произвольное число алиасов, навешенных внутри гостя. Колонки
apply_* хранят состояние применения алиаса к живому гостю.
"""
if not table_exists(conn, 'ipam_allocations'):
logger.info("Table ipam_allocations does not exist, skipping multi-IP migration")
return
added = False
for column, ddl in (
('is_primary', 'BOOLEAN NOT NULL DEFAULT FALSE'),
('assignment_kind', "VARCHAR(10) NOT NULL DEFAULT 'primary'"),
('target_interface', 'VARCHAR(20)'),
('apply_status', 'VARCHAR(20)'),
('apply_error', 'TEXT'),
('applied_at', 'TIMESTAMP WITH TIME ZONE'),
):
if add_column_if_not_exists(conn, 'ipam_allocations', column, ddl):
added = True
if added:
conn.execute(text(
"CREATE INDEX IF NOT EXISTS idx_ipam_alloc_primary "
"ON ipam_allocations(proxmox_server_id, proxmox_vmid, is_primary)"
))
# Всё, что существовало до этой миграции, было единственным адресом
# своего гостя — значит основным. Записи без привязки к гостю
# (ручные брони) основными не считаем.
conn.execute(text("""
UPDATE ipam_allocations
SET is_primary = TRUE, assignment_kind = 'primary'
WHERE proxmox_vmid IS NOT NULL AND is_primary = FALSE
"""))
logger.info("✓ Added multi-IP columns to ipam_allocations")
else:
logger.info("✓ ipam_allocations multi-IP columns already exist")
def migrate_setting_view_panel_only(conn):
"""Migration 42: drop setting:view where it was only granting the profile page.
`setting:view` used to gate the whole /settings page, which mixed panel-wide
configuration with a user's own account (password, 2FA, SSH keys,
notification preferences). The account half now lives at /profile and needs
no permission, so roles that were given setting:view just to let their users
reach their own settings would silently gain the panel settings page.
Only roles that clearly were not meant to administer the panel are touched:
those holding setting:view without setting:update or setting:manage. Runs
once — a marker in panel_settings keeps a later admin grant from being
reverted on the next startup.
"""
if not table_exists(conn, 'roles') or not table_exists(conn, 'panel_settings'):
logger.info("Tables roles/panel_settings missing, skipping setting:view split")
return
marker = 'migration_setting_view_panel_only'
done = conn.execute(
text("SELECT 1 FROM panel_settings WHERE key = :k"), {"k": marker}
).fetchone()
if done:
logger.info("✓ setting:view split already applied, skipping")
return
import json as _json
rows = conn.execute(text("SELECT id, name, permissions FROM roles")).fetchall()
changed = []
for role_id, role_name, permissions in rows:
perms = permissions if isinstance(permissions, dict) else _json.loads(permissions or '{}')
if not perms.get('setting:view'):
continue
if perms.get('setting:update') or perms.get('setting:manage'):
continue # a real panel administrator — leave the grant alone
del perms['setting:view']
conn.execute(
text("UPDATE roles SET permissions = :p WHERE id = :id"),
{"p": _json.dumps(perms), "id": role_id},
)
changed.append(role_name)
conn.execute(
text("""
INSERT INTO panel_settings (key, value, description)
VALUES (:k, 'done', 'setting:view no longer gates a user''s own profile page')
ON CONFLICT (key) DO NOTHING
"""),
{"k": marker},
)
if changed:
logger.info(f"✓ Removed panel-only setting:view from role(s): {', '.join(changed)}")
else:
logger.info("✓ No roles needed the setting:view split")
def run_all_migrations(engine, db_session=None):
"""
Run all migrations in order.
Args:
engine: SQLAlchemy engine
db_session: Optional database session (for compatibility)
"""
logger.info("=" * 50)
logger.info("Running PVEmanager database migrations...")
logger.info("=" * 50)
try:
with engine.connect() as conn:
# Migration 1: Notifications
try:
migrate_notifications(conn)
conn.commit()
except Exception as e:
logger.warning(f"Notifications migration: {e}")
conn.rollback()
# Migration 2: RBAC & Security
try:
migrate_rbac_security(conn)
conn.commit()
except Exception as e:
logger.warning(f"RBAC migration: {e}")
conn.rollback()
# Migration 3: Cross-node Templates
try:
migrate_cross_node_templates(conn)
conn.commit()
except Exception as e:
logger.warning(f"Cross-node templates migration: {e}")
conn.rollback()
# Migration 4: VM Cache
try:
migrate_vm_cache(conn)
conn.commit()
except Exception as e:
logger.warning(f"VM cache migration: {e}")
conn.rollback()
# Migration 5: Task Queue
try:
migrate_task_queue(conn)
conn.commit()
except Exception as e:
logger.warning(f"Task queue migration: {e}")
conn.rollback()
# Migration 6: OS Template Icons (theme-adaptive)
try:
migrate_os_template_icons(conn)
conn.commit()
except Exception as e:
logger.warning(f"OS template icons migration: {e}")
conn.rollback()
# Migration 7: VM Template Name
try:
migrate_vm_template_name(conn)
conn.commit()
except Exception as e:
logger.warning(f"VM template name migration: {e}")
conn.rollback()
# Migration 8: Enhanced Audit Logs
try:
migrate_enhanced_audit_logs(conn)
conn.commit()
except Exception as e:
logger.warning(f"Enhanced audit logs migration: {e}")
conn.rollback()
# Migration 9: RBAC v2 - New Permission Format
try:
migrate_rbac_v2(conn)
conn.commit()
except Exception as e:
logger.warning(f"RBAC v2 migration: {e}")
conn.rollback()
# Migration 10: VM Instance Owner (VPS-style user isolation)
try:
migrate_vm_instance_owner(conn)
conn.commit()
except Exception as e:
logger.warning(f"VM instance owner migration: {e}")
conn.rollback()
# Migration 11: Snapshot Archives
try:
migrate_snapshot_archives(conn)
conn.commit()
except Exception as e:
logger.warning(f"Snapshot archives migration: {e}")
conn.rollback()
# Migration 12: User SSH Public Key
try:
migrate_user_ssh_key(conn)
conn.commit()
except Exception as e:
logger.warning(f"User SSH key migration: {e}")
conn.rollback()
# Migration 13: Git Repository URL Setting
try:
migrate_git_repository_setting(conn)
conn.commit()
except Exception as e:
logger.warning(f"Git repository setting migration: {e}")
conn.rollback()
# Migration 14: Encrypt plaintext passwords (idempotent, skipped if FERNET_KEY not set)
try:
migrate_encrypt_passwords(conn)
conn.commit()
except Exception as e:
logger.warning(f"Password encryption migration: {e}")
conn.rollback()
# Migration 15: Backup Jobs
try:
migrate_backup_jobs(conn)
conn.commit()
except Exception as e:
logger.warning(f"Backup jobs migration: {e}")
conn.rollback()
# Migration 16: IPAM Network Node
try:
migrate_ipam_network_node(conn)
conn.commit()
except Exception as e:
logger.warning(f"IPAM network node migration: {e}")
conn.rollback()
# Migration 17: Cluster Support (cluster_name column)
try:
migrate_cluster_support(conn)
conn.commit()
except Exception as e:
logger.warning(f"Cluster support migration: {e}")
conn.rollback()
# Migration 18: Proxmox Tasks (UPID tracking)
try:
migrate_proxmox_tasks(conn)
conn.commit()
except Exception as e:
logger.warning(f"Proxmox tasks migration: {e}")
conn.rollback()
# Migration 19: Workspaces
try:
migrate_workspaces(conn)
conn.commit()
except Exception as e:
logger.warning(f"Workspaces migration: {e}")
conn.rollback()
# Migration 20: User-Server assignments
try:
migrate_user_servers(conn)
conn.commit()
except Exception as e:
logger.warning(f"User-Server assignments migration: {e}")
conn.rollback()
# Migration 21: Update user role - disable dashboard.view
try:
migrate_user_role_remove_dashboard(conn)
conn.commit()
except Exception as e:
logger.warning(f"User role dashboard migration: {e}")
conn.rollback()
# Migration 22: Deploy Tasks table
try:
migrate_deploy_tasks(conn)
conn.commit()
except Exception as e:
logger.warning(f"Deploy tasks migration: {e}")
conn.rollback()
# Migration 23: deploy_tasks.kind column
try:
migrate_deploy_tasks_kind(conn)
conn.commit()
except Exception as e:
logger.warning(f"Deploy tasks kind migration: {e}")
conn.rollback()
# Migration 24: user_ssh_keys table
try:
migrate_user_ssh_keys_table(conn)
conn.commit()
except Exception as e:
logger.warning(f"User SSH keys table migration: {e}")
conn.rollback()
# Migration 25: proxmox_tasks.progress column
try:
migrate_proxmox_task_progress(conn)
conn.commit()
except Exception as e:
logger.warning(f"Proxmox task progress migration: {e}")
conn.rollback()
# Migration 26: users.language column
try:
migrate_user_language(conn)
conn.commit()
except Exception as e:
logger.warning(f"User language migration: {e}")
conn.rollback()
# Migration 27: password_reset_tokens table
try:
migrate_password_reset_tokens(conn)
conn.commit()
except Exception as e:
logger.warning(f"Password reset tokens migration: {e}")
conn.rollback()
# Migration 28: user_quotas table
try:
migrate_user_quotas(conn)
conn.commit()
except Exception as e:
logger.warning(f"User quotas migration: {e}")
conn.rollback()
# Migration 29: Backfill granular RBAC permissions
try:
migrate_granular_permissions(conn)
conn.commit()
except Exception as e:
logger.warning(f"Granular permissions migration: {e}")
conn.rollback()
# Migration 30: Two-factor authentication columns
try:
migrate_two_factor(conn)
conn.commit()
except Exception as e:
logger.warning(f"Two-factor migration: {e}")
conn.rollback()
# Migration 31: App Store catalog
try:
migrate_appstore_catalog(conn)
conn.commit()
except Exception as e:
logger.warning(f"App Store catalog migration: {e}")
conn.rollback()
# Migration 32: App Store installed apps + operations
try:
migrate_appstore_installed(conn)
conn.commit()
except Exception as e:
logger.warning(f"App Store installed migration: {e}")
conn.rollback()
# Migration 33: IPAM network workspace binding + default flag
try:
migrate_ipam_network_workspace(conn)
conn.commit()
except Exception as e:
logger.warning(f"IPAM network workspace migration: {e}")
conn.rollback()
# Migration 34: App Store catalog source column (runtipi | umbrel)
try:
migrate_appstore_catalog_source(conn)
conn.commit()
except Exception as e:
logger.warning(f"App Store catalog source migration: {e}")
conn.rollback()
# Migration 35: script_git_repos metadata_format column (header-comment | community-scripts-ct)
try:
migrate_script_repo_metadata_format(conn)
conn.commit()
except Exception as e:
logger.warning(f"Script repo metadata_format migration: {e}")
conn.rollback()
# Migration 36: deploy_tasks.target_server_id column (remote-migrate destination cluster)
try:
migrate_deploy_tasks_target_server(conn)
conn.commit()
except Exception as e:
logger.warning(f"Deploy tasks target_server_id migration: {e}")
conn.rollback()
# Migration 37: app_operations.server_id column (per-server golden template)
try:
migrate_app_operations_server_id(conn)
conn.commit()
except Exception as e:
logger.warning(f"App operations server_id migration: {e}")
conn.rollback()
# Migration 38: catalog_apps.data_files column (файлы приложения из репозитория)
try:
migrate_appstore_catalog_data_files(conn)
conn.commit()
except Exception as e:
logger.warning(f"App Store catalog data_files migration: {e}")
conn.rollback()
# Migration 39: catalog_apps.default_credentials column (учётные данные из манифеста)
try:
migrate_appstore_default_credentials(conn)
conn.commit()
except Exception as e:
logger.warning(f"App Store default_credentials migration: {e}")
conn.rollback()
# Migration 40: proxmox_servers.last_error_kind ('auth' | 'connection')
try:
migrate_server_error_kind(conn)
conn.commit()
except Exception as e:
logger.warning(f"Server last_error_kind migration: {e}")
conn.rollback()
# Migration 41: backfill vm_instances.owner_id for ownerless instances
try:
migrate_backfill_instance_owner(conn)
conn.commit()
except Exception as e:
logger.warning(f"Instance owner backfill migration: {e}")
conn.rollback()
# Migration 42: setting:view now covers panel settings only
try:
migrate_setting_view_panel_only(conn)
conn.commit()
except Exception as e:
logger.warning(f"setting:view split migration: {e}")
conn.rollback()
# Migration 43: several IP addresses per guest
try:
migrate_ipam_multi_ip(conn)
conn.commit()
except Exception as e:
logger.warning(f"IPAM multi-IP migration: {e}")
conn.rollback()
logger.info("=" * 50)
logger.info("✓ All migrations completed successfully")
logger.info("=" * 50)
return True
except Exception as e:
logger.error(f"Migration failed: {e}")
return False
# ==================== CLI Entry Point ====================
if __name__ == "__main__":
print("Running PVEmanager migrations...")
try:
from app.db import engine
except ImportError:
from backend.app.db import engine
success = run_all_migrations(engine)
if success:
print("✓ All migrations completed successfully")
else:
print("✗ Migration failed")
exit(1)