"""Nexus Configuration — Pydantic Settings with MySQL overrides Settings loading priority: 1. .env file (bootstrap — required for DB connection to even work) 2. MySQL settings table (dynamic overrides — loaded at startup via load_settings_from_db) Immutable settings (not overridable from DB): - SECRET_KEY, API_KEY, ENCRYPTION_KEY, DATABASE_URL — must be set in .env Mutable settings (overridable from DB): - system_name/title, pool_size/overflow, redis_url, alert thresholds, Telegram config """ import logging from typing import Optional from pydantic_settings import BaseSettings from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession logger = logging.getLogger("nexus.config") class Settings(BaseSettings): """Base settings loaded from .env file, overrideable via MySQL settings table""" # Brand (configurable in system settings) SYSTEM_NAME: str = "Nexus" SYSTEM_TITLE: str = "Nexus — 服务器运维管理平台" # Server HOST: str = "0.0.0.0" PORT: int = 8600 # Deployment DEPLOY_PATH: str = "/opt/nexus" # Base directory on the server CORS_ORIGINS: str = "" # Comma-separated: "https://example.com,http://localhost:8600" # Database (immutable — must be set in .env) DATABASE_URL: str = "mysql+aiomysql://root:password@127.0.0.1:3306/nexus" # Pool params: install.php auto-calculates from MySQL max_connections # Formula: pool_size = max(20, maxConn * 0.4), overflow = max(20, maxConn * 0.3) # MySQL max_connections=400 → pool=160, overflow=120, max=280 # These defaults match install.php; override via .env or MySQL settings table DB_POOL_SIZE: Optional[int] = 160 DB_MAX_OVERFLOW: Optional[int] = 120 # Security (immutable — changing breaks encryption/auth) SECRET_KEY: str = "" # Must be set in .env; empty = startup error API_KEY: str = "" ENCRYPTION_KEY: str = "" # Redis (mutable — can be changed from settings UI) REDIS_URL: str = "redis://127.0.0.1:6379/0" # SSH SSH_STRICT_HOST_CHECKING: str = "true" # Health Check / Heartbeat HEALTH_CHECK_INTERVAL: int = 60 # seconds — Agent heartbeat interval # Alert thresholds (mutable — configurable from settings UI) CPU_ALERT_THRESHOLD: int = 80 MEM_ALERT_THRESHOLD: int = 80 DISK_ALERT_THRESHOLD: int = 80 # Telegram (mutable — configurable from settings UI) TELEGRAM_BOT_TOKEN: str = "" TELEGRAM_CHAT_ID: str = "" model_config = {"env_prefix": "NEXUS_", "env_file": ".env"} # ── MySQL settings table override ── # Mapping: MySQL settings.key → Settings attribute name # Only mutable settings are listed here; immutable ones (SECRET_KEY, API_KEY, etc.) are NOT DB_OVERRIDE_MAP: dict = { "system_name": "SYSTEM_NAME", "system_title": "SYSTEM_TITLE", "db_pool_size": "DB_POOL_SIZE", "db_max_overflow": "DB_MAX_OVERFLOW", "redis_url": "REDIS_URL", "heartbeat_timeout": "HEALTH_CHECK_INTERVAL", "cpu_alert_threshold": "CPU_ALERT_THRESHOLD", "mem_alert_threshold": "MEM_ALERT_THRESHOLD", "disk_alert_threshold": "DISK_ALERT_THRESHOLD", "telegram_bot_token": "TELEGRAM_BOT_TOKEN", "telegram_chat_id": "TELEGRAM_CHAT_ID", } # Settings that require int conversion from DB string values INT_SETTINGS: set = { "DB_POOL_SIZE", "DB_MAX_OVERFLOW", "HEALTH_CHECK_INTERVAL", "CPU_ALERT_THRESHOLD", "MEM_ALERT_THRESHOLD", "DISK_ALERT_THRESHOLD", } async def load_settings_from_db(self, session: AsyncSession) -> int: """Override mutable settings from MySQL settings table. Called during app startup (after init_db) to apply dynamic config. Immutable settings (SECRET_KEY, API_KEY, ENCRYPTION_KEY, DATABASE_URL) are NOT overridable — they stay as .env values. Returns: number of settings overridden from DB """ from server.domain.models import Setting try: result = await session.execute(select(Setting)) rows = result.scalars().all() overridden = 0 for row in rows: attr_name = self.DB_OVERRIDE_MAP.get(row.key) if attr_name and hasattr(self, attr_name): value = row.value # Type conversion if attr_name in self.INT_SETTINGS: try: value = int(value) except (ValueError, TypeError): logger.warning(f"Invalid int value for {row.key}: {value}, skipping") continue setattr(self, attr_name, value) overridden += 1 logger.info( f"Settings loaded from DB: {len(rows)} keys read, " f"{overridden} overridden" ) return overridden except Exception as e: logger.error(f"Failed to load settings from DB: {e}") return 0 settings = Settings()