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:
2026-06-01 08:37:24 -04:00
parent 88ab5b917e
commit a7a281cb11
99 changed files with 7931 additions and 52 deletions
+39 -20
View File
@@ -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)