Full-stack burn-in orchestration dashboard (Stages 1–6d complete): FastAPI backend, SQLite/WAL, SSE live dashboard, mock TrueNAS server, SMTP/webhook notifications, batch burn-in, settings UI, audit log, stats page, cancel SMART/burn-in, drag-to-reorder stages. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
104 lines
3.5 KiB
Python
104 lines
3.5 KiB
Python
"""
|
|
Runtime settings store — persists editable settings to /data/settings_overrides.json.
|
|
|
|
Changes take effect immediately (in-memory setattr on the global Settings object)
|
|
and survive restarts (JSON file is loaded in main.py lifespan).
|
|
|
|
Settings that require a container restart (TrueNAS URL, poll interval, allowed IPs, etc.)
|
|
are NOT included here and are display-only on the settings page.
|
|
"""
|
|
|
|
import json
|
|
import logging
|
|
from pathlib import Path
|
|
|
|
from app.config import settings
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
# Field name → coerce function. Only fields listed here are accepted by save().
|
|
_EDITABLE: dict[str, type] = {
|
|
"smtp_host": str,
|
|
"smtp_ssl_mode": str,
|
|
"smtp_timeout": int,
|
|
"smtp_user": str,
|
|
"smtp_password": str,
|
|
"smtp_from": str,
|
|
"smtp_to": str,
|
|
"smtp_daily_report_enabled": bool,
|
|
"smtp_report_hour": int,
|
|
"smtp_alert_on_fail": bool,
|
|
"smtp_alert_on_pass": bool,
|
|
"webhook_url": str,
|
|
"stuck_job_hours": int,
|
|
"max_parallel_burnins": int,
|
|
}
|
|
|
|
_VALID_SSL_MODES = {"starttls", "ssl", "plain"}
|
|
|
|
|
|
def _overrides_path() -> Path:
|
|
return Path(settings.db_path).parent / "settings_overrides.json"
|
|
|
|
|
|
def _coerce(key: str, raw) -> object:
|
|
coerce = _EDITABLE[key]
|
|
if coerce is bool:
|
|
if isinstance(raw, bool):
|
|
return raw
|
|
return str(raw).lower() in ("1", "true", "yes", "on")
|
|
return coerce(raw)
|
|
|
|
|
|
def _apply(data: dict) -> None:
|
|
"""Apply a dict of updates to the live settings object."""
|
|
for key, raw in data.items():
|
|
if key not in _EDITABLE:
|
|
continue
|
|
try:
|
|
val = _coerce(key, raw)
|
|
if key == "smtp_ssl_mode" and val not in _VALID_SSL_MODES:
|
|
log.warning("settings_store: invalid smtp_ssl_mode %r — ignoring", val)
|
|
continue
|
|
if key == "smtp_report_hour" and not (0 <= int(val) <= 23):
|
|
log.warning("settings_store: smtp_report_hour out of range — ignoring")
|
|
continue
|
|
setattr(settings, key, val)
|
|
except (ValueError, TypeError) as exc:
|
|
log.warning("settings_store: invalid value for %s: %s", key, exc)
|
|
|
|
|
|
def init() -> None:
|
|
"""Load persisted overrides at startup. Call once from lifespan."""
|
|
path = _overrides_path()
|
|
if not path.exists():
|
|
return
|
|
try:
|
|
data = json.loads(path.read_text())
|
|
_apply(data)
|
|
log.info("settings_store: loaded %d override(s) from %s", len(data), path)
|
|
except Exception as exc:
|
|
log.warning("settings_store: could not load overrides from %s: %s", path, exc)
|
|
|
|
|
|
def save(updates: dict) -> list[str]:
|
|
"""
|
|
Validate, apply, and persist a dict of settings updates.
|
|
Returns list of keys that were actually saved.
|
|
Raises ValueError for unknown or invalid fields.
|
|
"""
|
|
accepted: dict = {}
|
|
for key, raw in updates.items():
|
|
if key not in _EDITABLE:
|
|
raise ValueError(f"Unknown or non-editable setting: {key!r}")
|
|
accepted[key] = raw
|
|
|
|
_apply(accepted)
|
|
|
|
# Persist ALL currently-applied editable values (not just the delta)
|
|
snapshot = {k: getattr(settings, k) for k in _EDITABLE}
|
|
path = _overrides_path()
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text(json.dumps(snapshot, indent=2))
|
|
log.info("settings_store: saved %d key(s) — snapshot written to %s", len(accepted), path)
|
|
return list(accepted.keys())
|