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:
@@ -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/")
|
||||
|
||||
Reference in New Issue
Block a user