Files
FabledSteward/steward/app.py
T
bvandeusen 7b80552a7d
CI / lint (push) Successful in 3s
CI / integration (push) Successful in 2m16s
CI / unit (push) Successful in 4m28s
CI / publish (push) Successful in 1m4s
feat(docker): per-host collection via the host agent; drop central scrape
Docker collection moves off the central single-socket scrape onto the host
agent, giving Docker a real per-host dimension. The Steward host now reports
its own containers like any other host, and same-named containers on different
hosts no longer collide.

- agent: stdlib UDS Docker client (AF_UNIX HTTP/1.1, Connection: close,
  chunked-aware), collect_docker() ports the cpu%/mem math; sample["docker"]
  added best-effort (silent-skip on absent/unreadable socket). AGENT_VERSION
  1.2.0 → 1.3.0; optional docker_socket config key.
- ingest: host_agent ingest hands per-host container snapshots to the docker
  plugin via a new "docker.persist_host_samples" capability (no hard import,
  no-op when docker disabled), inside a SAVEPOINT so a docker failure never
  sinks the host metrics. Resource names are host-scoped ("<host>/<name>").
- schema: docker_containers re-keyed (host_id, name); docker_metrics gains
  host_id; docker_002 migration DROP+recreates (dev-only, rule 122).
- ui: Docker page + widgets grouped by host with host links; new per-host
  Docker panel embedded on the Hosts hub (gated on docker enabled via a new
  enabled_plugins template context). Replaces the SQLite-only strftime
  bucketing with DB-agnostic Python bucketing.
- provisioning: install/provision playbooks add steward-agent to the docker
  group (best-effort) so the agent can read the socket.
- removed central scrape: docker scheduler.py + scraper.py deleted; plugin.yaml
  socket_path/scrape_interval_seconds/include_stopped dropped (plugin 2.0.0).
- tests: agent docker collector units (math, chunked decode, silent-skip,
  sample shape, config) + integration (host-scoped schema + persistence).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Jg27rgypiW2efULXJDtMC
2026-06-18 17:54:36 -04:00

331 lines
14 KiB
Python

# 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=<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_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)