# steward/app.py from __future__ import annotations import asyncio from pathlib import Path from quart import Quart, render_template from .config import load_bootstrap from .database import init_db def create_app( config_path: Path | str | None = None, testing: bool = False, ) -> Quart: app = Quart(__name__, template_folder="templates") # ── 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_dirs": ["plugins"], "plugin_install_dir": "plugins", } app.config.update( SECRET_KEY=bootstrap["secret_key"], DATABASE_URL=bootstrap["database_url"], PLUGIN_DIRS=bootstrap["plugin_dirs"], PLUGIN_INSTALL_DIR=bootstrap["plugin_install_dir"], TESTING=testing, ) # ── 2. Init DB engine ────────────────────────────────────────────────────── if not testing: init_db(app) else: from unittest.mock import MagicMock app.db_sessionmaker = MagicMock() # ── 3. Core migrations only (creates app_settings table) ────────────────── if not testing: from .core.migration_runner import run_core_migrations run_core_migrations( app.config["DATABASE_URL"], plugin_dirs=[Path(d).resolve() for d in app.config["PLUGIN_DIRS"]], ) # ── 3b. Encryption-at-rest: bind the encryptor, then encrypt legacy secrets ─ if not testing: from .core.crypto import init_crypto from .core.settings import migrate_plaintext_secrets init_crypto(app.config["SECRET_KEY"]) migrate_plaintext_secrets(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, to_oidc_cfg, to_ldap_cfg, to_thresholds_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), OIDC=to_oidc_cfg(_settings), LDAP=to_ldap_cfg(_settings), THRESHOLDS=to_thresholds_cfg(_settings), ) else: from .core.settings import to_thresholds_cfg app.config.update( SESSION_LIFETIME_HOURS=8, DATA_RETENTION_DAYS=90, MONITORS_POLL_INTERVAL=60, SMTP={}, WEBHOOK={}, ANSIBLE={}, PLUGINS={}, OIDC={"enabled": False}, LDAP={"enabled": False}, THRESHOLDS=to_thresholds_cfg({}), ) # `threshold_style(value, kind)` — degraded-value coloring for templates, # reading the configurable cutoffs from app.config["THRESHOLDS"]. Available # in every template (core + plugin) via the jinja global. from .core.settings import threshold_style_for as _ts_for app.jinja_env.globals["threshold_style"] = ( lambda value, kind: _ts_for(value, kind, app.config.get("THRESHOLDS", {})) ) # ── 5. Plugin migrations ─────────────────────────────────────────────────── # Step 3 already applied all plugin migrations via discover_all_plugin_migration_dirs, # so this is a no-op on normal startup. We still run it with all discovered dirs so # Alembic can resolve the full revision graph regardless of which plugins are enabled. if not testing: from .core.migration_runner import run_plugin_migrations, discover_all_in _plugin_dirs = [Path(d).resolve() for d in app.config["PLUGIN_DIRS"]] _all_plugin_dirs = discover_all_in(_plugin_dirs) run_plugin_migrations(app.config["DATABASE_URL"], _all_plugin_dirs) # ── 6. Alert pipeline ────────────────────────────────────────────────────── from .core.alerts import init_alerts init_alerts(app) # ── 7. Register core blueprints ──────────────────────────────────────────── from .auth.routes import auth_bp from .dashboard.routes import dashboard_bp from .hosts.routes import hosts_bp from .monitors.routes import monitors_bp from .status.routes import status_bp from .alerts.routes import alerts_bp from .ansible.routes import ansible_bp from .ansible.inventory_routes import inventory_bp from .settings.routes import settings_bp from .audit.routes import audit_bp app.register_blueprint(auth_bp) app.register_blueprint(dashboard_bp) app.register_blueprint(hosts_bp) app.register_blueprint(monitors_bp) app.register_blueprint(status_bp) app.register_blueprint(alerts_bp) app.register_blueprint(ansible_bp) app.register_blueprint(inventory_bp) app.register_blueprint(settings_bp) app.register_blueprint(audit_bp) # Register the unified Monitor status source for the Status page. Plugins # may register additional sources from setup(). Idempotent. from .core.status import register_status_source, monitor_status_source register_status_source(monitor_status_source) # Publish the Ansible "run a playbook" capability so plugins (e.g. host_agent # auto-deploy) can drive runs without importing the runner. Ansible is core, # so this is always available; consumers still gate on has_capability(). from .core.capabilities import register_capability from .ansible.runner import trigger_run from .models.users import UserRole as _UserRole register_capability( "ansible.run_playbook", trigger_run, label="Run Ansible playbook", description="Launch an Ansible playbook run (manual, alert, schedule, or plugin-driven).", required_role=_UserRole.operator, ) # ── 8. Build task registry ───────────────────────────────────────────────── app._task_registry = [] if not testing: _register_core_tasks(app) # ── 9. Load plugins ──────────────────────────────────────────────────────── if not testing: from .core.plugin_manager import load_plugins load_plugins(app) # ── 10. Template context: inject plugin_failures into every response ─────── @app.context_processor def _inject_plugin_failures(): from .core.plugin_manager import get_plugin_failures # enabled_plugins lets templates gate cross-plugin embeds (e.g. the # Hosts hub only fetches the docker fragment when docker is enabled), # avoiding a 404 to a route whose blueprint isn't registered. enabled_plugins = { name for name, cfg in (app.config.get("PLUGINS") or {}).items() if isinstance(cfg, dict) and cfg.get("enabled") } return { "plugin_failures": get_plugin_failures(), "enabled_plugins": enabled_plugins, } # ── 11. Share-token middleware ───────────────────────────────────────────── @app.before_request async def _validate_share_token(): """If ?s= is present, validate it and set g.share_viewer.""" from quart import request, g raw = request.args.get("s") if not raw: return try: import hashlib from datetime import datetime, timezone from sqlalchemy import select from .models.dashboard import DashboardShareToken token_hash = hashlib.sha256(raw.encode()).hexdigest() async with app.db_sessionmaker() as db: result = await db.execute( select(DashboardShareToken) .where(DashboardShareToken.token_hash == token_hash) ) share = result.scalar_one_or_none() if share: now = datetime.now(timezone.utc) if share.expires_at is None or share.expires_at > now: g.share_viewer = True g.share_dashboard_id = share.dashboard_id except Exception: pass # never block a request due to token validation failure # ── 11. Health check ─────────────────────────────────────────────────────── @app.get("/health") async def health(): return {"status": "ok"} # ── 12. Error handlers ───────────────────────────────────────────────────── @app.errorhandler(404) async def not_found(_): return await render_template("errors/404.html"), 404 @app.errorhandler(500) async def server_error(_): return await render_template("errors/500.html"), 500 # ── 11. Startup hook ─────────────────────────────────────────────────────── @app.before_serving async def startup(): if not testing: await _mark_interrupted_runs(app) asyncio.create_task(_run_scheduler(app)) return app def _register_core_tasks(app: Quart) -> None: from .core.scheduler import ScheduledTask poll_interval = app.config.get("MONITORS_POLL_INTERVAL", 60) cleanup_interval = 3600 # hourly async def run_monitor_checks(): from .monitors.scheduler import run_due_monitors await run_due_monitors(app) async def run_cleanup(): from .core.cleanup import run_cleanup as _cleanup await _cleanup(app) async def run_report_check(): from .core.reports import check_and_send await check_and_send(app) app._task_registry.extend([ ScheduledTask( name="weekly_report_check", coro_factory=run_report_check, interval_seconds=3600, run_on_startup=False, ), ScheduledTask( name="monitor_check", coro_factory=run_monitor_checks, interval_seconds=poll_interval, run_on_startup=True, ), ScheduledTask( name="data_cleanup", coro_factory=run_cleanup, interval_seconds=cleanup_interval, run_on_startup=False, ), ]) # 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 async def run_git_pull(src=_source): from .ansible.sources import git_pull await git_pull(src) app._task_registry.append( ScheduledTask( name=f"ansible_git_pull_{_source['name']}", coro_factory=run_git_pull, interval_seconds=_source["pull_interval_seconds"], run_on_startup=True, ) ) # Fire due Ansible schedules (recurring playbook runs). Checks every minute; # each schedule's own interval gates whether it actually runs. async def run_ansible_schedules(): from .ansible.scheduler import run_due_schedules await run_due_schedules(app) app._task_registry.append( ScheduledTask( name="ansible_scheduled_runs", coro_factory=run_ansible_schedules, interval_seconds=60, run_on_startup=False, ) ) async def _mark_interrupted_runs(app: Quart) -> None: from sqlalchemy import update from .models.ansible import AnsibleRun, AnsibleRunStatus from datetime import datetime, timezone async with app.db_sessionmaker() as session: async with session.begin(): await session.execute( update(AnsibleRun) .where(AnsibleRun.status.in_( [AnsibleRunStatus.running, AnsibleRunStatus.queued])) .values( status=AnsibleRunStatus.interrupted, finished_at=datetime.now(timezone.utc), ) ) async def _run_scheduler(app: Quart) -> None: from .core.scheduler import start_scheduler await start_scheduler(app._task_registry)