M3 plugin platform: SDK + loader, RLS-isolated tables, migrations, jobs
Backend foundation for the plugin platform (SPEC §11), Phases 1-3.
Phase 1 — SDK + loader:
- app/plugins/types.py: frozen Plugin/UiManifest/Slot/Page/JobSpec contract.
- app/plugins/sdk.py: sdk(name) facade — the only app.* import allowed for plugins
(scoped session, plugin.{name}.* publish, blob, outbox, settings, SSRF http_get, KV, enqueue).
- app/plugins/loader.py: entry-point discovery (webmail.plugins), hook forwarding
(API + worker), router mounting at /api/plugins/{name}, ui-manifest aggregation.
- plugin_kv core table (migration 0013) + GET /api/plugins/ui-manifest.
Phase 2 — non-removable core plugins + DB-enforced privacy:
- builtin flag (calendar/contacts will be builtin=True; uninstall refuses them).
- RLS per-user isolation on plg_* tables: least-privilege webmail_plugin role
(migration 0014), plugin_session(user_id) sets LOCAL ROLE + app.current_user_id,
rls.enable_rls() (ENABLE+FORCE RLS + USING/WITH CHECK policy). Mail untouched.
- Per-plugin Alembic runner (shared env, dedicated version table) + install/uninstall
--drop-data CLI; skeleton fixture proves install -> run -> uninstall --drop-data
leaves zero residue (§12 M3 acceptance).
Phase 3 — plugin jobs (§11.2):
- JobSpec registered as arq function plg.{plugin}.{func} + cron (5-field parser);
singleton=True guarded by a per-(plugin,user) Redis lock; wired onto the shared
interactive worker.
Gates: ruff, mypy --strict (94 files + tests), full suite 326 passed. RLS isolation,
migration round-trip, and singleton lock covered by tests.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rcj3TpydMTzTSmU7kunzf8
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
"""Shared Alembic environment for plugin migration branches (SPEC §11.1).
|
||||
|
||||
Every plugin reuses this env; the runner (:mod:`app.plugins.migrate`) points
|
||||
``version_locations`` at the plugin's own revision directory and gives it a
|
||||
dedicated ``version_table`` (``alembic_version_plg_{name}``) so each plugin's
|
||||
migration history is independent of core and of every other plugin.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
from alembic import context
|
||||
from sqlalchemy.ext.asyncio import async_engine_from_config
|
||||
from sqlalchemy.pool import NullPool
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
config = context.config
|
||||
config.set_main_option("sqlalchemy.url", get_settings().database_url)
|
||||
_version_table = config.get_main_option("version_table") or "alembic_version"
|
||||
|
||||
|
||||
def _do_run_migrations(connection: object) -> None:
|
||||
context.configure(connection=connection, version_table=_version_table) # type: ignore[arg-type]
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
async def _run_online() -> None:
|
||||
engine = async_engine_from_config(
|
||||
config.get_section(config.config_ini_section, {}), prefix="sqlalchemy.", poolclass=NullPool
|
||||
)
|
||||
async with engine.connect() as connection:
|
||||
await connection.run_sync(_do_run_migrations)
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
asyncio.run(_run_online())
|
||||
@@ -0,0 +1,26 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
revision: str = ${repr(up_revision)}
|
||||
down_revision: str | None = ${repr(down_revision)}
|
||||
branch_labels: str | Sequence[str] | None = ${repr(branch_labels)}
|
||||
depends_on: str | Sequence[str] | None = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
${downgrades if downgrades else "pass"}
|
||||
Reference in New Issue
Block a user