feat: Core Plugin system — is_core flag, topological sort dependency resolution, deactivation protection
This commit is contained in:
@@ -0,0 +1,45 @@
|
|||||||
|
"""Add is_core boolean column to plugins table.
|
||||||
|
|
||||||
|
Marks core plugins (permissions, entity_links, tags) as is_core=True so they
|
||||||
|
cannot be deactivated and are always loaded first.
|
||||||
|
|
||||||
|
Revision ID: 0016_plugin_is_core
|
||||||
|
Revises: 0015_rls_policies
|
||||||
|
Create Date: 2026-07-07
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "0016_plugin_is_core"
|
||||||
|
down_revision: Union[str, None] = "0015_rls_policies"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
CORE_PLUGIN_NAMES = ["permissions", "entity_links", "tags"]
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Add is_core column with server default False so existing rows get False
|
||||||
|
op.add_column(
|
||||||
|
"plugins",
|
||||||
|
sa.Column("is_core", sa.Boolean(), nullable=False, server_default=sa.text("false")),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Mark known core plugins as is_core=True
|
||||||
|
op.execute(
|
||||||
|
sa.text(
|
||||||
|
"UPDATE plugins SET is_core = true WHERE name IN :names"
|
||||||
|
).bindparams(sa.bindparam("names", expanding=True)).bindparams(names=CORE_PLUGIN_NAMES)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("plugins", "is_core")
|
||||||
+2
-1
@@ -112,7 +112,7 @@ async def lifespan(app: FastAPI):
|
|||||||
async_session = async_sessionmaker(get_engine(), expire_on_commit=False)
|
async_session = async_sessionmaker(get_engine(), expire_on_commit=False)
|
||||||
|
|
||||||
async with async_session() as db:
|
async with async_session() as db:
|
||||||
for name in registry._plugins:
|
for name in registry.resolve_load_order():
|
||||||
plugin = registry.get_plugin(name)
|
plugin = registry.get_plugin(name)
|
||||||
if plugin is None:
|
if plugin is None:
|
||||||
continue
|
continue
|
||||||
@@ -131,6 +131,7 @@ async def lifespan(app: FastAPI):
|
|||||||
version=plugin.manifest.version,
|
version=plugin.manifest.version,
|
||||||
status="installed",
|
status="installed",
|
||||||
active=True,
|
active=True,
|
||||||
|
is_core=plugin.manifest.is_core,
|
||||||
)
|
)
|
||||||
db.add(plugin_record)
|
db.add(plugin_record)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ class Plugin(Base, TimestampMixin):
|
|||||||
status: Mapped[str] = mapped_column(String(20), nullable=False, default="installed")
|
status: Mapped[str] = mapped_column(String(20), nullable=False, default="installed")
|
||||||
installed: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
installed: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||||
active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
|
is_core: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
config: Mapped[dict[str, Any] | None] = mapped_column(
|
config: Mapped[dict[str, Any] | None] = mapped_column(
|
||||||
Text,
|
Text,
|
||||||
nullable=True, # JSON string for plugin configuration
|
nullable=True, # JSON string for plugin configuration
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ class EntityLinksPlugin(BasePlugin):
|
|||||||
events=["company.deleted", "contact.deleted"],
|
events=["company.deleted", "contact.deleted"],
|
||||||
migrations=["0001_initial.sql"],
|
migrations=["0001_initial.sql"],
|
||||||
permissions=[],
|
permissions=[],
|
||||||
|
is_core=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def on_company_deleted(self, payload: dict[str, Any]) -> None:
|
async def on_company_deleted(self, payload: dict[str, Any]) -> None:
|
||||||
|
|||||||
@@ -30,4 +30,5 @@ class PermissionsPlugin(BasePlugin):
|
|||||||
events=[],
|
events=[],
|
||||||
migrations=["0001_initial.sql"],
|
migrations=["0001_initial.sql"],
|
||||||
permissions=[],
|
permissions=[],
|
||||||
|
is_core=True,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -25,4 +25,5 @@ class TagsPlugin(BasePlugin):
|
|||||||
events=[],
|
events=[],
|
||||||
migrations=["0001_initial.sql"],
|
migrations=["0001_initial.sql"],
|
||||||
permissions=[],
|
permissions=[],
|
||||||
|
is_core=True,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -39,6 +39,9 @@ class PluginManifest(BaseModel):
|
|||||||
permissions: list[str] = Field(
|
permissions: list[str] = Field(
|
||||||
default_factory=list, description="Required permissions for this plugin"
|
default_factory=list, description="Required permissions for this plugin"
|
||||||
)
|
)
|
||||||
|
is_core: bool = Field(
|
||||||
|
default=False, description="Whether this is a core plugin that cannot be deactivated"
|
||||||
|
)
|
||||||
|
|
||||||
@field_validator("name")
|
@field_validator("name")
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|||||||
@@ -180,6 +180,78 @@ class PluginRegistry:
|
|||||||
f"Activate them first."
|
f"Activate them first."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# ── Topological Sort & Dependents ──
|
||||||
|
|
||||||
|
def resolve_load_order(self) -> list[str]:
|
||||||
|
"""Return plugin names in dependency-aware load order using Kahn's algorithm.
|
||||||
|
|
||||||
|
Core plugins (is_core=True) are emitted first, then non-core plugins
|
||||||
|
in topological order based on declared dependencies.
|
||||||
|
|
||||||
|
Raises RuntimeError on circular dependencies or missing dependencies.
|
||||||
|
"""
|
||||||
|
names = set(self._plugins.keys())
|
||||||
|
|
||||||
|
# Validate that all declared dependencies exist in the registry
|
||||||
|
for name in names:
|
||||||
|
plugin = self._plugins[name]
|
||||||
|
for dep in plugin.manifest.dependencies:
|
||||||
|
if dep not in names:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Plugin '{name}' depends on '{dep}' which is not discovered. "
|
||||||
|
f"Available plugins: {', '.join(sorted(names))}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build adjacency: dep -> list of plugins that depend on it
|
||||||
|
# and in-degree: plugin -> count of unsatisfied dependencies
|
||||||
|
in_degree: dict[str, int] = {name: 0 for name in names}
|
||||||
|
dependents_map: dict[str, list[str]] = {name: [] for name in names}
|
||||||
|
|
||||||
|
for name in names:
|
||||||
|
plugin = self._plugins[name]
|
||||||
|
for dep in plugin.manifest.dependencies:
|
||||||
|
dependents_map[dep].append(name)
|
||||||
|
in_degree[name] += 1
|
||||||
|
|
||||||
|
# Single Kahn pass with priority: core plugins before non-core at each level.
|
||||||
|
# Queue entries are tuples: (priority, name) where priority 0 = core, 1 = non-core.
|
||||||
|
import heapq
|
||||||
|
|
||||||
|
heap: list[tuple[int, str]] = []
|
||||||
|
for name in names:
|
||||||
|
if in_degree[name] == 0:
|
||||||
|
priority = 0 if self._plugins[name].manifest.is_core else 1
|
||||||
|
heapq.heappush(heap, (priority, name))
|
||||||
|
|
||||||
|
result: list[str] = []
|
||||||
|
while heap:
|
||||||
|
_, current = heapq.heappop(heap)
|
||||||
|
result.append(current)
|
||||||
|
for dependent in dependents_map[current]:
|
||||||
|
in_degree[dependent] -= 1
|
||||||
|
if in_degree[dependent] == 0:
|
||||||
|
priority = 0 if self._plugins[dependent].manifest.is_core else 1
|
||||||
|
heapq.heappush(heap, (priority, dependent))
|
||||||
|
|
||||||
|
if len(result) != len(names):
|
||||||
|
unresolved = names - set(result)
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Circular dependency detected among plugins: {', '.join(sorted(unresolved))}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
def get_dependents(self, name: str) -> list[str]:
|
||||||
|
"""Return list of discovered plugin names that depend on the given plugin.
|
||||||
|
|
||||||
|
This examines manifest.dependencies of all discovered plugins.
|
||||||
|
"""
|
||||||
|
dependents: list[str] = []
|
||||||
|
for plugin_name, plugin in self._plugins.items():
|
||||||
|
if name in plugin.manifest.dependencies:
|
||||||
|
dependents.append(plugin_name)
|
||||||
|
return sorted(dependents)
|
||||||
|
|
||||||
# ── Permission Validation (soft check) ──
|
# ── Permission Validation (soft check) ──
|
||||||
|
|
||||||
def _check_permissions(self, name: str) -> list[str]:
|
def _check_permissions(self, name: str) -> list[str]:
|
||||||
@@ -289,6 +361,7 @@ class PluginRegistry:
|
|||||||
status="installed",
|
status="installed",
|
||||||
installed=True,
|
installed=True,
|
||||||
active=False,
|
active=False,
|
||||||
|
is_core=plugin.manifest.is_core,
|
||||||
)
|
)
|
||||||
db.add(record)
|
db.add(record)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
@@ -370,6 +443,26 @@ class PluginRegistry:
|
|||||||
if not record.active and record.status == "inactive":
|
if not record.active and record.status == "inactive":
|
||||||
return record
|
return record
|
||||||
|
|
||||||
|
# Reject deactivation of core plugins
|
||||||
|
if plugin.manifest.is_core or record.is_core:
|
||||||
|
raise ValueError(
|
||||||
|
f"Plugin '{name}' is a core plugin and cannot be deactivated"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Reject deactivation if other active plugins depend on it
|
||||||
|
dependents = self.get_dependents(name)
|
||||||
|
active_dependents: list[str] = []
|
||||||
|
for dep_name in dependents:
|
||||||
|
dep_record = await self._get_plugin_record(db, dep_name)
|
||||||
|
if dep_record is not None and dep_record.active:
|
||||||
|
active_dependents.append(dep_name)
|
||||||
|
|
||||||
|
if active_dependents:
|
||||||
|
raise ValueError(
|
||||||
|
f"Plugin '{name}' cannot be deactivated because active plugins "
|
||||||
|
f"depend on it: {', '.join(active_dependents)}"
|
||||||
|
)
|
||||||
|
|
||||||
# Call on_deactivate hook (unregisters event listeners)
|
# Call on_deactivate hook (unregisters event listeners)
|
||||||
await plugin.on_deactivate(db, self._container, self._event_bus)
|
await plugin.on_deactivate(db, self._container, self._event_bus)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user