Files
FabledSteward/steward/app.py
T
bvandeusenandClaude Opus 5.5 c368e93e2c
CI / lint (push) Successful in 2s
CI / unit (push) Failing after 1m14s
CI / integration (push) Successful in 1m14s
CI / publish (push) Skipped
feat(alerts): silence is alertable — agent offline and no-data (#5062)
Agent offline is an ordinary rule now. The host_agent scheduler records
host_agent/<host>/seconds_since_seen every minute (from last_seen_at, or
registration time for an agent that never reported), replacing the task
that only logged stale agents. The catalog offers the metric, and picking
it on a new rule fills "> 180".

No data: each rule's state tracks its last sample and its usual gap,
smoothed so that an outage counts as at most three gaps. A core task,
alert_silence_check, runs every 60s and marks a rule no_data_since once its
series has had no sample for three usual gaps (at least 5 minutes; an hour
while the cadence is unknown). A never-sampled rule counts from when it
started watching. The mark sits beside the state, so a firing alert that
goes silent stays firing. Each silence records one event and sends one
notification (rule option notify_no_data, default on; muted in maintenance
windows). The next sample clears it with a "data resumed" event and
notification. Creating, changing, disabling or re-enabling a rule restarts
its watch.

Migration 0029 adds the columns and seeds each existing rule's last sample
and gap from its last day of data, so an upgrade does not flag every rule.

Shown on the Alerts page (a NO DATA badge with its start time), in MCP
list_alerts, list_alert_rules and get_host (state "no_data", no_data_since),
in get_overview findings (alert_no_data, or a firing alert marked silent)
and in the weekly report. The rule form has the notify option, and its
metric reference now matches what traefik records and lists host_agent.
Emails say "no data" and carry the event's note.

The metric-catalog guard also reads positional MetricBatch.add arguments.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 13:06:54 -04:00

454 lines
20 KiB
Python

# steward/app.py
from __future__ import annotations
import asyncio
import logging
from pathlib import Path
from quart import Quart, render_template, request
from .config import load_bootstrap
from .database import (
init_db, ensure_database_reachable, DatabaseUnavailable,
DB_CONNECT_TIMEOUT_SECONDS,
)
logger = logging.getLogger(__name__)
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",
"db_connect_timeout": DB_CONNECT_TIMEOUT_SECONDS,
"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,
# Lax keeps the session cookie off cross-site POSTs and iframes; the
# CSRF token below is the check that does not depend on the browser.
SESSION_COOKIE_SAMESITE="Lax",
)
# ── 2. Init DB engine ──────────────────────────────────────────────────────
if not testing:
init_db(app)
else:
from unittest.mock import MagicMock
app.db_sessionmaker = MagicMock()
# ── 2b. Block until the database is actually reachable ────────────────────
# Everything from here down touches the DB synchronously (migrations,
# secret re-encryption, settings load). Gate all of it behind one readiness
# check so a DB that is merely slow to come up — WAL recovery after an
# unclean shutdown, or Docker DNS not yet serving the `db` name after a host
# reboot — is waited out instead of crashing the container on a traceback.
if not testing:
from .database import wait_for_database
wait_for_database(
app.config["DATABASE_URL"],
timeout_seconds=bootstrap["db_connect_timeout"],
)
# ── 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, scan_undecryptable_secrets
init_crypto(app.config["SECRET_KEY"])
migrate_plaintext_secrets(app.config["DATABASE_URL"])
# Flag any secrets sealed under a now-lost key (see the admin banner).
undecryptable = scan_undecryptable_secrets(app.config["DATABASE_URL"])
if undecryptable:
logger.warning(
"%d stored secret(s) cannot be decrypted with the current app key "
"(re-enter them in Settings): %s",
len(undecryptable), ", ".join(undecryptable),
)
# ── 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, to_access_cfg,
)
_settings = load_settings_sync(app.config["DATABASE_URL"])
app.config.update(to_access_cfg(_settings))
app.config.update(
DATA_RETENTION_DAYS=_settings.get("data.retention_days", 90),
MONITORS_POLL_INTERVAL=_settings.get("monitors.poll_interval_seconds", 60),
PUBLIC_BASE_URL=_settings.get("general.public_base_url", ""),
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_access_cfg, to_thresholds_cfg
app.config.update(to_access_cfg({}))
app.config.update(
DATA_RETENTION_DAYS=90,
MONITORS_POLL_INTERVAL=60,
PUBLIC_BASE_URL="",
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", {}))
)
# Forged-request protection: every POST form renders csrf_field(), HTMX and
# fetch() send csrf_token() as X-CSRF-Token, and one check refuses any
# unsafe request without it (steward/auth/csrf.py).
from .auth import csrf as _csrf
app.jinja_env.globals["csrf_token"] = _csrf.csrf_token
app.jinja_env.globals["csrf_field"] = _csrf.csrf_field
app.before_request(_csrf.check_request)
# The session cookie is Secure, and HSTS is sent, only on requests that
# arrived over HTTPS — directly or as a trusted proxy reports it
# (steward/core/proxy.py). Plain-HTTP and LAN use keep working.
from quart import has_request_context
from quart.sessions import SecureCookieSessionInterface
from .core.proxy import is_https
class _SessionInterface(SecureCookieSessionInterface):
def get_cookie_secure(self, app):
return bool(app.config.get("SESSION_COOKIE_SECURE")) or (
has_request_context() and is_https())
app.session_interface = _SessionInterface()
@app.after_request
async def _hsts(response):
if is_https():
response.headers.setdefault("Strict-Transport-Security", "max-age=31536000")
return response
# Default-deny: a view with neither require_role nor public_route is refused.
from .auth.middleware import check_view_is_marked, public_route
app.before_request(check_view_is_marked)
# ── 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
from .ai_access.routes import ai_access_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)
app.register_blueprint(ai_access_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, get_plugin_nav
from .core.settings import get_undecryptable_secrets
# 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")
}
# Sidebar entries for enabled plugins that expose a UI (get_nav). Filter
# by enabled so a hot-disabled plugin's link can't linger before restart.
plugin_nav = [e for e in get_plugin_nav() if e["plugin"] in enabled_plugins]
return {
"plugin_failures": get_plugin_failures(),
"enabled_plugins": enabled_plugins,
"plugin_nav": plugin_nav,
# Read from an in-memory cache (no DB hit per render); kept current by
# the startup scan + set_setting's discard-on-write.
"undecryptable_secrets": get_undecryptable_secrets(),
}
# ── 11. Share-token middleware ─────────────────────────────────────────────
@app.before_request
async def _validate_share_token():
"""A share link's token (?s=) opens, read-only, exactly the widget
fragments its own dashboard loads (steward/dashboard/share.py)."""
from quart import request, g
raw = request.args.get("s")
if not raw or request.method != "GET":
return
try:
from .dashboard.share import share_grants
async with app.db_sessionmaker() as db:
if await share_grants(db, raw, request.path, request.args):
g.share_viewer = True
except Exception:
# A failed check grants nothing; the request falls through to login.
logging.getLogger(__name__).exception("Share token check failed")
# ── 11. Health check ───────────────────────────────────────────────────────
@app.get("/health")
@public_route("liveness probe for the container")
async def health():
return {"status": "ok"}
# ── 11b. Database availability gate ────────────────────────────────────────
# Every page in Steward reads the database, so a database that has gone away
# under a running app turns each request into an opaque 500 (this is how a
# login attempt surfaced a bare gaierror). Acquire a connection up front,
# retrying briefly to ride out a restart, and answer honestly if it stays
# down rather than failing deep inside a handler with a generic error.
if not testing:
@app.before_request
async def _database_gate():
# /health is a liveness probe for the container itself — it must
# stay answerable while the database is down, or a restart loop
# gets triggered by a dependency outage. Static files need no DB.
if request.endpoint in ("health", "static"):
return None
try:
await ensure_database_reachable(app._db_engine)
except DatabaseUnavailable as exc:
logger.error("Database unreachable while serving %s: %s",
request.path, exc)
return await render_template(
"errors/database_unavailable.html"), 503
return None
# ── 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
# ── Session freshness: lifetime, deactivation and role changes apply per request
if not testing:
from .auth.middleware import refresh_session_user
app.before_request(refresh_session_user)
# ── MCP endpoint (/mcp) ───────────────────────────────────────────────────
# Mounted after plugins load so their register_mcp_tools() hooks are seen.
# It wraps app.asgi_app: /mcp never enters Quart's pipeline (see mcp/server.py).
from .mcp import mount_mcp
mount_mcp(app)
# ── 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_silence_check():
from .core.alerts import check_silence
await check_silence(app)
async def run_report_check():
from .core.reports import check_and_send
await check_and_send(app)
async def run_self_monitor():
from .core.self_monitor import record_self_metrics
await record_self_metrics(app)
app._task_registry.extend([
ScheduledTask(
name="weekly_report_check",
coro_factory=run_report_check,
interval_seconds=3600,
run_on_startup=False,
),
# Watch our own fd usage so a descriptor leak surfaces as a metric/alert
# instead of a silent Errno 24 lockup. Cheap; plugin_metrics roll up
# hourly so the 60s cadence is storage-bounded.
ScheduledTask(
name="self_monitor",
coro_factory=run_self_monitor,
interval_seconds=60,
run_on_startup=True,
),
# Rules whose series has gone quiet get a visible no_data mark.
ScheduledTask(
name="alert_silence_check",
coro_factory=run_silence_check,
interval_seconds=60,
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)