refactor: rename package directory fabledscryer → roundtable
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,295 @@
|
||||
# fabledscryer/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_dir": "plugins",
|
||||
}
|
||||
|
||||
app.config.update(
|
||||
SECRET_KEY=bootstrap["secret_key"],
|
||||
DATABASE_URL=bootstrap["database_url"],
|
||||
PLUGIN_DIR=bootstrap["plugin_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_dir=Path(app.config["PLUGIN_DIR"]).resolve(),
|
||||
)
|
||||
|
||||
# ── 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,
|
||||
)
|
||||
_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),
|
||||
)
|
||||
else:
|
||||
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},
|
||||
)
|
||||
|
||||
# ── 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_plugin_migration_dirs
|
||||
_plugin_dir = Path(app.config["PLUGIN_DIR"]).resolve()
|
||||
_all_plugin_dirs = discover_all_plugin_migration_dirs(_plugin_dir)
|
||||
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 .ping.routes import ping_bp
|
||||
from .dns.routes import dns_bp
|
||||
from .alerts.routes import alerts_bp
|
||||
from .ansible.routes import ansible_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(ping_bp)
|
||||
app.register_blueprint(dns_bp)
|
||||
app.register_blueprint(alerts_bp)
|
||||
app.register_blueprint(ansible_bp)
|
||||
app.register_blueprint(settings_bp)
|
||||
app.register_blueprint(audit_bp)
|
||||
|
||||
# ── 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
|
||||
return {"plugin_failures": get_plugin_failures()}
|
||||
|
||||
# ── 11. Share-token middleware ─────────────────────────────────────────────
|
||||
@app.before_request
|
||||
async def _validate_share_token():
|
||||
"""If ?s=<token> 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_ping_monitors():
|
||||
from sqlalchemy import select
|
||||
from .models.hosts import Host
|
||||
from .monitors.ping import ping_check
|
||||
async with app.db_sessionmaker() as session:
|
||||
async with session.begin():
|
||||
result = await session.execute(
|
||||
select(Host).where(Host.ping_enabled.is_(True))
|
||||
)
|
||||
for host in result.scalars().all():
|
||||
try:
|
||||
await ping_check(host, session)
|
||||
except Exception:
|
||||
app.logger.exception(f"Ping check failed for host {host.name!r}")
|
||||
|
||||
async def run_dns_monitors():
|
||||
from sqlalchemy import select
|
||||
from .models.hosts import Host
|
||||
from .monitors.dns import dns_check
|
||||
async with app.db_sessionmaker() as session:
|
||||
async with session.begin():
|
||||
result = await session.execute(
|
||||
select(Host).where(Host.dns_enabled.is_(True))
|
||||
)
|
||||
for host in result.scalars().all():
|
||||
try:
|
||||
await dns_check(host, session)
|
||||
except Exception:
|
||||
app.logger.exception(f"DNS check failed for host {host.name!r}")
|
||||
|
||||
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="ping_monitor",
|
||||
coro_factory=run_ping_monitors,
|
||||
interval_seconds=poll_interval,
|
||||
run_on_startup=True,
|
||||
),
|
||||
ScheduledTask(
|
||||
name="dns_monitor",
|
||||
coro_factory=run_dns_monitors,
|
||||
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,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
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 == AnsibleRunStatus.running)
|
||||
.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)
|
||||
Reference in New Issue
Block a user