32a1885f0d
支持随时暂停/恢复实时监测并保留槽位;TTL 到期自动暂停不占删槽;修复到期竞态导致空槽与唯一约束冲突;探针 parse_error 与中文错误;移除失败 snackbar。
308 lines
17 KiB
Python
308 lines
17 KiB
Python
"""Nexus — Data Migration: category → Node, backfill platform_id + schema migrations
|
|
|
|
D8: Migrate existing server category field to the new Node tree structure,
|
|
and create default Platform entries for existing server types.
|
|
|
|
Also handles safe schema migrations for existing databases (adding new columns).
|
|
"""
|
|
|
|
import logging
|
|
from sqlalchemy import select, update, text
|
|
|
|
from server.domain.models import Server, Node, Platform
|
|
from server.infrastructure.database.session import AsyncSessionLocal
|
|
|
|
logger = logging.getLogger("nexus.migration")
|
|
|
|
|
|
# Default platforms to create
|
|
DEFAULT_PLATFORMS = [
|
|
{"name": "Linux 服务器", "category": "host", "type": "linux",
|
|
"default_protocols": [{"name": "ssh", "port": 22, "primary": True}]},
|
|
{"name": "Windows 服务器", "category": "host", "type": "windows",
|
|
"default_protocols": [{"name": "rdp", "port": 3389, "primary": True}, {"name": "ssh", "port": 22}]},
|
|
{"name": "MySQL", "category": "database", "type": "mysql",
|
|
"default_protocols": [{"name": "mysql", "port": 3306, "primary": True}]},
|
|
{"name": "网络设备", "category": "device", "type": "switch",
|
|
"default_protocols": [{"name": "ssh", "port": 22, "primary": True}]},
|
|
]
|
|
|
|
|
|
async def migrate_category_to_node():
|
|
"""Migrate server.category values to Node tree + backfill platform_id.
|
|
|
|
All steps in a single transaction — single commit at the end for atomicity.
|
|
"""
|
|
async with AsyncSessionLocal() as session:
|
|
# ── 1. Create default platforms ──
|
|
for plat_data in DEFAULT_PLATFORMS:
|
|
existing = await session.execute(
|
|
select(Platform).where(Platform.name == plat_data["name"])
|
|
)
|
|
if not existing.scalar_one_or_none():
|
|
platform = Platform(**plat_data)
|
|
session.add(platform)
|
|
logger.info(f"Created platform: {plat_data['name']}")
|
|
|
|
# ── 2. Create nodes from existing categories ──
|
|
result = await session.execute(
|
|
select(Server.category).distinct().where(Server.category.isnot(None))
|
|
)
|
|
categories = [row[0] for row in result.all() if row[0]]
|
|
|
|
category_to_node_id = {}
|
|
for cat in categories:
|
|
existing = await session.execute(
|
|
select(Node).where(Node.name == cat)
|
|
)
|
|
node = existing.scalar_one_or_none()
|
|
if not node:
|
|
node = Node(name=cat, sort_order=0)
|
|
session.add(node)
|
|
await session.flush()
|
|
logger.info(f"Created node: {cat}")
|
|
category_to_node_id[cat] = node.id
|
|
|
|
# ── 3. Backfill server node_id + platform_id ──
|
|
# Build lookup: platform type → platform id
|
|
all_platforms = await session.execute(select(Platform))
|
|
type_to_platform_id = {p.type: p.id for p in all_platforms.scalars().all()}
|
|
|
|
# Map category patterns to platform types
|
|
def _resolve_platform_type(category: str) -> str | None:
|
|
cat_lower = category.lower().strip()
|
|
if cat_lower in type_to_platform_id:
|
|
return cat_lower
|
|
if cat_lower in ("linux", "centos", "ubuntu", "debian", "redhat"):
|
|
return "linux"
|
|
if cat_lower in ("windows", "win"):
|
|
return "windows"
|
|
if cat_lower in ("mysql", "mariadb", "database", "postgresql"):
|
|
return "mysql"
|
|
if cat_lower in ("switch", "router", "network", "cisco"):
|
|
return "switch"
|
|
return None
|
|
|
|
for cat, node_id in category_to_node_id.items():
|
|
plat_type = _resolve_platform_type(cat)
|
|
plat_id = type_to_platform_id.get(plat_type) if plat_type else None
|
|
await session.execute(
|
|
update(Server)
|
|
.where(Server.category == cat)
|
|
.values(node_id=node_id, platform_id=plat_id)
|
|
)
|
|
logger.info(f"Migrated category '{cat}' → node_id={node_id}, platform_id={plat_id}")
|
|
|
|
await session.commit()
|
|
logger.info(f"Migration complete: {len(category_to_node_id)} categories migrated")
|
|
|
|
|
|
async def run_migrations():
|
|
"""Run all data migrations (called during startup after init_db)"""
|
|
try:
|
|
await migrate_category_to_node()
|
|
except Exception as e:
|
|
logger.error(f"Data migration failed: {e}")
|
|
# Non-fatal — tables exist, migration can be retried
|
|
|
|
try:
|
|
await run_schema_migrations()
|
|
except Exception as e:
|
|
logger.error(f"Schema migration failed: {e}")
|
|
|
|
|
|
async def run_schema_migrations():
|
|
"""Safe schema migrations — add new columns to existing tables.
|
|
Uses ALTER TABLE with try/except so it's a no-op if the column already exists.
|
|
"""
|
|
migrations = [
|
|
"ALTER TABLE push_schedules ADD COLUMN sync_mode VARCHAR(20) DEFAULT 'incremental' COMMENT '同步模式'",
|
|
"ALTER TABLE admins ADD COLUMN token_version INT DEFAULT 0 COMMENT '令牌版本(重用时递增使旧token失效)'",
|
|
"UPDATE admins SET token_version = 0 WHERE token_version IS NULL",
|
|
"ALTER TABLE script_executions ADD COLUMN credential_id INT NULL COMMENT 'DB凭据替换$DB_*变量'",
|
|
"ALTER TABLE script_executions MODIFY COLUMN results MEDIUMTEXT NULL COMMENT 'JSON: 每台服务器执行结果'",
|
|
# Alert log table
|
|
"""CREATE TABLE IF NOT EXISTS `alert_logs` (
|
|
`id` INT AUTO_INCREMENT PRIMARY KEY,
|
|
`server_id` INT NOT NULL,
|
|
`server_name` VARCHAR(100) NULL,
|
|
`alert_type` VARCHAR(20) NOT NULL,
|
|
`value` VARCHAR(50) NULL,
|
|
`is_recovery` TINYINT(1) DEFAULT 0,
|
|
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
INDEX `idx_alert_logs_server_id` (`server_id`),
|
|
INDEX `idx_alert_logs_created_at` (`created_at`),
|
|
INDEX `idx_alert_logs_type` (`alert_type`)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4""",
|
|
# Script-schedule columns (push_schedules extended to support script execution)
|
|
"ALTER TABLE push_schedules ADD COLUMN schedule_type VARCHAR(20) DEFAULT 'push' COMMENT '调度类型: push|script'",
|
|
"ALTER TABLE push_schedules ADD COLUMN script_id INT NULL COMMENT '脚本库引用'",
|
|
"ALTER TABLE push_schedules ADD COLUMN script_content TEXT NULL COMMENT '临时命令内容'",
|
|
"ALTER TABLE push_schedules ADD COLUMN exec_timeout INT DEFAULT 60 COMMENT '执行超时(秒)'",
|
|
"ALTER TABLE push_schedules ADD COLUMN long_task TINYINT(1) DEFAULT 0 COMMENT '长任务nohup模式'",
|
|
"ALTER TABLE push_schedules MODIFY COLUMN source_path VARCHAR(500) NULL COMMENT '推送源目录(push类型)'",
|
|
# run_mode + fire_at: support one-time schedules (default) vs recurring cron
|
|
"ALTER TABLE push_schedules ADD COLUMN run_mode VARCHAR(10) DEFAULT 'once' COMMENT '运行模式: once|cron'",
|
|
"ALTER TABLE push_schedules ADD COLUMN fire_at DATETIME NULL COMMENT '一次性执行时间(once模式)'",
|
|
"ALTER TABLE push_schedules MODIFY COLUMN cron_expr VARCHAR(100) NULL COMMENT 'cron表达式(cron模式)'",
|
|
"ALTER TABLE push_schedules ADD COLUMN target_path VARCHAR(500) NULL COMMENT '推送目标路径(留空则各机target_path)'",
|
|
# ── Server table: columns added after initial schema ──
|
|
"ALTER TABLE servers ADD COLUMN platform_id INT NULL COMMENT '平台类型ID'",
|
|
"ALTER TABLE servers ADD COLUMN node_id INT NULL COMMENT '所属节点ID'",
|
|
"ALTER TABLE servers ADD COLUMN protocols JSON NULL COMMENT '实例级协议覆盖'",
|
|
"ALTER TABLE servers ADD COLUMN extra_attrs JSON NULL COMMENT '扩展属性'",
|
|
"ALTER TABLE servers ADD COLUMN connectivity VARCHAR(20) DEFAULT 'unknown' COMMENT '连接状态'",
|
|
"ALTER TABLE servers ADD COLUMN last_checked_at DATETIME NULL COMMENT '上次连接测试时间'",
|
|
"ALTER TABLE servers ADD COLUMN ssh_key_configured TINYINT(1) DEFAULT 0 COMMENT '是否已配置SSH Key'",
|
|
# Fix: ssh_key columns were VARCHAR(500) in legacy schema — change to TEXT for large encrypted keys
|
|
"ALTER TABLE servers MODIFY COLUMN ssh_key_private TEXT NULL COMMENT '加密后的私钥内容(Fernet)'",
|
|
"ALTER TABLE servers MODIFY COLUMN ssh_key_public TEXT NULL COMMENT '公钥内容'",
|
|
# Legacy single-token refresh columns (replaced by Redis refresh_tokens:*)
|
|
"ALTER TABLE admins DROP COLUMN jwt_refresh_token",
|
|
"ALTER TABLE admins DROP COLUMN jwt_token_expires",
|
|
# Credential polling — preset username + pending servers queue
|
|
"ALTER TABLE password_presets ADD COLUMN username VARCHAR(100) DEFAULT 'root' COMMENT 'SSH用户名'",
|
|
"ALTER TABLE ssh_key_presets ADD COLUMN username VARCHAR(100) DEFAULT 'root' COMMENT 'SSH用户名'",
|
|
"""CREATE TABLE IF NOT EXISTS `pending_servers` (
|
|
`id` INT AUTO_INCREMENT PRIMARY KEY,
|
|
`name` VARCHAR(100) NOT NULL COMMENT '显示名称',
|
|
`domain` VARCHAR(255) NOT NULL COMMENT 'IP或域名',
|
|
`port` INT DEFAULT 22 COMMENT 'SSH端口',
|
|
`agent_port` INT DEFAULT 8601 COMMENT 'Agent端口',
|
|
`target_path` VARCHAR(500) NULL COMMENT '推送目标路径',
|
|
`category` VARCHAR(100) NULL COMMENT '分类',
|
|
`platform_id` INT NULL COMMENT '平台ID',
|
|
`node_id` INT NULL COMMENT '节点ID',
|
|
`attempts` INT DEFAULT 0 COMMENT '轮询尝试次数',
|
|
`last_error` TEXT NULL COMMENT '最后一次失败摘要',
|
|
`last_attempt_at` DATETIME NULL COMMENT '最后尝试时间',
|
|
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
INDEX `idx_pending_servers_domain` (`domain`)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4""",
|
|
"ALTER TABLE sync_logs ADD COLUMN push_permission VARCHAR(120) NULL COMMENT '推送后权限策略 chown=… chmod=…'",
|
|
"""CREATE TABLE IF NOT EXISTS `fleet_metric_samples` (
|
|
`id` INT AUTO_INCREMENT PRIMARY KEY,
|
|
`recorded_at` DATETIME NOT NULL COMMENT '采样时刻 UTC naive',
|
|
`online_count` INT DEFAULT 0 COMMENT '在线子机数',
|
|
`sample_count` INT DEFAULT 0 COMMENT '参与均值计算的子机数',
|
|
`cpu_avg` INT NULL COMMENT 'CPU 平均 %',
|
|
`mem_avg` INT NULL COMMENT '内存平均 %',
|
|
`disk_avg` INT NULL COMMENT '磁盘平均 %',
|
|
INDEX `idx_fleet_metric_recorded_at` (`recorded_at`)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4""",
|
|
"""CREATE TABLE IF NOT EXISTS `server_metric_samples` (
|
|
`id` INT AUTO_INCREMENT PRIMARY KEY,
|
|
`server_id` INT NOT NULL COMMENT '子机 ID',
|
|
`recorded_at` DATETIME NOT NULL COMMENT '采样时刻 UTC naive',
|
|
`is_online` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '采样时刻是否在线',
|
|
`cpu_pct` INT NULL COMMENT 'CPU %',
|
|
`mem_pct` INT NULL COMMENT '内存 %',
|
|
INDEX `idx_server_metric_server_recorded` (`server_id`, `recorded_at`),
|
|
CONSTRAINT `fk_server_metric_server` FOREIGN KEY (`server_id`) REFERENCES `servers` (`id`) ON DELETE CASCADE
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4""",
|
|
"""CREATE TABLE IF NOT EXISTS `admin_refresh_tokens` (
|
|
`id` INT AUTO_INCREMENT PRIMARY KEY,
|
|
`admin_id` INT NOT NULL COMMENT '管理员 ID',
|
|
`token_hash` VARCHAR(64) NOT NULL COMMENT 'SHA-256(refresh token)',
|
|
`expires_at` DATETIME NOT NULL COMMENT '过期时间 UTC',
|
|
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
UNIQUE KEY `uq_admin_refresh_token_hash` (`token_hash`),
|
|
INDEX `idx_admin_refresh_admin_expires` (`admin_id`, `expires_at`)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4""",
|
|
"DROP TABLE IF EXISTS `rdp_hosts`",
|
|
"""CREATE TABLE IF NOT EXISTS `admin_ui_preferences` (
|
|
`admin_id` INT NOT NULL COMMENT '管理员 ID',
|
|
`context` VARCHAR(50) NOT NULL COMMENT 'UI 上下文',
|
|
`payload` JSON NOT NULL COMMENT 'JSON 状态',
|
|
`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
|
PRIMARY KEY (`admin_id`, `context`),
|
|
CONSTRAINT `fk_admin_ui_pref_admin` FOREIGN KEY (`admin_id`) REFERENCES `admins` (`id`) ON DELETE CASCADE
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4""",
|
|
"""CREATE TABLE IF NOT EXISTS `watch_pin_sessions` (
|
|
`id` INT AUTO_INCREMENT PRIMARY KEY,
|
|
`admin_id` INT NOT NULL,
|
|
`server_id` INT NOT NULL,
|
|
`slot_index` INT NOT NULL,
|
|
`started_at` DATETIME NOT NULL,
|
|
`ended_at` DATETIME NULL,
|
|
`end_reason` VARCHAR(16) NULL,
|
|
INDEX `idx_watch_pin_sess_admin_started` (`admin_id`, `started_at`),
|
|
INDEX `idx_watch_pin_sess_server_started` (`server_id`, `started_at`),
|
|
CONSTRAINT `fk_watch_sess_admin` FOREIGN KEY (`admin_id`) REFERENCES `admins` (`id`) ON DELETE CASCADE,
|
|
CONSTRAINT `fk_watch_sess_server` FOREIGN KEY (`server_id`) REFERENCES `servers` (`id`) ON DELETE CASCADE
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4""",
|
|
"""CREATE TABLE IF NOT EXISTS `watch_pins` (
|
|
`id` INT AUTO_INCREMENT PRIMARY KEY,
|
|
`admin_id` INT NOT NULL,
|
|
`session_id` INT NOT NULL,
|
|
`slot_index` INT NOT NULL,
|
|
`server_id` INT NOT NULL,
|
|
`pinned_at` DATETIME NOT NULL,
|
|
`expires_at` DATETIME NOT NULL,
|
|
`monitoring_enabled` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否开启实时探针',
|
|
`ttl_hours` INT NOT NULL DEFAULT 8 COMMENT '监测窗口时长(小时) 8|24',
|
|
UNIQUE KEY `uq_watch_pins_admin_slot` (`admin_id`, `slot_index`),
|
|
UNIQUE KEY `uq_watch_pins_admin_server` (`admin_id`, `server_id`),
|
|
INDEX `idx_watch_pins_expires` (`expires_at`),
|
|
CONSTRAINT `fk_watch_pin_admin` FOREIGN KEY (`admin_id`) REFERENCES `admins` (`id`) ON DELETE CASCADE,
|
|
CONSTRAINT `fk_watch_pin_session` FOREIGN KEY (`session_id`) REFERENCES `watch_pin_sessions` (`id`) ON DELETE CASCADE,
|
|
CONSTRAINT `fk_watch_pin_server` FOREIGN KEY (`server_id`) REFERENCES `servers` (`id`) ON DELETE CASCADE
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4""",
|
|
"""CREATE TABLE IF NOT EXISTS `watch_probe_records` (
|
|
`id` BIGINT AUTO_INCREMENT PRIMARY KEY,
|
|
`session_id` INT NOT NULL,
|
|
`server_id` INT NOT NULL,
|
|
`admin_id` INT NOT NULL,
|
|
`recorded_at` DATETIME NOT NULL,
|
|
`source` VARCHAR(16) NOT NULL,
|
|
`probe_status` VARCHAR(24) NOT NULL,
|
|
`duration_ms` INT NOT NULL DEFAULT 0,
|
|
`is_online` TINYINT(1) NOT NULL DEFAULT 0,
|
|
`cpu_pct` INT NULL,
|
|
`mem_pct` INT NULL,
|
|
`disk_pct` INT NULL,
|
|
`disk_mount` VARCHAR(255) NULL DEFAULT '/',
|
|
`load_1` DOUBLE NULL,
|
|
`load_5` DOUBLE NULL,
|
|
`load_15` DOUBLE NULL,
|
|
`net_bytes_sent` BIGINT NULL,
|
|
`net_bytes_recv` BIGINT NULL,
|
|
`disk_read_bytes` BIGINT NULL,
|
|
`disk_write_bytes` BIGINT NULL,
|
|
`net_up_bps` BIGINT NULL,
|
|
`net_down_bps` BIGINT NULL,
|
|
`disk_read_bps` BIGINT NULL,
|
|
`disk_write_bps` BIGINT NULL,
|
|
`error` VARCHAR(255) NULL,
|
|
INDEX `idx_watch_probe_sess_recorded` (`session_id`, `recorded_at`),
|
|
INDEX `idx_watch_probe_server_recorded` (`server_id`, `recorded_at`),
|
|
INDEX `idx_watch_probe_admin_recorded` (`admin_id`, `recorded_at`),
|
|
INDEX `idx_watch_probe_status_recorded` (`probe_status`, `recorded_at`),
|
|
CONSTRAINT `fk_watch_probe_sess` FOREIGN KEY (`session_id`) REFERENCES `watch_pin_sessions` (`id`) ON DELETE CASCADE,
|
|
CONSTRAINT `fk_watch_probe_server` FOREIGN KEY (`server_id`) REFERENCES `servers` (`id`) ON DELETE CASCADE,
|
|
CONSTRAINT `fk_watch_probe_admin` FOREIGN KEY (`admin_id`) REFERENCES `admins` (`id`) ON DELETE CASCADE
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4""",
|
|
"ALTER TABLE `watch_probe_records` ADD COLUMN `processes_json` JSON NULL COMMENT 'Top5 processes snapshot'",
|
|
"ALTER TABLE `watch_pins` ADD COLUMN `monitoring_enabled` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否开启实时探针'",
|
|
"ALTER TABLE `watch_pins` ADD COLUMN `ttl_hours` INT NOT NULL DEFAULT 8 COMMENT '监测窗口时长(小时) 8|24'",
|
|
]
|
|
async with AsyncSessionLocal() as session:
|
|
for sql in migrations:
|
|
try:
|
|
await session.execute(text(sql))
|
|
await session.commit()
|
|
logger.info(f"Schema migration applied: {sql[:60]}...")
|
|
except Exception as e:
|
|
await session.rollback()
|
|
err = str(e).lower()
|
|
if (
|
|
"duplicate column" in err
|
|
or "1060" in err
|
|
or "can't drop" in err
|
|
or "check that column/key exists" in err
|
|
or "1091" in err
|
|
):
|
|
logger.debug("Schema migration skipped (already applied): %s", sql[:60])
|
|
else:
|
|
logger.error("Schema migration failed: %s — %s", sql[:60], e)
|
|
raise |