Files
FabledSteward/steward/core/settings.py
T
bvandeusen b49496b57b
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 7s
CI / integration (push) Failing after 2m16s
CI / publish (push) Has been skipped
feat(plugins): default-enable generic bundled plugins on fresh install
A fresh install enabled zero plugins — settings.py DEFAULTS had no plugin.*
keys, so to_plugins_cfg returned {} and every plugin had to be flipped on
by hand. Seed docker, host_agent, http, snmp as default-on (generic, non-
vendor-specific); traefik and unifi stay opt-in. Stored choices override
the default, so disabling persists.

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

252 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,
"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),
}
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("/")