feat(plugins): fold first-party plugins in-tree; bundled + external roots
First-party plugins (host_agent, http, snmp, traefik, unifi, docker) are now tracked under plugins/ and baked into the image, so they version atomically with core — ending the cross-repo import drift the roundtable->steward rename exposed. History for these files is preserved in the archived Roundtable-plugins repo. Plugin discovery becomes multi-root: PLUGIN_DIR (single) -> PLUGIN_DIRS (bundled first, then external) + PLUGIN_INSTALL_DIR. Bundled ships in the image; third-party plugins still mount at runtime into the external root (STEWARD_PLUGIN_DIR, default /data/plugins) and downloads/installs land there. Bundled shadows external on a name collision. - config.py: load_bootstrap returns plugin_dirs + plugin_install_dir - app.py: iterate PLUGIN_DIRS at the migration + load sites - migration_runner.py: discover_all_in() unions every plugin root - plugin_manager.py: resolve_plugin_path() (pure, first-root-wins); load / install / hot-reload span all roots; installs target the external root - settings/routes.py: _discover_plugins scans all roots, dedup bundled-first - Dockerfile: COPY plugins/ ; docker-compose: drop host bind, document external - tests/test_plugin_dirs.py: resolution, multi-root discovery, bootstrap split Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -31,6 +31,19 @@ def get_plugin_failures() -> dict[str, str]:
|
||||
return dict(_FAILED_PLUGINS)
|
||||
|
||||
|
||||
def resolve_plugin_path(plugin_dirs: list[Path], name: str) -> Path | None:
|
||||
"""Return the first plugin root that contains `name`, else None.
|
||||
|
||||
Roots are searched in order, so a bundled (first-party) plugin shadows an
|
||||
external plugin of the same name. Pure function — no app/IO beyond exists().
|
||||
"""
|
||||
for pd in plugin_dirs:
|
||||
candidate = pd / name
|
||||
if candidate.exists():
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def _import_plugin(name: str, plugin_path: Path):
|
||||
"""Load a plugin module by file path, avoiding sys.modules stdlib collisions.
|
||||
|
||||
@@ -80,22 +93,24 @@ def load_plugins(app: "Quart") -> None:
|
||||
"""
|
||||
import steward
|
||||
|
||||
plugin_dir = Path(app.config["PLUGIN_DIR"])
|
||||
plugin_dirs = [Path(d) for d in app.config["PLUGIN_DIRS"]]
|
||||
plugins_cfg: dict = app.config["PLUGINS"]
|
||||
|
||||
# Ensure plugin_dir is on sys.path so plugins are importable by name
|
||||
plugin_dir_str = str(plugin_dir.resolve())
|
||||
if plugin_dir_str not in sys.path:
|
||||
sys.path.insert(0, plugin_dir_str)
|
||||
# Ensure every plugin root is on sys.path so plugins are importable by name
|
||||
for pd in plugin_dirs:
|
||||
pd_str = str(pd.resolve())
|
||||
if pd_str not in sys.path:
|
||||
sys.path.insert(0, pd_str)
|
||||
|
||||
for name, cfg in list(plugins_cfg.items()):
|
||||
if not cfg.get("enabled", False):
|
||||
continue
|
||||
|
||||
plugin_path = plugin_dir / name
|
||||
if not plugin_path.exists():
|
||||
_FAILED_PLUGINS[name] = f"Plugin directory not found: {plugin_path}"
|
||||
logger.error("Plugin %r: directory %s not found, skipping", name, plugin_path)
|
||||
plugin_path = resolve_plugin_path(plugin_dirs, name)
|
||||
if plugin_path is None:
|
||||
roots = ", ".join(str(d) for d in plugin_dirs)
|
||||
_FAILED_PLUGINS[name] = f"Plugin directory not found in: {roots}"
|
||||
logger.error("Plugin %r: not found in any plugin root (%s), skipping", name, roots)
|
||||
continue
|
||||
|
||||
# Load and validate plugin.yaml
|
||||
@@ -224,10 +239,12 @@ async def download_and_install_plugin(
|
||||
"""
|
||||
import httpx
|
||||
|
||||
plugin_dir = Path(app.config["PLUGIN_DIR"]).resolve()
|
||||
# Downloads always land in the external (writable, persistent) install dir,
|
||||
# never the read-only bundled dir.
|
||||
plugin_dir = Path(app.config["PLUGIN_INSTALL_DIR"]).resolve()
|
||||
|
||||
# Ensure plugin_dir is on sys.path (may not be set yet if no plugins were
|
||||
# enabled at startup)
|
||||
# Ensure the install dir is on sys.path (may not be set yet if no plugins
|
||||
# were enabled at startup)
|
||||
plugin_dir_str = str(plugin_dir)
|
||||
if plugin_dir_str not in sys.path:
|
||||
sys.path.insert(0, plugin_dir_str)
|
||||
@@ -304,9 +321,11 @@ async def download_and_install_plugin(
|
||||
if mdir.exists():
|
||||
from steward.core.migration_runner import (
|
||||
run_plugin_migrations,
|
||||
discover_all_plugin_migration_dirs,
|
||||
discover_all_in,
|
||||
)
|
||||
all_dirs = discover_all_plugin_migration_dirs(plugin_dir)
|
||||
# Span every plugin root so Alembic can resolve bundled-plugin
|
||||
# revisions already stamped in alembic_version.
|
||||
all_dirs = discover_all_in([Path(d).resolve() for d in app.config["PLUGIN_DIRS"]])
|
||||
run_plugin_migrations(app.config["DATABASE_URL"], all_dirs)
|
||||
except Exception:
|
||||
logger.exception("Plugin %r: migration failed after install", name)
|
||||
@@ -330,11 +349,11 @@ def hot_reload_plugin(app: "Quart", name: str) -> tuple[bool, str]:
|
||||
if name in _LOADED_PLUGINS:
|
||||
return False, "Plugin already loaded — restart required to apply updates"
|
||||
|
||||
plugin_dir = Path(app.config["PLUGIN_DIR"]).resolve()
|
||||
plugin_path = plugin_dir / name
|
||||
plugin_dirs = [Path(d).resolve() for d in app.config["PLUGIN_DIRS"]]
|
||||
plugin_path = resolve_plugin_path(plugin_dirs, name)
|
||||
|
||||
if not plugin_path.exists():
|
||||
return False, f"Plugin directory {plugin_path} not found"
|
||||
if plugin_path is None:
|
||||
return False, f"Plugin {name!r} not found in any plugin root"
|
||||
|
||||
yaml_path = plugin_path / "plugin.yaml"
|
||||
if not yaml_path.exists():
|
||||
@@ -383,9 +402,9 @@ def hot_reload_plugin(app: "Quart", name: str) -> tuple[bool, str]:
|
||||
try:
|
||||
from steward.core.migration_runner import (
|
||||
run_plugin_migrations,
|
||||
discover_all_plugin_migration_dirs,
|
||||
discover_all_in,
|
||||
)
|
||||
all_dirs = discover_all_plugin_migration_dirs(plugin_dir)
|
||||
all_dirs = discover_all_in(plugin_dirs)
|
||||
run_plugin_migrations(app.config["DATABASE_URL"], all_dirs)
|
||||
except Exception:
|
||||
logger.exception("Plugin %r: migration failed during hot-reload", name)
|
||||
|
||||
Reference in New Issue
Block a user