feat: two-phase migrations, DB settings loading in create_app

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-18 20:33:36 -04:00
parent 03bbd8d0fa
commit c5dff7ec8c
2 changed files with 86 additions and 43 deletions
+65 -30
View File
@@ -1,8 +1,9 @@
# fablednetmon/app.py
from __future__ import annotations
import asyncio
from pathlib import Path
from quart import Quart
from .config import load_config
from .config import load_bootstrap
from .database import init_db
@@ -12,72 +13,107 @@ def create_app(
) -> Quart:
app = Quart(__name__, template_folder="templates")
# Load config
cfg = load_config(config_path)
# ── 1. Bootstrap: db_url + secret_key only ────────────────────────────────
if not testing:
bootstrap = load_bootstrap(config_path)
else:
bootstrap = {
"database_url": "postgresql+asyncpg://test/test",
"secret_key": "test-secret-key",
"plugin_dir": "plugins",
}
app.config.update(
SECRET_KEY=cfg["secret_key"],
DATABASE_URL=cfg["database"]["url"],
SESSION_LIFETIME_HOURS=cfg.get("session", {}).get("lifetime_hours", 8),
DATA_RETENTION_DAYS=cfg.get("data", {}).get("retention_days", 90),
PLUGIN_DIR=cfg.get("plugin_dir", "plugins"),
PLUGINS=cfg.get("plugins", {}),
SMTP=cfg.get("smtp", {}),
WEBHOOK=cfg.get("webhook", {}),
ANSIBLE=cfg.get("ansible", {}),
SECRET_KEY=bootstrap["secret_key"],
DATABASE_URL=bootstrap["database_url"],
PLUGIN_DIR=bootstrap["plugin_dir"],
TESTING=testing,
_RAW_CFG=cfg,
)
# Database
# ── 2. Init DB engine ──────────────────────────────────────────────────────
if not testing:
init_db(app)
else:
from unittest.mock import MagicMock
app.db_sessionmaker = MagicMock()
# Run migrations (core + plugin) synchronously before event loop starts
# ── 3. Core migrations only (creates app_settings table) ──────────────────
if not testing:
from .core.migration_runner import run_migrations, get_plugin_migration_dirs
from .core.migration_runner import run_core_migrations
run_core_migrations(app.config["DATABASE_URL"])
# ── 4. Load all settings from DB → populate app.config ────────────────────
if not testing:
from .core.settings import (
load_settings_sync, to_smtp_cfg, to_webhook_cfg,
to_ansible_cfg, to_plugins_cfg,
)
_settings = load_settings_sync(app.config["DATABASE_URL"])
app.config.update(
SESSION_LIFETIME_HOURS=_settings.get("session.lifetime_hours", 8),
DATA_RETENTION_DAYS=_settings.get("data.retention_days", 90),
MONITORS_POLL_INTERVAL=_settings.get("monitors.poll_interval_seconds", 60),
SMTP=to_smtp_cfg(_settings),
WEBHOOK=to_webhook_cfg(_settings),
ANSIBLE=to_ansible_cfg(_settings),
PLUGINS=to_plugins_cfg(_settings),
)
else:
app.config.update(
SESSION_LIFETIME_HOURS=8,
DATA_RETENTION_DAYS=90,
MONITORS_POLL_INTERVAL=60,
SMTP={},
WEBHOOK={},
ANSIBLE={},
PLUGINS={},
)
# ── 5. Plugin migrations (depends on knowing which plugins are enabled) ────
if not testing:
from .core.migration_runner import run_plugin_migrations, get_plugin_migration_dirs
_plugin_dir = Path(app.config["PLUGIN_DIR"]).resolve()
_plugin_migration_dirs = get_plugin_migration_dirs(
_plugin_dir, app.config["PLUGINS"]
)
run_migrations(app.config["DATABASE_URL"], _plugin_migration_dirs)
run_plugin_migrations(app.config["DATABASE_URL"], _plugin_migration_dirs)
# Alert pipeline
# ── 6. Alert pipeline ──────────────────────────────────────────────────────
from .core.alerts import init_alerts
init_alerts(app)
# Register core blueprints
# ── 7. Register core blueprints ────────────────────────────────────────────
from .auth.routes import auth_bp
from .dashboard.routes import dashboard_bp
from .hosts.routes import hosts_bp
from .alerts.routes import alerts_bp
from .ansible.routes import ansible_bp
from .settings.routes import settings_bp
app.register_blueprint(auth_bp)
app.register_blueprint(dashboard_bp)
app.register_blueprint(hosts_bp)
app.register_blueprint(alerts_bp)
app.register_blueprint(ansible_bp)
app.register_blueprint(settings_bp)
# Build task registry
# ── 8. Build task registry ─────────────────────────────────────────────────
app._task_registry = []
if not testing:
_register_core_tasks(app, cfg)
_register_core_tasks(app)
# Load plugins
# ── 9. Load plugins ────────────────────────────────────────────────────────
if not testing:
from .core.plugin_manager import load_plugins
load_plugins(app)
# Health check
# ── 10. Health check ───────────────────────────────────────────────────────
@app.get("/health")
async def health():
return {"status": "ok"}
# Start scheduler on first request
# ── 11. Startup hook ───────────────────────────────────────────────────────
@app.before_serving
async def startup():
if not testing:
@@ -87,10 +123,10 @@ def create_app(
return app
def _register_core_tasks(app: Quart, cfg: dict) -> None:
def _register_core_tasks(app: Quart) -> None:
from .core.scheduler import ScheduledTask
poll_interval = cfg.get("monitors", {}).get("poll_interval_seconds", 60)
poll_interval = app.config.get("MONITORS_POLL_INTERVAL", 60)
cleanup_interval = 3600 # hourly
async def run_ping_monitors():
@@ -148,13 +184,13 @@ def _register_core_tasks(app: Quart, cfg: dict) -> None:
),
])
# Register git-pull tasks for each git source
ansible_cfg = cfg.get("ansible", {})
# Register git-pull tasks for each ansible git source
ansible_cfg = app.config.get("ANSIBLE", {})
from .ansible.sources import get_sources
for source in get_sources(ansible_cfg):
if source["type"] != "git":
continue
_source = source # capture for closure
_source = source
async def run_git_pull(src=_source):
from .ansible.sources import git_pull
@@ -171,7 +207,6 @@ def _register_core_tasks(app: Quart, cfg: dict) -> None:
async def _mark_interrupted_runs(app: Quart) -> None:
"""Mark any runs still in 'running' state as 'interrupted' (process restart)."""
from sqlalchemy import update
from .models.ansible import AnsibleRun, AnsibleRunStatus
from datetime import datetime, timezone