386 lines
14 KiB
Python
386 lines
14 KiB
Python
|
|
"""Plugin registry — manages discovered, installed, and active plugins."""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import importlib
|
||
|
|
import logging
|
||
|
|
from typing import Any
|
||
|
|
|
||
|
|
from fastapi import FastAPI
|
||
|
|
from sqlalchemy import select
|
||
|
|
from sqlalchemy.ext.asyncio import AsyncSession, AsyncEngine
|
||
|
|
|
||
|
|
from app.core.event_bus import EventBus, get_event_bus
|
||
|
|
from app.core.service_container import ServiceContainer, get_container
|
||
|
|
from app.models.plugin import Plugin as PluginModel
|
||
|
|
from app.plugins.base import BasePlugin
|
||
|
|
from app.plugins.migration_runner import MigrationRunner
|
||
|
|
|
||
|
|
logger = logging.getLogger(__name__)
|
||
|
|
|
||
|
|
|
||
|
|
class PluginRegistry:
|
||
|
|
"""Registry for managing plugin lifecycle.
|
||
|
|
|
||
|
|
Maintains in-memory state for discovered plugins and their runtime instances,
|
||
|
|
backed by database records for persistent status tracking.
|
||
|
|
"""
|
||
|
|
|
||
|
|
def __init__(self) -> None:
|
||
|
|
# name -> BasePlugin instance (discovered/loaded)
|
||
|
|
self._plugins: dict[str, BasePlugin] = {}
|
||
|
|
# name -> PluginModel DB record (installed status)
|
||
|
|
self._db_status: dict[str, PluginModel] = {}
|
||
|
|
# name -> list of mounted APIRouter references (for unregistration)
|
||
|
|
self._mounted_routers: dict[str, list[Any]] = {}
|
||
|
|
self._engine: AsyncEngine | None = None
|
||
|
|
self._event_bus: EventBus = get_event_bus()
|
||
|
|
self._container: ServiceContainer = get_container()
|
||
|
|
self._migration_runner: MigrationRunner | None = None
|
||
|
|
self._app: FastAPI | None = None
|
||
|
|
self._initialized = False
|
||
|
|
|
||
|
|
def initialize(self, engine: AsyncEngine, app: FastAPI | None = None) -> None:
|
||
|
|
"""Initialize the registry with the DB engine and optional FastAPI app."""
|
||
|
|
self._engine = engine
|
||
|
|
self._app = app
|
||
|
|
self._migration_runner = MigrationRunner(engine)
|
||
|
|
self._initialized = True
|
||
|
|
|
||
|
|
@property
|
||
|
|
def migration_runner(self) -> MigrationRunner:
|
||
|
|
if self._migration_runner is None:
|
||
|
|
raise RuntimeError("Registry not initialized — call initialize() first")
|
||
|
|
return self._migration_runner
|
||
|
|
|
||
|
|
@property
|
||
|
|
def engine(self) -> AsyncEngine:
|
||
|
|
if self._engine is None:
|
||
|
|
raise RuntimeError("Registry not initialized — call initialize() first")
|
||
|
|
return self._engine
|
||
|
|
|
||
|
|
# ─── Discovery ───
|
||
|
|
|
||
|
|
def discover_builtins(self) -> list[str]:
|
||
|
|
"""Discover and load built-in plugins from app.plugins.builtins.
|
||
|
|
|
||
|
|
Scans the builtins package for modules that export a BasePlugin subclass.
|
||
|
|
Returns list of discovered plugin names.
|
||
|
|
"""
|
||
|
|
discovered: list[str] = []
|
||
|
|
try:
|
||
|
|
builtins_pkg = importlib.import_module("app.plugins.builtins")
|
||
|
|
except ImportError:
|
||
|
|
return discovered
|
||
|
|
|
||
|
|
pkg_path = getattr(builtins_pkg, "__path__", None)
|
||
|
|
if pkg_path is None:
|
||
|
|
return discovered
|
||
|
|
|
||
|
|
import pkgutil
|
||
|
|
for importer, modname, ispkg in pkgutil.iter_modules(pkg_path):
|
||
|
|
if modname.startswith("_"):
|
||
|
|
continue
|
||
|
|
try:
|
||
|
|
full_name = f"app.plugins.builtins.{modname}"
|
||
|
|
module = importlib.import_module(full_name)
|
||
|
|
# Look for BasePlugin subclass in module
|
||
|
|
for attr_name in dir(module):
|
||
|
|
attr = getattr(module, attr_name)
|
||
|
|
if (
|
||
|
|
isinstance(attr, type)
|
||
|
|
and issubclass(attr, BasePlugin)
|
||
|
|
and attr is not BasePlugin
|
||
|
|
):
|
||
|
|
instance = attr()
|
||
|
|
if instance.name not in self._plugins:
|
||
|
|
self._plugins[instance.name] = instance
|
||
|
|
discovered.append(instance.name)
|
||
|
|
logger.info(f"Discovered plugin: {instance.name} v{instance.version}")
|
||
|
|
except Exception as exc:
|
||
|
|
logger.warning(f"Failed to load builtin plugin module {modname}: {exc}")
|
||
|
|
|
||
|
|
return discovered
|
||
|
|
|
||
|
|
def register_plugin(self, plugin: BasePlugin) -> None:
|
||
|
|
"""Manually register a plugin instance."""
|
||
|
|
self._plugins[plugin.name] = plugin
|
||
|
|
logger.info(f"Registered plugin: {plugin.name} v{plugin.version}")
|
||
|
|
|
||
|
|
def get_plugin(self, name: str) -> BasePlugin | None:
|
||
|
|
"""Get a registered plugin instance by name."""
|
||
|
|
return self._plugins.get(name)
|
||
|
|
|
||
|
|
def list_discovered(self) -> list[str]:
|
||
|
|
"""List all discovered plugin names."""
|
||
|
|
return list(self._plugins.keys())
|
||
|
|
|
||
|
|
# ─── DB Status Sync ───
|
||
|
|
|
||
|
|
async def sync_db_status(self, db: AsyncSession) -> None:
|
||
|
|
"""Load plugin status records from the database."""
|
||
|
|
result = await db.execute(select(PluginModel))
|
||
|
|
self._db_status = {row.name: row for row in result.scalars().all()}
|
||
|
|
|
||
|
|
def get_db_status(self, name: str) -> PluginModel | None:
|
||
|
|
"""Get the DB status record for a plugin."""
|
||
|
|
return self._db_status.get(name)
|
||
|
|
|
||
|
|
# ─── Install / Activate / Deactivate / Uninstall ───
|
||
|
|
|
||
|
|
async def install(self, db: AsyncSession, name: str) -> PluginModel:
|
||
|
|
"""Install a plugin: run migrations and create DB record.
|
||
|
|
|
||
|
|
Idempotent: if already installed, returns existing record.
|
||
|
|
"""
|
||
|
|
plugin = self.get_plugin(name)
|
||
|
|
if plugin is None:
|
||
|
|
raise ValueError(f"Plugin '{name}' not found in registry")
|
||
|
|
|
||
|
|
# Check if already installed in DB
|
||
|
|
existing = await self._get_plugin_record(db, name)
|
||
|
|
if existing is not None:
|
||
|
|
return existing
|
||
|
|
|
||
|
|
# Run migrations
|
||
|
|
if plugin.manifest.migrations:
|
||
|
|
await self.migration_runner.run_all_migrations(
|
||
|
|
db, name, plugin.manifest.migrations
|
||
|
|
)
|
||
|
|
|
||
|
|
# Call on_install hook
|
||
|
|
await plugin.on_install(db, self._container)
|
||
|
|
|
||
|
|
# Create DB record
|
||
|
|
record = PluginModel(
|
||
|
|
name=name,
|
||
|
|
display_name=plugin.manifest.display_name,
|
||
|
|
version=plugin.manifest.version,
|
||
|
|
status="installed",
|
||
|
|
installed=True,
|
||
|
|
active=False,
|
||
|
|
)
|
||
|
|
db.add(record)
|
||
|
|
await db.flush()
|
||
|
|
self._db_status[name] = record
|
||
|
|
|
||
|
|
return record
|
||
|
|
|
||
|
|
async def activate(self, db: AsyncSession, name: str) -> PluginModel:
|
||
|
|
"""Activate a plugin: register routes, event listeners, set status=active.
|
||
|
|
|
||
|
|
Idempotent: if already active, returns existing record without error.
|
||
|
|
"""
|
||
|
|
plugin = self.get_plugin(name)
|
||
|
|
if plugin is None:
|
||
|
|
raise ValueError(f"Plugin '{name}' not found in registry")
|
||
|
|
|
||
|
|
record = await self._get_plugin_record(db, name)
|
||
|
|
if record is None:
|
||
|
|
raise ValueError(f"Plugin '{name}' is not installed — install first")
|
||
|
|
|
||
|
|
# Idempotent: already active
|
||
|
|
if record.active and record.status == "active":
|
||
|
|
return record
|
||
|
|
|
||
|
|
# Call on_activate hook (registers event listeners)
|
||
|
|
await plugin.on_activate(db, self._container, self._event_bus)
|
||
|
|
|
||
|
|
# Register routes on FastAPI app if available
|
||
|
|
if self._app is not None:
|
||
|
|
routers = plugin.get_routes()
|
||
|
|
mounted = []
|
||
|
|
for router in routers:
|
||
|
|
self._app.include_router(router)
|
||
|
|
mounted.append(router)
|
||
|
|
self._mounted_routers[name] = mounted
|
||
|
|
|
||
|
|
# Update DB record
|
||
|
|
record.status = "active"
|
||
|
|
record.active = True
|
||
|
|
await db.flush()
|
||
|
|
self._db_status[name] = record
|
||
|
|
|
||
|
|
return record
|
||
|
|
|
||
|
|
async def deactivate(self, db: AsyncSession, name: str) -> PluginModel:
|
||
|
|
"""Deactivate a plugin: unregister event listeners, set status=inactive.
|
||
|
|
|
||
|
|
Idempotent: if already inactive, returns existing record without error.
|
||
|
|
"""
|
||
|
|
plugin = self.get_plugin(name)
|
||
|
|
if plugin is None:
|
||
|
|
raise ValueError(f"Plugin '{name}' not found in registry")
|
||
|
|
|
||
|
|
record = await self._get_plugin_record(db, name)
|
||
|
|
if record is None:
|
||
|
|
raise ValueError(f"Plugin '{name}' is not installed")
|
||
|
|
|
||
|
|
# Idempotent: already inactive
|
||
|
|
if not record.active and record.status == "inactive":
|
||
|
|
return record
|
||
|
|
|
||
|
|
# Call on_deactivate hook (unregisters event listeners)
|
||
|
|
await plugin.on_deactivate(db, self._container, self._event_bus)
|
||
|
|
|
||
|
|
# Unregister routes from FastAPI app if available
|
||
|
|
if self._app is not None and name in self._mounted_routers:
|
||
|
|
mounted = self._mounted_routers.pop(name, [])
|
||
|
|
# Collect all route paths from mounted routers for removal
|
||
|
|
paths_to_remove: set[str] = set()
|
||
|
|
for router in mounted:
|
||
|
|
for route in router.routes:
|
||
|
|
if hasattr(route, "path"):
|
||
|
|
paths_to_remove.add(route.path)
|
||
|
|
# Remove matching routes from app
|
||
|
|
self._app.router.routes = [
|
||
|
|
r for r in self._app.router.routes
|
||
|
|
if not (hasattr(r, "path") and r.path in paths_to_remove)
|
||
|
|
]
|
||
|
|
|
||
|
|
# Update DB record
|
||
|
|
record.status = "inactive"
|
||
|
|
record.active = False
|
||
|
|
await db.flush()
|
||
|
|
self._db_status[name] = record
|
||
|
|
|
||
|
|
return record
|
||
|
|
|
||
|
|
async def uninstall(
|
||
|
|
self, db: AsyncSession, name: str, remove_data: bool = False
|
||
|
|
) -> PluginModel:
|
||
|
|
"""Uninstall a plugin: deactivate, optionally drop tables, remove DB record.
|
||
|
|
|
||
|
|
Args:
|
||
|
|
db: Async database session.
|
||
|
|
name: Plugin name to uninstall.
|
||
|
|
remove_data: If True, drop all plugin-created tables.
|
||
|
|
|
||
|
|
Returns:
|
||
|
|
The plugin record before deletion (for response).
|
||
|
|
|
||
|
|
Raises:
|
||
|
|
ValueError if plugin not installed.
|
||
|
|
"""
|
||
|
|
plugin = self.get_plugin(name)
|
||
|
|
if plugin is None:
|
||
|
|
raise ValueError(f"Plugin '{name}' not found in registry")
|
||
|
|
|
||
|
|
record = await self._get_plugin_record(db, name)
|
||
|
|
if record is None:
|
||
|
|
raise ValueError(f"Plugin '{name}' is not installed")
|
||
|
|
|
||
|
|
# Deactivate first if active
|
||
|
|
if record.active:
|
||
|
|
await self.deactivate(db, name)
|
||
|
|
# Refetch record after deactivate
|
||
|
|
record = await self._get_plugin_record(db, name)
|
||
|
|
if record is None:
|
||
|
|
raise ValueError(f"Plugin '{name}' disappeared during uninstall")
|
||
|
|
|
||
|
|
# Call on_uninstall hook
|
||
|
|
await plugin.on_uninstall(db, self._container)
|
||
|
|
|
||
|
|
# Optionally drop plugin tables
|
||
|
|
dropped_tables: list[str] = []
|
||
|
|
if remove_data:
|
||
|
|
dropped_tables = await self.migration_runner.drop_plugin_tables(db, name)
|
||
|
|
|
||
|
|
# Remove DB record
|
||
|
|
await db.delete(record)
|
||
|
|
await db.flush()
|
||
|
|
self._db_status.pop(name, None)
|
||
|
|
|
||
|
|
# Return a detached copy for response
|
||
|
|
record_dropped_tables = dropped_tables
|
||
|
|
record.status = "uninstalled"
|
||
|
|
record.dropped_tables = record_dropped_tables
|
||
|
|
return record
|
||
|
|
|
||
|
|
async def list_plugins(self, db: AsyncSession) -> list[dict[str, Any]]:
|
||
|
|
"""List all plugins with their current status.
|
||
|
|
|
||
|
|
Merges discovered (in-memory) plugins with installed (DB) records.
|
||
|
|
"""
|
||
|
|
result = await db.execute(select(PluginModel))
|
||
|
|
db_records = {row.name: row for row in result.scalars().all()}
|
||
|
|
|
||
|
|
plugins_list: list[dict[str, Any]] = []
|
||
|
|
for name, plugin in self._plugins.items():
|
||
|
|
record = db_records.get(name)
|
||
|
|
if record is not None:
|
||
|
|
plugins_list.append({
|
||
|
|
"name": name,
|
||
|
|
"display_name": record.display_name,
|
||
|
|
"version": record.version,
|
||
|
|
"status": record.status,
|
||
|
|
"installed": record.installed,
|
||
|
|
"active": record.active,
|
||
|
|
"description": plugin.manifest.description,
|
||
|
|
"dependencies": plugin.manifest.dependencies,
|
||
|
|
"events": plugin.manifest.events,
|
||
|
|
"migrations": plugin.manifest.migrations,
|
||
|
|
"permissions": plugin.manifest.permissions,
|
||
|
|
})
|
||
|
|
else:
|
||
|
|
plugins_list.append({
|
||
|
|
"name": name,
|
||
|
|
"display_name": plugin.manifest.display_name,
|
||
|
|
"version": plugin.version,
|
||
|
|
"status": "discovered",
|
||
|
|
"installed": False,
|
||
|
|
"active": False,
|
||
|
|
"description": plugin.manifest.description,
|
||
|
|
"dependencies": plugin.manifest.dependencies,
|
||
|
|
"events": plugin.manifest.events,
|
||
|
|
"migrations": plugin.manifest.migrations,
|
||
|
|
"permissions": plugin.manifest.permissions,
|
||
|
|
})
|
||
|
|
|
||
|
|
# Also include DB-only records (plugins that were installed but no longer discovered)
|
||
|
|
for name, record in db_records.items():
|
||
|
|
if name not in self._plugins:
|
||
|
|
plugins_list.append({
|
||
|
|
"name": name,
|
||
|
|
"display_name": record.display_name,
|
||
|
|
"version": record.version,
|
||
|
|
"status": record.status,
|
||
|
|
"installed": record.installed,
|
||
|
|
"active": record.active,
|
||
|
|
"description": "",
|
||
|
|
"dependencies": [],
|
||
|
|
"events": [],
|
||
|
|
"migrations": [],
|
||
|
|
"permissions": [],
|
||
|
|
})
|
||
|
|
|
||
|
|
return plugins_list
|
||
|
|
|
||
|
|
# ─── Internal Helpers ───
|
||
|
|
|
||
|
|
async def _get_plugin_record(self, db: AsyncSession, name: str) -> PluginModel | None:
|
||
|
|
"""Fetch a plugin record from DB by name."""
|
||
|
|
result = await db.execute(
|
||
|
|
select(PluginModel).where(PluginModel.name == name)
|
||
|
|
)
|
||
|
|
return result.scalar_one_or_none()
|
||
|
|
|
||
|
|
|
||
|
|
# Global registry instance
|
||
|
|
_registry: PluginRegistry | None = None
|
||
|
|
|
||
|
|
|
||
|
|
def get_registry() -> PluginRegistry:
|
||
|
|
"""Get the global plugin registry."""
|
||
|
|
global _registry
|
||
|
|
if _registry is None:
|
||
|
|
_registry = PluginRegistry()
|
||
|
|
return _registry
|
||
|
|
|
||
|
|
|
||
|
|
def reset_registry_for_testing() -> PluginRegistry:
|
||
|
|
"""Create a fresh registry for testing."""
|
||
|
|
global _registry
|
||
|
|
_registry = PluginRegistry()
|
||
|
|
return _registry
|