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
+90 -14
View File
@@ -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"):