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>
454 lines
20 KiB
Python
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)
|