T03: plugin system framework + lifecycle + migrations + event bus + DI
- Plugin registry with discover/install/activate/deactivate/uninstall lifecycle - PluginManifest Pydantic v2 schema (name, version, dependencies, routes, events, migrations) - BasePlugin abstract class with lifecycle hooks (on_install/activate/deactivate/uninstall) - Migration runner with tenant_id validator (rejects tables without tenant_id) - Event bus integration: register/unregister listeners on activate/deactivate - Service container DI: plugins receive db, cache, event_bus, storage, notifications - Idempotent operations (activate active=200, deactivate inactive=200) - UI registry for frontend component registration - 47 new tests (14 ACs + 33 unit tests), 103 total tests pass - Migration 0003: plugins + plugin_migrations tables - Coverage: 85.92% for plugin modules
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
"""Plugin system framework for LeoCRM."""
|
||||
|
||||
from app.plugins.manifest import PluginManifest
|
||||
from app.plugins.base import BasePlugin
|
||||
from app.plugins.registry import get_registry
|
||||
from app.plugins.migration_runner import MigrationRunner
|
||||
|
||||
__all__ = ["PluginManifest", "BasePlugin", "get_registry", "MigrationRunner"]
|
||||
@@ -0,0 +1,125 @@
|
||||
"""Base plugin abstract class with lifecycle hooks."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.plugins.manifest import PluginManifest
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.core.event_bus import EventBus
|
||||
from app.core.service_container import ServiceContainer
|
||||
|
||||
|
||||
class BasePlugin(ABC):
|
||||
"""Abstract base class for all LeoCRM plugins.
|
||||
|
||||
Subclasses must define a ``manifest`` class attribute (PluginManifest)
|
||||
and implement the lifecycle hooks.
|
||||
"""
|
||||
|
||||
manifest: PluginManifest
|
||||
|
||||
def __init__(self) -> None:
|
||||
if not hasattr(self, "manifest") or self.manifest is None:
|
||||
raise ValueError(f"{self.__class__.__name__} must define a 'manifest' attribute")
|
||||
self._event_handlers: dict[str, Any] = {}
|
||||
self._routers: list[APIRouter] = []
|
||||
|
||||
# ─── Lifecycle Hooks ───
|
||||
|
||||
async def on_install(self, db: AsyncSession, service_container: ServiceContainer) -> None:
|
||||
"""Called when the plugin is installed (after migrations are run).
|
||||
|
||||
Override to perform seed data or initial setup.
|
||||
"""
|
||||
return None
|
||||
|
||||
async def on_activate(
|
||||
self, db: AsyncSession, service_container: ServiceContainer, event_bus: EventBus
|
||||
) -> None:
|
||||
"""Called when the plugin is activated.
|
||||
|
||||
Override to register event listeners and prepare runtime state.
|
||||
Default implementation subscribes to events listed in the manifest.
|
||||
"""
|
||||
for event_name in self.manifest.events:
|
||||
handler = self._make_event_handler(event_name)
|
||||
self._event_handlers[event_name] = handler
|
||||
event_bus.subscribe(event_name, handler)
|
||||
|
||||
async def on_deactivate(
|
||||
self, db: AsyncSession, service_container: ServiceContainer, event_bus: EventBus
|
||||
) -> None:
|
||||
"""Called when the plugin is deactivated.
|
||||
|
||||
Override to clean up runtime state. Default implementation unsubscribes
|
||||
all event listeners that were registered during activation.
|
||||
"""
|
||||
for event_name, handler in self._event_handlers.items():
|
||||
event_bus.unsubscribe(event_name, handler)
|
||||
self._event_handlers.clear()
|
||||
|
||||
async def on_uninstall(self, db: AsyncSession, service_container: ServiceContainer) -> None:
|
||||
"""Called when the plugin is uninstalled (before data tables are dropped).
|
||||
|
||||
Override to clean up any external resources.
|
||||
"""
|
||||
return None
|
||||
|
||||
# ─── Route Registration ───
|
||||
|
||||
def get_routes(self) -> list[APIRouter]:
|
||||
"""Return list of APIRouter instances to mount on the FastAPI app.
|
||||
|
||||
Default implementation loads routers from manifest route definitions
|
||||
by importing the module and getting the specified attribute.
|
||||
"""
|
||||
if self._routers:
|
||||
return self._routers
|
||||
|
||||
routers: list[APIRouter] = []
|
||||
for route_def in self.manifest.routes:
|
||||
import importlib
|
||||
module = importlib.import_module(route_def.module)
|
||||
router: APIRouter = getattr(module, route_def.router_attr)
|
||||
routers.append(router)
|
||||
self._routers = routers
|
||||
return routers
|
||||
|
||||
# ─── Event Handler Factory ───
|
||||
|
||||
def _make_event_handler(self, event_name: str) -> Any:
|
||||
"""Create an event handler coroutine for the given event name.
|
||||
|
||||
Looks for a method named ``on_<event_name>`` with dots replaced by underscores.
|
||||
Falls back to ``on_event`` if it exists, otherwise uses a no-op handler.
|
||||
"""
|
||||
method_name = f"on_{event_name.replace('.', '_')}"
|
||||
specific = getattr(self, method_name, None)
|
||||
if specific is not None:
|
||||
return specific
|
||||
fallback = getattr(self, "on_event", None)
|
||||
if fallback is not None:
|
||||
return fallback
|
||||
# Default no-op handler
|
||||
async def _noop_handler(payload: dict[str, Any]) -> None:
|
||||
pass
|
||||
return _noop_handler
|
||||
|
||||
# ─── Utility ───
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return self.manifest.name
|
||||
|
||||
@property
|
||||
def version(self) -> str:
|
||||
return self.manifest.version
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<{self.__class__.__name__} name={self.manifest.name} version={self.manifest.version}>"
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Built-in plugins directory.
|
||||
|
||||
Each module in this package that exports a BasePlugin subclass will be
|
||||
discovered automatically by the plugin registry on application startup.
|
||||
"""
|
||||
@@ -0,0 +1,7 @@
|
||||
-- Bad migration: creates table WITHOUT tenant_id (should be rejected by validator)
|
||||
CREATE TABLE IF NOT EXISTS plugin_bad_table (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
title VARCHAR(200) NOT NULL,
|
||||
content TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
@@ -0,0 +1,10 @@
|
||||
-- Test plugin migration: creates plugin_test_data table with tenant_id
|
||||
CREATE TABLE IF NOT EXISTS plugin_test_data (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
tenant_id UUID NOT NULL,
|
||||
title VARCHAR(200) NOT NULL,
|
||||
content TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS ix_plugin_test_data_tenant ON plugin_test_data(tenant_id);
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Sample test plugin for LeoCRM plugin system testing."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from app.plugins.base import BasePlugin
|
||||
from app.plugins.manifest import PluginManifest
|
||||
|
||||
|
||||
class TestSamplePlugin(BasePlugin):
|
||||
"""A sample plugin for testing the plugin lifecycle."""
|
||||
|
||||
manifest = PluginManifest(
|
||||
name="test_sample",
|
||||
version="1.0.0",
|
||||
display_name="Test Sample Plugin",
|
||||
description="A sample plugin for testing install/activate/deactivate/uninstall lifecycle.",
|
||||
dependencies=[],
|
||||
routes=[],
|
||||
events=["company.created", "contact.created"],
|
||||
migrations=["0001_test_plugin.sql"],
|
||||
permissions=[],
|
||||
)
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.install_called = False
|
||||
self.activate_called = False
|
||||
self.deactivate_called = False
|
||||
self.uninstall_called = False
|
||||
self.event_log: list[dict[str, Any]] = []
|
||||
|
||||
async def on_install(self, db, service_container) -> None:
|
||||
self.install_called = True
|
||||
|
||||
async def on_activate(self, db, service_container, event_bus) -> None:
|
||||
self.activate_called = True
|
||||
await super().on_activate(db, service_container, event_bus)
|
||||
|
||||
async def on_deactivate(self, db, service_container, event_bus) -> None:
|
||||
self.deactivate_called = True
|
||||
await super().on_deactivate(db, service_container, event_bus)
|
||||
|
||||
async def on_uninstall(self, db, service_container) -> None:
|
||||
self.uninstall_called = True
|
||||
|
||||
async def on_company_created(self, payload: dict[str, Any]) -> None:
|
||||
self.event_log.append({"event": "company.created", "payload": payload})
|
||||
|
||||
async def on_contact_created(self, payload: dict[str, Any]) -> None:
|
||||
self.event_log.append({"event": "contact.created", "payload": payload})
|
||||
@@ -0,0 +1,70 @@
|
||||
"""Plugin manifest schema (Pydantic v2)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
|
||||
class PluginRouteDef(BaseModel):
|
||||
"""Route definition within a plugin manifest."""
|
||||
|
||||
path: str = Field(..., description="URL path prefix, e.g. /api/v1/plugin-mail")
|
||||
module: str = Field(..., description="Dotted path to the module containing the APIRouter")
|
||||
router_attr: str = Field(default="router", description="Attribute name of the APIRouter in the module")
|
||||
|
||||
|
||||
class PluginManifest(BaseModel):
|
||||
"""Manifest describing a plugin's metadata, dependencies, and capabilities."""
|
||||
|
||||
name: str = Field(..., min_length=1, max_length=80, description="Unique plugin identifier (snake_case)")
|
||||
version: str = Field(..., min_length=1, max_length=40, description="Semantic version string")
|
||||
display_name: str = Field(..., min_length=1, max_length=120)
|
||||
description: str = Field(default="", max_length=500)
|
||||
dependencies: list[str] = Field(default_factory=list, description="Other plugin names this plugin depends on")
|
||||
routes: list[PluginRouteDef] = Field(default_factory=list, description="Route definitions to register on activation")
|
||||
events: list[str] = Field(default_factory=list, description="Event names this plugin listens to")
|
||||
migrations: list[str] = Field(default_factory=list, description="Migration file names (ordered, e.g. 0001_initial.sql)")
|
||||
permissions: list[str] = Field(default_factory=list, description="Required permissions for this plugin")
|
||||
|
||||
@field_validator("name")
|
||||
@classmethod
|
||||
def validate_name(cls, v: str) -> str:
|
||||
if not v.replace("_", "").isalnum():
|
||||
raise ValueError("Plugin name must be alphanumeric with underscores only")
|
||||
return v.lower()
|
||||
|
||||
model_config = {"extra": "forbid"}
|
||||
|
||||
|
||||
class ManifestSchemaResponse(BaseModel):
|
||||
"""Response model describing the manifest schema for API consumers."""
|
||||
|
||||
fields: dict[str, dict[str, str]]
|
||||
example: PluginManifest
|
||||
|
||||
|
||||
# Pre-built schema documentation for GET /api/v1/plugins/manifest endpoint
|
||||
MANIFEST_SCHEMA_DOC = ManifestSchemaResponse(
|
||||
fields={
|
||||
"name": {"type": "str", "required": "true", "description": "Unique plugin identifier (snake_case, max 80 chars)"},
|
||||
"version": {"type": "str", "required": "true", "description": "Semantic version string"},
|
||||
"display_name": {"type": "str", "required": "true", "description": "Human-readable plugin name"},
|
||||
"description": {"type": "str", "required": "false", "description": "Plugin description (max 500 chars)"},
|
||||
"dependencies": {"type": "list[str]", "required": "false", "description": "Other plugin names required"},
|
||||
"routes": {"type": "list[PluginRouteDef]", "required": "false", "description": "Route definitions to register"},
|
||||
"events": {"type": "list[str]", "required": "false", "description": "Event names to listen to"},
|
||||
"migrations": {"type": "list[str]", "required": "false", "description": "Migration file names (ordered)"},
|
||||
"permissions": {"type": "list[str]", "required": "false", "description": "Required permissions"},
|
||||
},
|
||||
example=PluginManifest(
|
||||
name="example_plugin",
|
||||
version="1.0.0",
|
||||
display_name="Example Plugin",
|
||||
description="An example plugin demonstrating the manifest schema.",
|
||||
dependencies=[],
|
||||
routes=[],
|
||||
events=["company.created", "contact.created"],
|
||||
migrations=["0001_initial.sql"],
|
||||
permissions=["companies.read"],
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,282 @@
|
||||
"""Plugin DB migration runner with tenant_id column validation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import text, inspect
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession
|
||||
|
||||
from app.models.plugin import PluginMigration
|
||||
|
||||
|
||||
class MigrationValidationError(Exception):
|
||||
"""Raised when a plugin migration creates a table without tenant_id column."""
|
||||
|
||||
|
||||
class MigrationRunner:
|
||||
"""Runs plugin SQL migrations and validates that all created tables have tenant_id."""
|
||||
|
||||
def __init__(self, engine: AsyncEngine, migrations_dir: str | None = None) -> None:
|
||||
self._engine = engine
|
||||
self._migrations_dir = Path(migrations_dir or os.path.join(os.path.dirname(__file__), "migrations"))
|
||||
|
||||
def _resolve_migration_path(self, filename: str) -> Path:
|
||||
"""Resolve a migration filename to an absolute path."""
|
||||
# Try migrations_dir first, then app/plugins/migrations/
|
||||
candidate = self._migrations_dir / filename
|
||||
if candidate.exists():
|
||||
return candidate
|
||||
# Fallback: builtins directory
|
||||
builtins_dir = Path(__file__).parent / "builtins" / "migrations"
|
||||
candidate2 = builtins_dir / filename
|
||||
if candidate2.exists():
|
||||
return candidate2
|
||||
raise FileNotFoundError(f"Migration file not found: {filename}")
|
||||
|
||||
async def run_migration(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
plugin_name: str,
|
||||
migration_filename: str,
|
||||
tenant_id: Any | None = None,
|
||||
) -> PluginMigration:
|
||||
"""Run a single SQL migration file for a plugin.
|
||||
|
||||
Reads the SQL file, executes it, validates created tables have tenant_id,
|
||||
and records the migration in plugin_migrations table.
|
||||
|
||||
Args:
|
||||
db: Async database session.
|
||||
plugin_name: Name of the plugin being migrated.
|
||||
migration_filename: Filename of the migration SQL file.
|
||||
tenant_id: Optional tenant UUID for tenant-scoped migrations.
|
||||
|
||||
Returns:
|
||||
PluginMigration record.
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: If migration file doesn't exist.
|
||||
MigrationValidationError: If a created table lacks tenant_id column.
|
||||
"""
|
||||
migration_path = self._resolve_migration_path(migration_filename)
|
||||
sql_content = migration_path.read_text(encoding="utf-8")
|
||||
|
||||
# Capture existing table names before migration (static analysis of SQL)
|
||||
existing_tables = await self._get_table_names_via_session(db)
|
||||
|
||||
# Execute the migration SQL
|
||||
# Split on semicolons but handle potential function definitions
|
||||
statements = self._split_sql(sql_content)
|
||||
for stmt in statements:
|
||||
stmt = stmt.strip()
|
||||
if stmt:
|
||||
await db.execute(text(stmt))
|
||||
await db.flush()
|
||||
|
||||
# Capture table names after migration (using same session connection)
|
||||
new_tables = await self._get_table_names_via_session(db)
|
||||
created_tables = new_tables - existing_tables
|
||||
|
||||
# Validate: all newly created tables must have tenant_id column
|
||||
tables_without_tenant = []
|
||||
for table_name in created_tables:
|
||||
if not await self._table_has_column_via_session(db, table_name, "tenant_id"):
|
||||
tables_without_tenant.append(table_name)
|
||||
|
||||
if tables_without_tenant:
|
||||
# Rollback the migration
|
||||
await db.rollback()
|
||||
raise MigrationValidationError(
|
||||
f"Plugin '{plugin_name}' migration '{migration_filename}' created tables without tenant_id column: "
|
||||
f"{', '.join(tables_without_tenant)}. All plugin tables must have tenant_id for multi-tenant isolation."
|
||||
)
|
||||
|
||||
# Record the migration in plugin_migrations table
|
||||
migration_record = PluginMigration(
|
||||
plugin_name=plugin_name,
|
||||
migration_file=migration_filename,
|
||||
status="applied",
|
||||
)
|
||||
db.add(migration_record)
|
||||
await db.flush()
|
||||
|
||||
return migration_record
|
||||
|
||||
async def run_all_migrations(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
plugin_name: str,
|
||||
migration_files: list[str],
|
||||
tenant_id: Any | None = None,
|
||||
) -> list[PluginMigration]:
|
||||
"""Run all migration files for a plugin in order.
|
||||
|
||||
Skips already-applied migrations (idempotent).
|
||||
"""
|
||||
# Check which migrations are already applied
|
||||
from sqlalchemy import select
|
||||
|
||||
result = await db.execute(
|
||||
select(PluginMigration).where(
|
||||
PluginMigration.plugin_name == plugin_name,
|
||||
PluginMigration.status == "applied",
|
||||
)
|
||||
)
|
||||
applied_files = {row.migration_file for row in result.scalars().all()}
|
||||
|
||||
records: list[PluginMigration] = []
|
||||
for filename in migration_files:
|
||||
if filename in applied_files:
|
||||
continue # Already applied — idempotent skip
|
||||
record = await self.run_migration(db, plugin_name, filename, tenant_id)
|
||||
records.append(record)
|
||||
|
||||
return records
|
||||
|
||||
async def drop_plugin_tables(self, db: AsyncSession, plugin_name: str) -> list[str]:
|
||||
"""Drop all tables that were created by a plugin's migrations.
|
||||
|
||||
Args:
|
||||
db: Async database session.
|
||||
plugin_name: Name of the plugin to remove tables for.
|
||||
|
||||
Returns:
|
||||
List of dropped table names.
|
||||
"""
|
||||
from sqlalchemy import select
|
||||
|
||||
# Get all migration records for this plugin
|
||||
result = await db.execute(
|
||||
select(PluginMigration).where(PluginMigration.plugin_name == plugin_name)
|
||||
)
|
||||
migration_records = result.scalars().all()
|
||||
|
||||
dropped_tables: list[str] = []
|
||||
for record in migration_records:
|
||||
migration_path = self._resolve_migration_path(record.migration_file)
|
||||
sql_content = migration_path.read_text(encoding="utf-8")
|
||||
|
||||
# Parse table names from CREATE TABLE statements
|
||||
table_names = self._extract_table_names(sql_content)
|
||||
for table_name in table_names:
|
||||
try:
|
||||
await db.execute(text(f'DROP TABLE IF EXISTS "{table_name}" CASCADE'))
|
||||
dropped_tables.append(table_name)
|
||||
except Exception:
|
||||
pass # Table may already be gone
|
||||
await db.flush()
|
||||
|
||||
# Remove migration records
|
||||
for record in migration_records:
|
||||
await db.delete(record)
|
||||
await db.flush()
|
||||
|
||||
return dropped_tables
|
||||
|
||||
async def _get_table_names_via_session(self, db: AsyncSession) -> set[str]:
|
||||
"""Get current table names using the session's own connection.
|
||||
|
||||
This ensures we see uncommitted DDL changes within the same transaction.
|
||||
"""
|
||||
result = await db.execute(
|
||||
text("SELECT tablename FROM pg_tables WHERE schemaname = 'public'")
|
||||
)
|
||||
return {row[0] for row in result.fetchall()}
|
||||
|
||||
async def _table_has_column_via_session(
|
||||
self, db: AsyncSession, table_name: str, column_name: str
|
||||
) -> bool:
|
||||
"""Check if a table has a specific column using the session's connection."""
|
||||
result = await db.execute(
|
||||
text(
|
||||
"SELECT column_name FROM information_schema.columns "
|
||||
"WHERE table_schema = 'public' AND table_name = :table_name AND column_name = :column_name"
|
||||
),
|
||||
{"table_name": table_name, "column_name": column_name},
|
||||
)
|
||||
return result.fetchone() is not None
|
||||
|
||||
async def _get_table_names(self) -> set[str]:
|
||||
"""Get current table names from the database (separate connection)."""
|
||||
def _get_names(sync_conn):
|
||||
insp = inspect(sync_conn)
|
||||
return set(insp.get_table_names())
|
||||
|
||||
async with self._engine.connect() as conn:
|
||||
return await conn.run_sync(_get_names)
|
||||
|
||||
async def _table_has_column(self, table_name: str, column_name: str) -> bool:
|
||||
"""Check if a table has a specific column (separate connection)."""
|
||||
def _has_col(sync_conn):
|
||||
insp = inspect(sync_conn)
|
||||
if table_name not in insp.get_table_names():
|
||||
return False
|
||||
columns = [col["name"] for col in insp.get_columns(table_name)]
|
||||
return column_name in columns
|
||||
|
||||
async with self._engine.connect() as conn:
|
||||
return await conn.run_sync(_has_col)
|
||||
|
||||
@staticmethod
|
||||
def _split_sql(sql: str) -> list[str]:
|
||||
"""Split SQL into individual statements, respecting basic semicolon boundaries."""
|
||||
statements: list[str] = []
|
||||
current: list[str] = []
|
||||
in_dollar_quote = False
|
||||
dollar_tag = ""
|
||||
|
||||
for line in sql.splitlines():
|
||||
stripped = line.strip()
|
||||
|
||||
# Handle dollar-quoted blocks (PostgreSQL function bodies)
|
||||
if "$$" in stripped and not in_dollar_quote:
|
||||
parts = stripped.split("$$", 1)
|
||||
if len(parts) > 1:
|
||||
dollar_tag = parts[0].strip()
|
||||
if dollar_tag:
|
||||
in_dollar_quote = True
|
||||
elif stripped.count("$$") >= 2:
|
||||
# Single-line $$ block
|
||||
current.append(line)
|
||||
if stripped.rstrip().endswith(";"):
|
||||
statements.append("\n".join(current))
|
||||
current = []
|
||||
continue
|
||||
else:
|
||||
in_dollar_quote = True
|
||||
current.append(line)
|
||||
continue
|
||||
|
||||
if in_dollar_quote:
|
||||
current.append(line)
|
||||
if "$$" in stripped:
|
||||
in_dollar_quote = False
|
||||
# After closing $$, check if the line ends with ; to flush statement
|
||||
after_dollar = stripped.split("$$", 1)[-1] if "$$" in stripped else ""
|
||||
if after_dollar.rstrip().endswith(";"):
|
||||
statements.append("\n".join(current))
|
||||
current = []
|
||||
continue
|
||||
|
||||
current.append(line)
|
||||
if stripped.endswith(";"):
|
||||
statements.append("\n".join(current))
|
||||
current = []
|
||||
|
||||
if current:
|
||||
remaining = "\n".join(current).strip()
|
||||
if remaining:
|
||||
statements.append(remaining)
|
||||
|
||||
return statements
|
||||
|
||||
@staticmethod
|
||||
def _extract_table_names(sql: str) -> list[str]:
|
||||
"""Extract table names from CREATE TABLE statements in SQL."""
|
||||
import re
|
||||
# Match CREATE TABLE [IF NOT EXISTS] "table_name" or CREATE TABLE table_name
|
||||
pattern = r'CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?["\']?(\w+)["\']?'
|
||||
return re.findall(pattern, sql, re.IGNORECASE)
|
||||
@@ -0,0 +1,385 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user