truenas-burnin/app/settings_store.py
Brandon Walter b73b5251ae Initial commit — TrueNAS Burn-In Dashboard v0.5.0
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>
2026-02-24 00:08:29 -05:00

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())