#!/usr/bin/env python3 """Create nexus database (if missing) and all tables via SQLAlchemy init_db + migrations. Run from project root: python3 scripts/bootstrap_database.py """ import asyncio import os import re import sys from pathlib import Path from urllib.parse import unquote ROOT = Path(__file__).resolve().parent.parent sys.path.insert(0, str(ROOT)) # Load only NEXUS_* from .env (MYSQL_* is for MCP only) env_path = ROOT / ".env" if env_path.exists(): for line in env_path.read_text(encoding="utf-8").splitlines(): line = line.strip() if line and not line.startswith("#") and "=" in line: k, v = line.split("=", 1) k = k.strip() if k.startswith("NEXUS_"): os.environ.setdefault(k, v.strip()) URL_RE = re.compile( r"^mysql\+(?:aiomysql|pymysql)://([^:]+):([^@]+)@([^:/]+)(?::(\d+))?/([^?\s#]+)", re.IGNORECASE, ) EXPECTED_TABLES = [ "admins", "audit_logs", "command_logs", "db_credentials", "login_attempts", "nodes", "password_presets", "platforms", "push_retry_jobs", "push_schedules", "script_executions", "scripts", "servers", "settings", "ssh_sessions", "sync_logs", ] _DB_NAME_RE = re.compile(r"^[A-Za-z0-9_]{1,64}$") def parse_url() -> dict: url = os.environ.get("NEXUS_DATABASE_URL", "") m = URL_RE.match(url.strip()) if not m: raise SystemExit(f"Invalid NEXUS_DATABASE_URL: {url!r}") user, password, host, port, database = m.groups() return { "host": host, "port": int(port or 3306), "user": unquote(user), "password": unquote(password), "database": unquote(database), } async def ensure_database(cfg: dict) -> None: db_name = cfg["database"] if not _DB_NAME_RE.match(db_name): raise SystemExit(f"Invalid database name (allowed: [A-Za-z0-9_], max 64): {db_name!r}") import aiomysql conn = await aiomysql.connect( host=cfg["host"], port=cfg["port"], user=cfg["user"], password=cfg["password"], connect_timeout=15, ) async with conn.cursor() as cur: await cur.execute( f"CREATE DATABASE IF NOT EXISTS `{cfg['database']}` " "CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci" ) conn.close() print(f"OK: database `{cfg['database']}` exists") async def init_schema() -> list[str]: from server.infrastructure.database.session import init_db from server.infrastructure.database.migrations import run_migrations from server.infrastructure.database.session import AsyncSessionLocal from sqlalchemy import text await init_db() print("OK: init_db() — tables created via SQLAlchemy metadata") await run_migrations() print("OK: run_migrations() — data + schema migrations") async with AsyncSessionLocal() as session: result = await session.execute(text("SHOW TABLES")) return sorted(row[0] for row in result.all()) async def main() -> int: cfg = parse_url() print(f"Bootstrap {cfg['user']}@{cfg['host']}:{cfg['port']}/{cfg['database']}") await ensure_database(cfg) tables = await init_schema() missing = [t for t in EXPECTED_TABLES if t not in tables] extra = [t for t in tables if t not in EXPECTED_TABLES] print(f"\nTables ({len(tables)}): {tables}") if missing: print("WARNING missing expected:", missing) return 1 if extra: print("Extra tables:", extra) print("\nBootstrap complete.") return 0 if __name__ == "__main__": raise SystemExit(asyncio.run(main()))