Files
FabledSteward/steward/core/settings.py
T
bvandeusen 88857be24e
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 7s
CI / integration (push) Successful in 2m18s
CI / publish (push) Successful in 55s
feat(ansible): runner robustness — cancel, concurrency, structured results, retention
Closes #550 (all four):

- Cancellation: track live subprocesses; POST /ansible/runs/<id>/cancel
  (operator) SIGTERMs then SIGKILLs after a grace; new 'cancelled' status
  (+ migration 0019, ALTER TYPE in autocommit). Queued runs cancel cleanly
  before launch. Cancel button on run detail.
- Concurrency: global semaphore (ansible.max_concurrent_runs, default 3,
  Settings→Ansible) caps simultaneous runs; excess show 'queued' (new status)
  until a slot frees. Semaphore bound lazily per running loop.
- Structured results: parse PLAY RECAP into per-host ok/changed/unreachable/
  failed/skipped + capture failed-task lines, stored in new results JSON
  column (migration 0020); rendered as a host-summary table on run detail.
  Keeps live streaming (no json-callback swap).
- Retention: full output written to a persistent log artifact
  (/data/ansible/runs/<id>.log, env-overridable) beyond the 1 MB DB cap and
  across restarts; in-memory replay buffer bounded + GC'd after completion;
  Download-log route. Boot reconciliation now also sweeps stale 'queued'.

Unit tests for recap parsing + cancel flagging. Status colors updated across
run list / detail / schedules.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 14:51:04 -04:00

255 lines
11 KiB
Python

