feat: managed NUT setup, plugin config improvements, migration fix

- Add managed NUT mode: configure NUT daemon from Settings → Plugins
  (UPS plugin), writes /data/nut_managed.json read by entrypoint on
  restart — no env vars or USB passthrough required
- entrypoint.sh: start NUT from /data/nut_managed.json or NUT_MANAGED=1
  env var; drop to app user via gosu after NUT daemons start
- nut_setup.py: support --from-file <json> in addition to env vars
- Dockerfile: add nut, nut-client, gosu packages and entrypoint
- docker-compose.yml: document optional NUT_MANAGED env var block
- Fix plugin hot-reload migration failure: pass all plugin migration
  dirs to Alembic so previously-stamped revisions from other plugins
  remain resolvable (fixes UPS and any plugin with depends_on)
- Fix plugin list-type config (e.g. SNMP devices) rendering as broken
  text input — now shows a read-only note to edit plugin.yaml instead
- Fix _sync_nut_managed_config: only write JSON when nut_ups_host is
  non-empty, preventing NUT startup loop on container restart

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-23 08:15:48 -04:00
parent ced1eebe1d
commit 93d5ed8fb0
7 changed files with 444 additions and 21 deletions
+150 -2
View File
@@ -3,13 +3,15 @@ from __future__ import annotations
import json
import logging
from pathlib import Path
from quart import Blueprint, current_app, render_template, request, redirect, url_for
from quart import Blueprint, current_app, render_template, request, redirect, url_for, session
from fabledscryer.auth.middleware import require_role
from fabledscryer.core.audit import log_audit
from fabledscryer.models.users import UserRole
from fabledscryer.core.settings import (
DEFAULTS, get_all_settings, set_setting,
to_smtp_cfg, to_webhook_cfg, to_ansible_cfg, to_plugins_cfg,
to_oidc_cfg, to_ldap_cfg,
)
settings_bp = Blueprint("settings", __name__, url_prefix="/settings")
@@ -58,6 +60,8 @@ async def _reload_app_config() -> None:
WEBHOOK=to_webhook_cfg(fresh),
ANSIBLE=to_ansible_cfg(fresh),
PLUGINS=to_plugins_cfg(fresh),
OIDC=to_oidc_cfg(fresh),
LDAP=to_ldap_cfg(fresh),
)
@@ -92,6 +96,8 @@ async def save_general():
await set_setting(db, "monitors.poll_interval_seconds",
int(form.get("monitors.poll_interval_seconds", 60)))
await _reload_app_config()
await log_audit(current_app, session.get("user_id"), session.get("username", ""),
"settings.saved", detail={"section": "general"})
return redirect(url_for("settings.general"))
@@ -123,6 +129,8 @@ async def save_notifications():
await set_setting(db, "webhook.url", form.get("webhook.url", ""))
await set_setting(db, "webhook.template", form.get("webhook.template", ""))
await _reload_app_config()
await log_audit(current_app, session.get("user_id"), session.get("username", ""),
"settings.saved", detail={"section": "notifications"})
return redirect(url_for("settings.notifications"))
@@ -154,6 +162,95 @@ async def save_ansible():
return redirect(url_for("settings.ansible"))
# ── Auth (OIDC / LDAP) ────────────────────────────────────────────────────────
@settings_bp.get("/auth/")
@require_role(UserRole.admin)
async def auth_settings():
async with current_app.db_sessionmaker() as db:
settings = await get_all_settings(db)
return await render_template("settings/auth.html", settings=settings)
@settings_bp.post("/auth/")
@require_role(UserRole.admin)
async def save_auth_settings():
form = await request.form
async with current_app.db_sessionmaker() as db:
async with db.begin():
# OIDC
await set_setting(db, "oidc.enabled", "oidc.enabled" in form)
await set_setting(db, "oidc.discovery_url", form.get("oidc.discovery_url", "").strip())
await set_setting(db, "oidc.client_id", form.get("oidc.client_id", "").strip())
if form.get("oidc.client_secret"):
await set_setting(db, "oidc.client_secret", form.get("oidc.client_secret").strip())
await set_setting(db, "oidc.scopes", form.get("oidc.scopes", "openid profile email").strip())
await set_setting(db, "oidc.username_claim", form.get("oidc.username_claim", "preferred_username").strip())
await set_setting(db, "oidc.email_claim", form.get("oidc.email_claim", "email").strip())
await set_setting(db, "oidc.groups_claim", form.get("oidc.groups_claim", "groups").strip())
await set_setting(db, "oidc.admin_group", form.get("oidc.admin_group", "").strip())
await set_setting(db, "oidc.operator_group", form.get("oidc.operator_group", "").strip())
# LDAP
await set_setting(db, "ldap.enabled", "ldap.enabled" in form)
await set_setting(db, "ldap.host", form.get("ldap.host", "").strip())
await set_setting(db, "ldap.port", int(form.get("ldap.port") or 389))
await set_setting(db, "ldap.tls", "ldap.tls" in form)
await set_setting(db, "ldap.bind_dn", form.get("ldap.bind_dn", "").strip())
if form.get("ldap.bind_password"):
await set_setting(db, "ldap.bind_password", form.get("ldap.bind_password").strip())
await set_setting(db, "ldap.base_dn", form.get("ldap.base_dn", "").strip())
await set_setting(db, "ldap.user_filter", form.get("ldap.user_filter", "(uid={username})").strip())
await set_setting(db, "ldap.admin_group_dn", form.get("ldap.admin_group_dn", "").strip())
await set_setting(db, "ldap.operator_group_dn", form.get("ldap.operator_group_dn", "").strip())
await set_setting(db, "ldap.attr_username", form.get("ldap.attr_username", "uid").strip())
await set_setting(db, "ldap.attr_email", form.get("ldap.attr_email", "mail").strip())
await _reload_app_config()
await log_audit(current_app, session.get("user_id"), session.get("username", ""),
"settings.saved", detail={"section": "auth"})
# Clear OIDC discovery cache so new settings take effect
from fabledscryer.auth.oidc import _discovery_cache
_discovery_cache.clear()
return redirect(url_for("settings.auth_settings"))
# ── Reports ───────────────────────────────────────────────────────────────────
@settings_bp.get("/reports/")
@require_role(UserRole.admin)
async def reports():
async with current_app.db_sessionmaker() as db:
settings = await get_all_settings(db)
return await render_template("settings/reports.html", settings=settings, flash=None)
@settings_bp.post("/reports/")
@require_role(UserRole.admin)
async def save_reports():
form = await request.form
async with current_app.db_sessionmaker() as db:
async with db.begin():
await set_setting(db, "reports.enabled", "reports.enabled" in form)
await set_setting(db, "reports.schedule_day",
int(form.get("reports.schedule_day", 6)))
await set_setting(db, "reports.schedule_hour",
int(form.get("reports.schedule_hour", 8)))
return redirect(url_for("settings.reports"))
@settings_bp.post("/reports/send-now/")
@require_role(UserRole.admin)
async def send_report_now():
from fabledscryer.core.reports import send_report
async with current_app.db_sessionmaker() as db:
settings = await get_all_settings(db)
ok, message = await send_report(current_app._get_current_object())
return await render_template(
"settings/reports.html",
settings=settings,
flash={"ok": ok, "message": message},
)
# ── Plugins ───────────────────────────────────────────────────────────────────
@settings_bp.get("/plugins/")
@@ -175,6 +272,11 @@ async def plugins():
async def save_plugins():
form = await request.form
discovered = _discover_plugins()
# Snapshot old enabled states for audit diff
async with current_app.db_sessionmaker() as db:
old_settings = await get_all_settings(db)
old_plugins_cfg = to_plugins_cfg(old_settings)
async with current_app.db_sessionmaker() as db:
async with db.begin():
# Save plugin index URL if provided
@@ -188,7 +290,9 @@ async def save_plugins():
for cfg_key in plugin.get("config", {}).keys():
field_name = f"plugin.{name}.{cfg_key}"
default_val = plugin["config"][cfg_key]
if isinstance(default_val, dict):
if isinstance(default_val, list):
pass # list configs (e.g. SNMP devices) must be set in plugin.yaml
elif isinstance(default_val, dict):
sub_dict = {}
for sub_key, sub_default in default_val.items():
sub_field = f"plugin.{name}.{cfg_key}.{sub_key}"
@@ -221,9 +325,53 @@ async def save_plugins():
await _reload_app_config()
from fabledscryer.core.plugin_index import clear_catalog_cache
clear_catalog_cache()
# Audit plugin enable/disable changes + auto-hot-reload newly enabled plugins
from fabledscryer.core.plugin_manager import hot_reload_plugin
for plugin in discovered:
name = plugin["_dir"]
old_enabled = old_plugins_cfg.get(name, {}).get("enabled", False)
new_enabled = f"plugin.{name}.enabled" in form
if old_enabled != new_enabled:
action = "plugin.enabled" if new_enabled else "plugin.disabled"
await log_audit(current_app, session.get("user_id"), session.get("username", ""),
action, entity_type="plugin", entity_id=name)
if new_enabled and not old_enabled:
# Attempt to activate without requiring a manual reload click
hot_reload_plugin(current_app._get_current_object(), name)
# Write /data/nut_managed.json so entrypoint.sh can start NUT on next restart
_sync_nut_managed_config(form)
return redirect(url_for("settings.plugins"))
def _sync_nut_managed_config(form) -> None:
"""Write or remove /data/nut_managed.json based on UPS plugin managed NUT settings."""
import json
nut_managed_path = Path("/data/nut_managed.json")
nut_managed = "plugin.ups.nut_managed" in form
ups_host = form.get("plugin.ups.nut_ups_host", "").strip()
# Only write the file when managed mode is on AND a host is configured.
# Without a host, NUT can't start and the container would loop on errors.
if nut_managed and ups_host:
cfg = {
"ups_host": ups_host,
"driver": form.get("plugin.ups.nut_driver", "snmp-ups").strip(),
"ups_name": form.get("plugin.ups.ups_name", "ups").strip(),
"snmp_community": form.get("plugin.ups.nut_snmp_community", "public").strip(),
"snmp_version": form.get("plugin.ups.nut_snmp_version", "v1").strip(),
"username": form.get("plugin.ups.nut_username", "").strip(),
"password": form.get("plugin.ups.nut_password", "").strip(),
}
try:
nut_managed_path.write_text(json.dumps(cfg, indent=2))
except OSError as exc:
logger.warning("Could not write %s: %s", nut_managed_path, exc)
else:
try:
nut_managed_path.unlink(missing_ok=True)
except OSError as exc:
logger.warning("Could not remove %s: %s", nut_managed_path, exc)
# ── Plugin catalog (HTMX partial) ─────────────────────────────────────────────
@settings_bp.get("/plugins/catalog/")