88857be24e
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>
255 lines
11 KiB
Python
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("/")
|