faecac3ec6
Bounds Docker time-series growth (the main scaling concern). New docker_metrics_hourly table + docker_006 migration; a plugin retention module (docker.run_retention capability) rolls raw docker_metrics older than the raw window into hourly averages (idempotent upsert), deletes the rolled raw rows, then prunes stale rollups + lifecycle events. Core cleanup.py drives it each hourly run via the capability (no plugin-model import), reading the three retention windows fresh from settings so changes apply without restart (rule 25). Settings → "Thresholds & Retention" gains a Docker retention card (raw / rolled-up / events windows, working defaults 7/90/30 days). Unit tests cover the hour-aligned cutoff/bucketing helpers; integration test exercises the real rollup-average + prune across both windows. Milestone 77 task #941. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016Jg27rgypiW2efULXJDtMC
384 lines
17 KiB
Python
384 lines
17 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).
|
|
# ssh_private_key/become/vault are encrypted at rest (see SECRET_KEYS).
|
|
"ansible.ssh_private_key": "",
|
|
"ansible.become_password": "",
|
|
"ansible.vault_password": "",
|
|
# Public half of the managed keypair — non-secret, displayed so it can be
|
|
# sprayed onto hosts (provisioning installs it into ~steward/.ssh).
|
|
"ansible.ssh_public_key": "",
|
|
# Default SSH login for steady-state runs — the dedicated account
|
|
# provisioning creates. Acts as a floor (--user); a target's ansible_user
|
|
# or a per-run bootstrap override still wins.
|
|
"ansible.ssh_user": "steward",
|
|
"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,
|
|
# Degraded/critical cutoffs for metric coloring (warn=amber, crit=red).
|
|
# cpu/mem/disk/load are percentages (load is load-per-core %); temp in °C.
|
|
# uptime is "higher is better" so its warn/crit are floors. Ping latency
|
|
# reuses ping.threshold.good_ms/warn_ms above.
|
|
"thresholds.cpu_warn": 80, "thresholds.cpu_crit": 90,
|
|
"thresholds.mem_warn": 80, "thresholds.mem_crit": 90,
|
|
"thresholds.disk_warn": 80, "thresholds.disk_crit": 90,
|
|
"thresholds.load_warn": 80, "thresholds.load_crit": 100,
|
|
"thresholds.temp_warn": 70, "thresholds.temp_crit": 85,
|
|
"thresholds.uptime_warn": 99.0, "thresholds.uptime_crit": 95.0,
|
|
# Docker time-series retention (rule 25 — tunable, no restart). Raw 30s
|
|
# samples are heavy, so keep a short raw window then roll up to hourly
|
|
# averages kept much longer; lifecycle events are light, keep a month.
|
|
"docker.retention.metrics_raw_days": 7,
|
|
"docker.retention.metrics_rollup_days": 90,
|
|
"docker.retention.events_days": 30,
|
|
"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": "",
|
|
}
|
|
|
|
# Settings encrypted at rest (transparent encrypt-on-write / decrypt-on-read).
|
|
# Adding a key here makes new writes ciphertext; run migrate_plaintext_secrets
|
|
# to convert any existing plaintext rows.
|
|
SECRET_KEYS: set[str] = {
|
|
"smtp.password",
|
|
"oidc.client_secret",
|
|
"ldap.bind_password",
|
|
"ansible.ssh_private_key",
|
|
"ansible.become_password",
|
|
"ansible.vault_password",
|
|
}
|
|
|
|
|
|
def _decode(value: Any, key: str = "") -> Any:
|
|
"""Decrypt a stored value if it's an encrypted token; else pass through.
|
|
|
|
key is passed through to the decrypt log so a wrong-key failure names the
|
|
exact setting that needs re-entering.
|
|
"""
|
|
from steward.core.crypto import decrypt_secret, is_encrypted
|
|
return decrypt_secret(value, context=key) if is_encrypted(value) else value
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# 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 _decode(json.loads(row.value_json), key)
|
|
|
|
|
|
async def set_setting(session: AsyncSession, key: str, value: Any) -> None:
|
|
"""Upsert a setting (encrypting secret keys at rest). Call in a transaction."""
|
|
to_store = value
|
|
if key in SECRET_KEYS and isinstance(value, str) and value:
|
|
from steward.core.crypto import encrypt_secret
|
|
to_store = encrypt_secret(value)
|
|
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(to_store), updated_at=now))
|
|
else:
|
|
row.value_json = json.dumps(to_store)
|
|
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: _decode(json.loads(row.value_json), row.key) 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", ""),
|
|
"ssh_public_key": settings.get("ansible.ssh_public_key", ""),
|
|
"ssh_user": settings.get("ansible.ssh_user", "steward"),
|
|
"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_thresholds_cfg(settings: dict[str, Any]) -> dict:
|
|
"""Per-metric (warn, crit, direction) policy for degraded-value coloring.
|
|
|
|
Centralizes "what counts as degraded" so templates just name a metric kind
|
|
via the threshold_style() jinja global. `dir` is "high" (higher is worse,
|
|
e.g. CPU) or "low" (lower is worse, e.g. uptime %). Latency reuses the
|
|
existing ping good/warn thresholds.
|
|
"""
|
|
g = settings.get
|
|
return {
|
|
"cpu": {"warn": g("thresholds.cpu_warn", 80), "crit": g("thresholds.cpu_crit", 90), "dir": "high"},
|
|
"mem": {"warn": g("thresholds.mem_warn", 80), "crit": g("thresholds.mem_crit", 90), "dir": "high"},
|
|
"disk": {"warn": g("thresholds.disk_warn", 80), "crit": g("thresholds.disk_crit", 90), "dir": "high"},
|
|
"load": {"warn": g("thresholds.load_warn", 80), "crit": g("thresholds.load_crit", 100), "dir": "high"},
|
|
"temp": {"warn": g("thresholds.temp_warn", 70), "crit": g("thresholds.temp_crit", 85), "dir": "high"},
|
|
"latency": {"warn": g("ping.threshold.good_ms", 50), "crit": g("ping.threshold.warn_ms", 200), "dir": "high"},
|
|
"uptime": {"warn": g("thresholds.uptime_warn", 99.0), "crit": g("thresholds.uptime_crit", 95.0), "dir": "low"},
|
|
}
|
|
|
|
|
|
def threshold_style_for(value: Any, kind: str, thresholds: dict) -> str:
|
|
"""Return an inline-style fragment (amber at warn, red at crit) for a metric.
|
|
|
|
The Python twin of the old _macros.metric_style, but reading configurable
|
|
cutoffs from `thresholds` (see to_thresholds_cfg) and handling both
|
|
directions. Empty string when normal/unknown/None — drop straight into a
|
|
span's style="".
|
|
"""
|
|
if value is None:
|
|
return ""
|
|
th = thresholds.get(kind)
|
|
if not th:
|
|
return ""
|
|
warn, crit, direction = th["warn"], th["crit"], th.get("dir", "high")
|
|
if direction == "low":
|
|
if value < crit:
|
|
return "color:var(--red);font-weight:700;"
|
|
if value < warn:
|
|
return "color:var(--yellow);font-weight:600;"
|
|
else:
|
|
if value >= crit:
|
|
return "color:var(--red);font-weight:700;"
|
|
if value >= warn:
|
|
return "color:var(--yellow);font-weight:600;"
|
|
return ""
|
|
|
|
|
|
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: _decode(json.loads(row.value_json), row.key) 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
|
|
|
|
|
|
def migrate_plaintext_secrets(db_url: str) -> int:
|
|
"""Encrypt any existing plaintext secret rows in place. Idempotent.
|
|
|
|
Returns the number of values converted. Run once at startup after the
|
|
encryptor is initialised (already-encrypted rows are skipped).
|
|
"""
|
|
from steward.core.crypto import encrypt_secret, is_encrypted
|
|
|
|
async def _run() -> int:
|
|
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)
|
|
converted = 0
|
|
try:
|
|
async with factory() as session:
|
|
async with session.begin():
|
|
result = await session.execute(
|
|
select(AppSetting).where(AppSetting.key.in_(SECRET_KEYS))
|
|
)
|
|
for row in result.scalars():
|
|
val = json.loads(row.value_json)
|
|
if isinstance(val, str) and val and not is_encrypted(val):
|
|
row.value_json = json.dumps(encrypt_secret(val))
|
|
converted += 1
|
|
finally:
|
|
await engine.dispose()
|
|
return converted
|
|
|
|
return asyncio.run(_run())
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# 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("/")
|