# steward/core/settings.py
"""DB-backed application settings.
Keys use dotted notation (e.g. "smtp.host").
Values are JSON-encoded in the DB.
Usage in create_app() (before event loop):
from steward.core.settings import load_settings_sync
settings = load_settings_sync(db_url)
Usage at runtime (inside async handlers):
from steward.core.settings import get_setting, set_setting
async with app.db_sessionmaker() as session:
value = await get_setting(session, "smtp.host")
await set_setting(session, "smtp.host", "mail.example.com")
"""
from __future__ import annotations
import asyncio
import json
import logging
from datetime import datetime, timezone
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from steward.models.settings import AppSetting
logger = logging.getLogger(__name__)
_DEFAULT_WEBHOOK_TEMPLATE = (
'{"content": "**{{ alert.state }}** — {{ alert.resource }} — '
'{{ alert.rule_name }} ({{ alert.metric }} = {{ alert.value }})"}'
)
# All recognised settings and their defaults.
# Plugin settings are stored as "plugin.<name>" and handled separately.
DEFAULTS: dict[str, Any] = {
# General — external URL for install scripts, share links, alert deep-links.
# Empty = fall back to the current request's Host header.
"general.public_base_url": "",
"session.lifetime_hours": 8,
"data.retention_days": 90,
"monitors.poll_interval_seconds": 60,
"smtp.host": "",
"smtp.port": 587,
"smtp.tls": True,
"smtp.username": "",
"smtp.password": "",
"smtp.recipients": [],
"webhook.url": "",
"webhook.template": _DEFAULT_WEBHOOK_TEMPLATE,
"ansible.sources": [],
# Ansible credentials — global, used by every run (manual + alert-triggered).
# Plaintext at rest, masked in the UI (encryption-at-rest tracked separately).
"ansible.ssh_private_key": "",
"ansible.become_password": "",
"ansible.vault_password": "",
"ansible.host_key_checking": False,
# Max simultaneous playbook runs; extra runs queue. Applied at app start.
"ansible.max_concurrent_runs": 3,
"ping.threshold.good_ms": 50,
"ping.threshold.warn_ms": 200,
"plugins.index_url": "https://git.fabledsword.com/bvandeusen/Steward-plugins/raw/branch/main/index.yaml",
# Default-enabled plugins. These are the generic, non-vendor-specific
# bundled plugins (protocols/standards, not a single product) — useful on
# almost any install, so a fresh deployment comes up monitoring rather than
# blank. Vendor-specific plugins (traefik, unifi) stay opt-in. An operator
# who disables one writes plugin.<name>={"enabled": False}, which overrides
# these defaults (stored value wins in get_all_settings/load_settings_sync).
# Per-plugin yaml config defaults are merged on top at load time.
"plugin.docker": {"enabled": True},
"plugin.host_agent": {"enabled": True},
"plugin.http": {"enabled": True},
"plugin.snmp": {"enabled": True},
# OIDC single-sign-on
"oidc.enabled": False,
"oidc.discovery_url": "",
"oidc.client_id": "",
"oidc.client_secret": "",
"oidc.scopes": "openid profile email",
"oidc.username_claim": "preferred_username",
"oidc.email_claim": "email",
"oidc.groups_claim": "groups",
"oidc.admin_group": "",
"oidc.operator_group": "",
# LDAP authentication
"ldap.enabled": False,
"ldap.host": "",
"ldap.port": 389,
"ldap.tls": False,
"ldap.bind_dn": "",
"ldap.bind_password": "",
"ldap.base_dn": "",
"ldap.user_filter": "(uid={username})",
"ldap.admin_group_dn": "",
"ldap.operator_group_dn": "",
"ldap.attr_username": "uid",
"ldap.attr_email": "mail",
# Scheduled reports
"reports.enabled": False,
"reports.schedule_day": 6, # 0=Monday … 6=Sunday
"reports.schedule_hour": 8, # UTC hour
"reports.last_sent_at": "",
}
# ─────────────────────────────────────────────────────────────────────────────
# Async helpers (use inside request handlers / scheduled tasks)
# ─────────────────────────────────────────────────────────────────────────────
async def get_setting(session: AsyncSession, key: str) -> Any:
"""Return the value for key, or the default if not set."""
result = await session.execute(
select(AppSetting).where(AppSetting.key == key)
)
row = result.scalar_one_or_none()
if row is None:
return DEFAULTS.get(key)
return json.loads(row.value_json)
async def set_setting(session: AsyncSession, key: str, value: Any) -> None:
"""Upsert a setting. Call inside an active transaction."""
result = await session.execute(
select(AppSetting).where(AppSetting.key == key)
)
row = result.scalar_one_or_none()
now = datetime.now(timezone.utc)
if row is None:
session.add(AppSetting(key=key, value_json=json.dumps(value), updated_at=now))
else:
row.value_json = json.dumps(value)
row.updated_at = now
async def get_all_settings(session: AsyncSession) -> dict[str, Any]:
"""Return flat key→value dict with defaults filled in for missing keys."""
result = await session.execute(select(AppSetting))
stored = {row.key: json.loads(row.value_json) for row in result.scalars()}
out: dict[str, Any] = {}
for key, default in DEFAULTS.items():
out[key] = stored.get(key, default)
# Include any plugin.* keys stored in DB
for key, value in stored.items():
if key.startswith("plugin.") and key not in out:
out[key] = value
return out
# ─────────────────────────────────────────────────────────────────────────────
# Structured config extractors (dict shapes expected by existing consumers)
# ─────────────────────────────────────────────────────────────────────────────
def to_smtp_cfg(settings: dict[str, Any]) -> dict:
return {
"host": settings.get("smtp.host", ""),
"port": settings.get("smtp.port", 587),
"tls": settings.get("smtp.tls", True),
"username": settings.get("smtp.username", ""),
"password": settings.get("smtp.password", ""),
"recipients": settings.get("smtp.recipients", []),
}
def to_webhook_cfg(settings: dict[str, Any]) -> dict:
return {
"url": settings.get("webhook.url", ""),
"template": settings.get("webhook.template", _DEFAULT_WEBHOOK_TEMPLATE),
}
def to_ansible_cfg(settings: dict[str, Any]) -> dict:
return {
"sources": settings.get("ansible.sources", []),
"ssh_private_key": settings.get("ansible.ssh_private_key", ""),
"become_password": settings.get("ansible.become_password", ""),
"vault_password": settings.get("ansible.vault_password", ""),
"host_key_checking": settings.get("ansible.host_key_checking", False),
"max_concurrent_runs": settings.get("ansible.max_concurrent_runs", 3),
}
def to_oidc_cfg(settings: dict[str, Any]) -> dict:
return {k[len("oidc."):]: settings.get(k, DEFAULTS[k]) for k in DEFAULTS if k.startswith("oidc.")}
def to_ldap_cfg(settings: dict[str, Any]) -> dict:
return {k[len("ldap."):]: settings.get(k, DEFAULTS[k]) for k in DEFAULTS if k.startswith("ldap.")}
def to_plugins_cfg(settings: dict[str, Any]) -> dict:
"""Assemble {plugin_name: {...config}} from all plugin.* keys."""
result = {}
for key, value in settings.items():
if key.startswith("plugin."):
name = key[len("plugin."):]
result[name] = value
return result
# ─────────────────────────────────────────────────────────────────────────────
# Synchronous loader — safe to call before the event loop starts
# ─────────────────────────────────────────────────────────────────────────────
def load_settings_sync(db_url: str) -> dict[str, Any]:
"""Load all settings from DB synchronously via asyncio.run().
Safe to call in create_app() before the Quart event loop starts.
Returns flat key→value dict with defaults filled in.
"""
async def _load() -> dict[str, Any]:
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
engine = create_async_engine(db_url, echo=False)
factory = async_sessionmaker(engine, expire_on_commit=False)
try:
async with factory() as session:
result = await session.execute(select(AppSetting))
return {row.key: json.loads(row.value_json) for row in result.scalars()}
finally:
await engine.dispose()
stored = asyncio.run(_load())
out: dict[str, Any] = {}
for key, default in DEFAULTS.items():
out[key] = stored.get(key, default)
for key, value in stored.items():
if key.startswith("plugin.") and key not in out:
out[key] = value
return out
# ─────────────────────────────────────────────────────────────────────────────
# External URL helper
# ─────────────────────────────────────────────────────────────────────────────
def public_base_url(request) -> str:
"""Return the externally-reachable base URL for this Steward instance.
Prefers the admin-configured 'general.public_base_url' setting (cached in
current_app.config['PUBLIC_BASE_URL']) when set. Falls back to
request.host_url stripped of its trailing slash, so unconfigured
single-hostname installs keep working with zero config.
Use this — NOT request.host_url — anywhere you build a URL that will be
consumed by something outside this Quart request: install scripts, share
links, alert notifications, webhook callbacks. The Host header is not
reliable behind proxies or on multi-hostname deployments.
"""
from quart import current_app
configured = (current_app.config.get("PUBLIC_BASE_URL") or "").strip()
if configured:
return configured.rstrip("/")
return request.host_url.rstrip("/")