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:
+14
-4
@@ -1,7 +1,13 @@
|
||||
FROM python:3.13-slim
|
||||
|
||||
RUN DEBIAN_FRONTEND=noninteractive apt-get update \
|
||||
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends iputils-ping \
|
||||
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
||||
iputils-ping \
|
||||
# NUT (Network UPS Tools) — only used when NUT_MANAGED=1
|
||||
nut \
|
||||
nut-client \
|
||||
# gosu — minimal privilege-drop tool used by entrypoint.sh
|
||||
gosu \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Upgrade pip to suppress the version notice
|
||||
@@ -14,14 +20,18 @@ COPY fabledscryer/ fabledscryer/
|
||||
RUN pip install --no-cache-dir .
|
||||
|
||||
COPY alembic.ini .
|
||||
COPY entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
# Runtime directories — owned by app user
|
||||
# Runtime directories — owned by app user.
|
||||
# The container runs as root so the entrypoint can start NUT daemons (USB
|
||||
# access requires root); it drops to 'app' (uid 1000) via gosu before
|
||||
# launching fabledscryer.
|
||||
RUN useradd -m -u 1000 app \
|
||||
&& mkdir -p /data/playbook_cache /data/plugins \
|
||||
&& chown -R app:app /data /app
|
||||
|
||||
USER app
|
||||
|
||||
EXPOSE 5000
|
||||
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
CMD ["fabledscryer", "--host", "0.0.0.0", "--port", "5000"]
|
||||
|
||||
@@ -10,6 +10,19 @@ services:
|
||||
- /mnt/Data/traefik/log:/var/log/traefik:ro
|
||||
environment:
|
||||
- FABLEDSCRYER_DATABASE_URL=postgresql+asyncpg://fabledscryer:fabledscryer@db/fabledscryer
|
||||
# ── Managed NUT (optional) ───────────────────────────────────────────────
|
||||
# Set NUT_MANAGED=1 to run NUT inside this container and connect to your
|
||||
# UPS over the network (SNMP or XML/HTTP). No USB or special privileges
|
||||
# required — just network access to the UPS management card.
|
||||
#
|
||||
# - NUT_MANAGED=1
|
||||
# - NUT_UPS_HOST=192.168.1.x # IP/hostname of UPS management card (required)
|
||||
# - NUT_DRIVER=snmp-ups # snmp-ups (default) or netxml-ups for Eaton
|
||||
# - NUT_UPS_NAME=ups # must match 'ups_name' in UPS plugin settings
|
||||
# - NUT_SNMP_COMMUNITY=public # SNMP community string (snmp-ups only)
|
||||
# - NUT_SNMP_VERSION=v1 # v1 or v2c (snmp-ups only)
|
||||
# - NUT_USERNAME= # upsd auth username (blank = no auth)
|
||||
# - NUT_PASSWORD= # upsd auth password
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
#!/bin/sh
|
||||
# FabledScryer container entrypoint.
|
||||
#
|
||||
# When NUT_MANAGED=1:
|
||||
# - Generates /etc/nut/*.conf from environment variables
|
||||
# - Starts NUT driver (upsdrvctl) and network daemon (upsd)
|
||||
# - Connects to the UPS over the network — no USB passthrough required
|
||||
# - Then drops to 'app' user via gosu to run fabledscryer
|
||||
#
|
||||
# When NUT_MANAGED=0 (default):
|
||||
# - Drops directly to 'app' user and runs the command
|
||||
#
|
||||
# Required env vars when NUT_MANAGED=1:
|
||||
# NUT_UPS_HOST IP or hostname of the UPS management card (required)
|
||||
# NUT_DRIVER NUT driver — snmp-ups (default) or netxml-ups for Eaton
|
||||
# NUT_UPS_NAME UPS name in NUT (default: ups) — must match UPS plugin setting
|
||||
# NUT_SNMP_COMMUNITY SNMP community string (default: public) [snmp-ups only]
|
||||
# NUT_SNMP_VERSION SNMP version: v1, v2c (default: v1) [snmp-ups only]
|
||||
# NUT_USERNAME Optional upsd auth username
|
||||
# NUT_PASSWORD Optional upsd auth password
|
||||
|
||||
set -e
|
||||
|
||||
NUT_MANAGED_JSON="/data/nut_managed.json"
|
||||
|
||||
if [ "${NUT_MANAGED:-0}" = "1" ] || [ -f "$NUT_MANAGED_JSON" ]; then
|
||||
if [ -f "$NUT_MANAGED_JSON" ]; then
|
||||
echo "[entrypoint] Found $NUT_MANAGED_JSON — configuring NUT from settings..."
|
||||
python3 /app/fabledscryer/nut_setup.py --from-file "$NUT_MANAGED_JSON"
|
||||
else
|
||||
echo "[entrypoint] NUT_MANAGED=1 — configuring NUT from environment..."
|
||||
python3 /app/fabledscryer/nut_setup.py
|
||||
fi
|
||||
|
||||
echo "[entrypoint] Starting NUT driver..."
|
||||
/sbin/upsdrvctl start || echo "[entrypoint] Warning: upsdrvctl returned non-zero"
|
||||
|
||||
echo "[entrypoint] Starting NUT daemon..."
|
||||
/sbin/upsd || echo "[entrypoint] Warning: upsd returned non-zero"
|
||||
|
||||
sleep 1
|
||||
echo "[entrypoint] NUT ready on 127.0.0.1:3493."
|
||||
fi
|
||||
|
||||
exec gosu app "$@"
|
||||
@@ -23,6 +23,40 @@ logger = logging.getLogger(__name__)
|
||||
_LOADED_PLUGINS: set[str] = set()
|
||||
|
||||
|
||||
def _import_plugin(name: str, plugin_path: Path):
|
||||
"""Load a plugin module by file path, avoiding sys.modules stdlib collisions.
|
||||
|
||||
Using importlib.import_module(name) fails for plugins whose names shadow
|
||||
Python stdlib modules (e.g. the 'http' plugin vs stdlib's 'http' package).
|
||||
This helper loads from the filesystem path directly and registers the module
|
||||
under a namespaced key so relative imports within the plugin still work.
|
||||
"""
|
||||
import importlib.util
|
||||
|
||||
module_key = f"_fabledscryer_plugin_{name}"
|
||||
if module_key in sys.modules:
|
||||
return sys.modules[module_key]
|
||||
|
||||
init_file = plugin_path / "__init__.py"
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
module_key,
|
||||
str(init_file),
|
||||
submodule_search_locations=[str(plugin_path)],
|
||||
)
|
||||
if spec is None or spec.loader is None:
|
||||
raise ImportError(f"Could not create import spec for plugin {name!r}")
|
||||
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
module.__package__ = module_key
|
||||
sys.modules[module_key] = module
|
||||
try:
|
||||
spec.loader.exec_module(module)
|
||||
except Exception:
|
||||
sys.modules.pop(module_key, None)
|
||||
raise
|
||||
return module
|
||||
|
||||
|
||||
def load_plugins(app: "Quart") -> None:
|
||||
"""Import all enabled plugins and register their blueprints and scheduled tasks.
|
||||
|
||||
@@ -88,15 +122,27 @@ def load_plugins(app: "Quart") -> None:
|
||||
logger.exception("Plugin %r: invalid min_app_version %r, skipping", name, min_ver)
|
||||
continue
|
||||
|
||||
# Merge plugin.yaml config defaults with user overrides (non-"enabled" keys)
|
||||
# Merge plugin.yaml config defaults with user overrides (non-"enabled" keys).
|
||||
# If a stored value's type doesn't match the default (e.g. a list default
|
||||
# was corrupted to a string in the DB), fall back to the yaml default.
|
||||
defaults = meta.get("config", {})
|
||||
user_overrides = {k: v for k, v in cfg.items() if k != "enabled"}
|
||||
plugins_cfg[name] = {"enabled": True, **defaults, **user_overrides}
|
||||
merged = {"enabled": True, **defaults}
|
||||
for k, v in user_overrides.items():
|
||||
default_v = defaults.get(k)
|
||||
if default_v is not None and type(v) is not type(default_v):
|
||||
logger.warning(
|
||||
"Plugin %r: config key %r has wrong type (%s), using yaml default",
|
||||
name, k, type(v).__name__,
|
||||
)
|
||||
else:
|
||||
merged[k] = v
|
||||
plugins_cfg[name] = merged
|
||||
|
||||
# Import the plugin package
|
||||
# Import the plugin package (file-path load avoids stdlib name collisions)
|
||||
try:
|
||||
module = importlib.import_module(name)
|
||||
except ImportError:
|
||||
module = _import_plugin(name, plugin_path)
|
||||
except Exception:
|
||||
logger.exception("Plugin %r: import failed, skipping", name)
|
||||
continue
|
||||
|
||||
@@ -223,12 +269,18 @@ async def download_and_install_plugin(
|
||||
logger.error("Plugin %r: %s", name, msg)
|
||||
return False, msg
|
||||
|
||||
# Run migrations for the newly installed plugin
|
||||
# Run migrations for the newly installed plugin.
|
||||
# Include all plugin migration dirs so Alembic can resolve any previously-stamped
|
||||
# revisions from other plugins already in alembic_version.
|
||||
try:
|
||||
mdir = plugin_dir / name / "migrations"
|
||||
if mdir.exists():
|
||||
from fabledscryer.core.migration_runner import run_plugin_migrations
|
||||
run_plugin_migrations(app.config["DATABASE_URL"], [mdir])
|
||||
from fabledscryer.core.migration_runner import (
|
||||
run_plugin_migrations,
|
||||
discover_all_plugin_migration_dirs,
|
||||
)
|
||||
all_dirs = discover_all_plugin_migration_dirs(plugin_dir)
|
||||
run_plugin_migrations(app.config["DATABASE_URL"], all_dirs)
|
||||
except Exception:
|
||||
logger.exception("Plugin %r: migration failed after install", name)
|
||||
return False, "Plugin extracted but migrations failed — check logs"
|
||||
@@ -281,17 +333,41 @@ def hot_reload_plugin(app: "Quart", name: str) -> tuple[bool, str]:
|
||||
except Exception:
|
||||
return False, f"Invalid min_app_version: {min_ver!r}"
|
||||
|
||||
# Merge config defaults with any stored user config
|
||||
# Merge config defaults with any stored user config.
|
||||
# Discard stored values whose type doesn't match the yaml default (corruption guard).
|
||||
defaults = meta.get("config", {})
|
||||
stored_cfg = app.config.get("PLUGINS", {}).get(name, {})
|
||||
user_overrides = {k: v for k, v in stored_cfg.items() if k != "enabled"}
|
||||
merged = {"enabled": True, **defaults, **user_overrides}
|
||||
merged = {"enabled": True, **defaults}
|
||||
for k, v in stored_cfg.items():
|
||||
if k == "enabled":
|
||||
continue
|
||||
default_v = defaults.get(k)
|
||||
if default_v is not None and type(v) is not type(default_v):
|
||||
logger.warning("Plugin %r: config key %r has wrong type, using yaml default", name, k)
|
||||
else:
|
||||
merged[k] = v
|
||||
app.config["PLUGINS"][name] = merged
|
||||
|
||||
# Import
|
||||
# Run migrations if the plugin has them (safe to re-run — alembic is idempotent).
|
||||
# Pass ALL plugin migration dirs so Alembic can resolve any previously-stamped
|
||||
# revisions from other plugins that are already in alembic_version.
|
||||
mdir = plugin_path / "migrations"
|
||||
if mdir.exists():
|
||||
try:
|
||||
from fabledscryer.core.migration_runner import (
|
||||
run_plugin_migrations,
|
||||
discover_all_plugin_migration_dirs,
|
||||
)
|
||||
all_dirs = discover_all_plugin_migration_dirs(plugin_dir)
|
||||
run_plugin_migrations(app.config["DATABASE_URL"], all_dirs)
|
||||
except Exception:
|
||||
logger.exception("Plugin %r: migration failed during hot-reload", name)
|
||||
return False, "Migration failed — check logs"
|
||||
|
||||
# Import (file-path load avoids stdlib name collisions)
|
||||
try:
|
||||
module = importlib.import_module(name)
|
||||
except ImportError as exc:
|
||||
module = _import_plugin(name, plugin_path)
|
||||
except Exception as exc:
|
||||
return False, f"Import failed: {exc}"
|
||||
|
||||
if not hasattr(module, "setup") or not hasattr(module, "get_scheduled_tasks"):
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
"""Generate NUT (Network UPS Tools) configuration files.
|
||||
|
||||
Called by entrypoint.sh before the NUT daemons start. Reads config either from
|
||||
environment variables (legacy NUT_MANAGED=1 path) or from a JSON file written
|
||||
by the FabledScryer settings page (/data/nut_managed.json).
|
||||
|
||||
Environment variables (used when no --from-file is given):
|
||||
NUT_DRIVER NUT driver (default: snmp-ups)
|
||||
snmp-ups — any UPS with SNMP management card (APC, Eaton,
|
||||
CyberPower, Tripp Lite, Liebert, ...)
|
||||
netxml-ups — Eaton Network Management Card (XML/HTTP)
|
||||
NUT_UPS_NAME Name exposed by upsd (default: ups)
|
||||
Must match 'ups_name' in the UPS plugin settings.
|
||||
NUT_UPS_HOST IP address or hostname of the UPS management card (required)
|
||||
NUT_SNMP_COMMUNITY SNMP community string (default: public) [snmp-ups only]
|
||||
NUT_SNMP_VERSION SNMP version: v1, v2c (default: v1) [snmp-ups only]
|
||||
NUT_USERNAME Optional upsd username for authenticated access
|
||||
NUT_PASSWORD Optional upsd password
|
||||
|
||||
JSON file format (--from-file path):
|
||||
{
|
||||
"ups_host": "192.168.1.x", // required
|
||||
"driver": "snmp-ups",
|
||||
"ups_name": "ups",
|
||||
"snmp_community": "public",
|
||||
"snmp_version": "v1",
|
||||
"username": "",
|
||||
"password": ""
|
||||
}
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
NUT_CONF_DIR = Path("/etc/nut")
|
||||
NUT_MANAGED_JSON = Path("/data/nut_managed.json")
|
||||
|
||||
|
||||
def _env(key: str, default: str = "") -> str:
|
||||
return os.environ.get(key, default).strip()
|
||||
|
||||
|
||||
def write_configs(cfg: dict | None = None) -> None:
|
||||
"""Write NUT config files. If cfg is given, use it; otherwise fall back to env vars."""
|
||||
NUT_CONF_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if cfg is not None:
|
||||
driver = cfg.get("driver", "snmp-ups").strip()
|
||||
ups_name = cfg.get("ups_name", "ups").strip()
|
||||
ups_host = cfg.get("ups_host", "").strip()
|
||||
community = cfg.get("snmp_community", "public").strip()
|
||||
snmp_ver = cfg.get("snmp_version", "v1").strip()
|
||||
username = cfg.get("username", "").strip()
|
||||
password = cfg.get("password", "").strip()
|
||||
else:
|
||||
driver = _env("NUT_DRIVER", "snmp-ups")
|
||||
ups_name = _env("NUT_UPS_NAME", "ups")
|
||||
ups_host = _env("NUT_UPS_HOST")
|
||||
community = _env("NUT_SNMP_COMMUNITY", "public")
|
||||
snmp_ver = _env("NUT_SNMP_VERSION", "v1")
|
||||
username = _env("NUT_USERNAME")
|
||||
password = _env("NUT_PASSWORD")
|
||||
|
||||
if not ups_host:
|
||||
print("[nut_setup] ERROR: NUT_UPS_HOST is required", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# ── nut.conf ───────────────────────────────────────────────────────────────
|
||||
(NUT_CONF_DIR / "nut.conf").write_text("MODE=standalone\n")
|
||||
|
||||
# ── ups.conf ───────────────────────────────────────────────────────────────
|
||||
ups_block = [f"[{ups_name}]"]
|
||||
ups_block.append(f" driver = {driver}")
|
||||
|
||||
if driver == "netxml-ups":
|
||||
# Eaton: port is a URL
|
||||
ups_block.append(f" port = http://{ups_host}")
|
||||
else:
|
||||
# snmp-ups and others: port is the IP/hostname
|
||||
ups_block.append(f" port = {ups_host}")
|
||||
if driver == "snmp-ups":
|
||||
ups_block.append(f" community = {community}")
|
||||
ups_block.append(f" snmp_version = {snmp_ver}")
|
||||
|
||||
ups_block.append(f' desc = "Network UPS at {ups_host}"')
|
||||
(NUT_CONF_DIR / "ups.conf").write_text("\n".join(ups_block) + "\n")
|
||||
|
||||
# ── upsd.conf ──────────────────────────────────────────────────────────────
|
||||
(NUT_CONF_DIR / "upsd.conf").write_text("LISTEN 127.0.0.1 3493\n")
|
||||
|
||||
# ── upsd.users ─────────────────────────────────────────────────────────────
|
||||
if username and password:
|
||||
users_content = (
|
||||
f"[{username}]\n"
|
||||
f" password = {password}\n"
|
||||
f" upsmon primary\n"
|
||||
)
|
||||
else:
|
||||
users_content = "# No users configured — unauthenticated access\n"
|
||||
users_path = NUT_CONF_DIR / "upsd.users"
|
||||
users_path.write_text(users_content)
|
||||
users_path.chmod(0o640)
|
||||
|
||||
print(
|
||||
f"[nut_setup] Config written: driver={driver} host={ups_host} ups_name={ups_name}"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
cfg = None
|
||||
if "--from-file" in sys.argv:
|
||||
idx = sys.argv.index("--from-file")
|
||||
json_path = Path(sys.argv[idx + 1])
|
||||
with json_path.open() as f:
|
||||
cfg = json.load(f)
|
||||
write_configs(cfg)
|
||||
except Exception as exc:
|
||||
print(f"[nut_setup] ERROR: {exc}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
@@ -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/")
|
||||
|
||||
@@ -68,7 +68,17 @@
|
||||
|
||||
{% for cfg_key, cfg_default in plugin.config.items() %}
|
||||
|
||||
{% if cfg_default is mapping %}
|
||||
{% if cfg_default is iterable and cfg_default is not string and cfg_default is not mapping %}
|
||||
{# List — cannot be edited via a flat form; must be set in plugin.yaml #}
|
||||
<div style="display:flex;align-items:flex-start;gap:0.5rem;padding:0.5rem 0.75rem;
|
||||
background:var(--bg);border-radius:5px;border:1px solid var(--border);
|
||||
font-size:0.82rem;color:var(--text-muted);">
|
||||
<span style="flex-shrink:0;">📋</span>
|
||||
<span><strong style="color:var(--text);">{{ cfg_key }}</strong> — list config, edit in
|
||||
<code>plugin.yaml</code> ({{ cfg_default | length }} item{{ 's' if cfg_default | length != 1 }}).</span>
|
||||
</div>
|
||||
|
||||
{% elif cfg_default is mapping %}
|
||||
{# Nested dict group #}
|
||||
<div style="padding:0.6rem 0.75rem;background:var(--bg);border-radius:5px;border:1px solid var(--border);">
|
||||
<div style="font-size:0.75rem;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.05em;margin-bottom:0.6rem;">{{ cfg_key }}</div>
|
||||
|
||||
Reference in New Issue
Block a user