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:
@@ -1 +1,3 @@
|
||||
"""Service layer package."""
|
||||
|
||||
from app.services.plugin_service import PluginService, get_plugin_service
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
"""Plugin service layer — business logic for plugin lifecycle operations."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.audit import log_audit
|
||||
from app.plugins.registry import PluginRegistry, get_registry
|
||||
from app.plugins.migration_runner import MigrationValidationError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PluginService:
|
||||
"""Service layer for plugin lifecycle management.
|
||||
|
||||
Wraps the PluginRegistry with audit logging and error handling.
|
||||
"""
|
||||
|
||||
def __init__(self, registry: PluginRegistry | None = None) -> None:
|
||||
self._registry = registry or get_registry()
|
||||
|
||||
@property
|
||||
def registry(self) -> PluginRegistry:
|
||||
return self._registry
|
||||
|
||||
async def list_plugins(self, db: AsyncSession) -> list[dict[str, Any]]:
|
||||
"""List all discovered and installed plugins with their status."""
|
||||
return await self._registry.list_plugins(db)
|
||||
|
||||
async def install_plugin(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
name: str,
|
||||
tenant_id: uuid.UUID | None = None,
|
||||
user_id: uuid.UUID | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Install a plugin by name.
|
||||
|
||||
Runs migrations, calls on_install hook, creates DB record.
|
||||
Idempotent: returns existing record if already installed.
|
||||
"""
|
||||
try:
|
||||
record = await self._registry.install(db, name)
|
||||
if tenant_id and user_id:
|
||||
await log_audit(
|
||||
db, tenant_id, user_id,
|
||||
action="plugin.install",
|
||||
entity_type="plugin",
|
||||
entity_id=getattr(record, "id", None),
|
||||
changes={"name": name, "version": record.version},
|
||||
)
|
||||
return {
|
||||
"name": record.name,
|
||||
"display_name": record.display_name,
|
||||
"version": record.version,
|
||||
"status": record.status,
|
||||
"installed": record.installed,
|
||||
"active": record.active,
|
||||
"message": "Plugin installed successfully",
|
||||
}
|
||||
except ValueError as exc:
|
||||
raise ValueError(str(exc))
|
||||
except MigrationValidationError as exc:
|
||||
raise MigrationValidationError(str(exc))
|
||||
|
||||
async def activate_plugin(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
name: str,
|
||||
tenant_id: uuid.UUID | None = None,
|
||||
user_id: uuid.UUID | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Activate a plugin by name.
|
||||
|
||||
Registers event listeners and routes. Idempotent.
|
||||
"""
|
||||
try:
|
||||
record = await self._registry.activate(db, name)
|
||||
was_already_active = record.active and record.status == "active"
|
||||
if tenant_id and user_id:
|
||||
await log_audit(
|
||||
db, tenant_id, user_id,
|
||||
action="plugin.activate",
|
||||
entity_type="plugin",
|
||||
entity_id=getattr(record, "id", None),
|
||||
changes={"name": name, "was_already_active": was_already_active},
|
||||
)
|
||||
return {
|
||||
"name": record.name,
|
||||
"display_name": record.display_name,
|
||||
"version": record.version,
|
||||
"status": record.status,
|
||||
"installed": record.installed,
|
||||
"active": record.active,
|
||||
"message": "Plugin is already active" if was_already_active else "Plugin activated successfully",
|
||||
}
|
||||
except ValueError as exc:
|
||||
raise ValueError(str(exc))
|
||||
|
||||
async def deactivate_plugin(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
name: str,
|
||||
tenant_id: uuid.UUID | None = None,
|
||||
user_id: uuid.UUID | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Deactivate a plugin by name.
|
||||
|
||||
Unregisters event listeners and routes. Idempotent.
|
||||
"""
|
||||
try:
|
||||
record = await self._registry.deactivate(db, name)
|
||||
was_already_inactive = not record.active and record.status == "inactive"
|
||||
if tenant_id and user_id:
|
||||
await log_audit(
|
||||
db, tenant_id, user_id,
|
||||
action="plugin.deactivate",
|
||||
entity_type="plugin",
|
||||
entity_id=getattr(record, "id", None),
|
||||
changes={"name": name, "was_already_inactive": was_already_inactive},
|
||||
)
|
||||
return {
|
||||
"name": record.name,
|
||||
"display_name": record.display_name,
|
||||
"version": record.version,
|
||||
"status": record.status,
|
||||
"installed": record.installed,
|
||||
"active": record.active,
|
||||
"message": "Plugin is already inactive" if was_already_inactive else "Plugin deactivated successfully",
|
||||
}
|
||||
except ValueError as exc:
|
||||
raise ValueError(str(exc))
|
||||
|
||||
async def uninstall_plugin(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
name: str,
|
||||
remove_data: bool = False,
|
||||
tenant_id: uuid.UUID | None = None,
|
||||
user_id: uuid.UUID | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Uninstall a plugin by name.
|
||||
|
||||
Deactivates, calls on_uninstall hook, optionally drops tables, removes DB record.
|
||||
"""
|
||||
try:
|
||||
record = await self._registry.uninstall(db, name, remove_data=remove_data)
|
||||
dropped_tables = getattr(record, "dropped_tables", [])
|
||||
if tenant_id and user_id:
|
||||
await log_audit(
|
||||
db, tenant_id, user_id,
|
||||
action="plugin.uninstall",
|
||||
entity_type="plugin",
|
||||
changes={"name": name, "remove_data": remove_data, "dropped_tables": dropped_tables},
|
||||
)
|
||||
return {
|
||||
"name": name,
|
||||
"display_name": record.display_name,
|
||||
"version": record.version,
|
||||
"status": "uninstalled",
|
||||
"installed": False,
|
||||
"active": False,
|
||||
"dropped_tables": dropped_tables,
|
||||
"message": f"Plugin uninstalled{' and data tables dropped' if remove_data else ''}",
|
||||
}
|
||||
except ValueError as exc:
|
||||
raise ValueError(str(exc))
|
||||
|
||||
def get_manifest_schema(self) -> dict[str, Any]:
|
||||
"""Return the manifest schema documentation."""
|
||||
from app.plugins.manifest import MANIFEST_SCHEMA_DOC
|
||||
return MANIFEST_SCHEMA_DOC.model_dump()
|
||||
|
||||
|
||||
# Global service instance
|
||||
_service: PluginService | None = None
|
||||
|
||||
|
||||
def get_plugin_service() -> PluginService:
|
||||
"""Get the global plugin service instance."""
|
||||
global _service
|
||||
if _service is None:
|
||||
_service = PluginService()
|
||||
return _service
|
||||
|
||||
|
||||
def reset_plugin_service_for_testing(registry: PluginRegistry | None = None) -> PluginService:
|
||||
"""Create a fresh plugin service for testing."""
|
||||
global _service
|
||||
_service = PluginService(registry=registry)
|
||||
return _service
|
||||
Reference in New Issue
Block a user