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