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,115 @@
|
||||
"""Plugin background jobs: registration, cron parsing, singleton lock (SPEC §11.2)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from app.plugins import jobs, loader
|
||||
from app.plugins.jobs import job_name, parse_cron, plugin_arq_functions, plugin_cron_jobs
|
||||
from app.plugins.sdk import JobSpec, Plugin, sdk
|
||||
from app.redis_client import get_redis
|
||||
|
||||
|
||||
async def _noop(ctx: dict[str, Any], **kwargs: Any) -> None:
|
||||
return None
|
||||
|
||||
|
||||
def test_job_name_is_namespaced() -> None:
|
||||
assert job_name("calendar", JobSpec(func=_noop)) == "plg.calendar._noop"
|
||||
|
||||
|
||||
def test_parse_cron() -> None:
|
||||
assert parse_cron("*/15 * * * *") == {
|
||||
"minute": {0, 15, 30, 45},
|
||||
"hour": None,
|
||||
"day": None,
|
||||
"month": None,
|
||||
"weekday": None,
|
||||
}
|
||||
assert parse_cron("0 9-11 1 * 1,3") == {
|
||||
"minute": {0},
|
||||
"hour": {9, 10, 11},
|
||||
"day": {1},
|
||||
"month": None,
|
||||
"weekday": {1, 3},
|
||||
}
|
||||
with pytest.raises(ValueError, match="5 fields"):
|
||||
parse_cron("* * *")
|
||||
|
||||
|
||||
def test_functions_and_cron_registered() -> None:
|
||||
async def refresh(ctx: dict[str, Any], **kwargs: Any) -> None:
|
||||
return None
|
||||
|
||||
async def poll(ctx: dict[str, Any], **kwargs: Any) -> None:
|
||||
return None
|
||||
|
||||
loader.set_plugins_for_test(
|
||||
[
|
||||
Plugin(
|
||||
name="cal",
|
||||
version="1",
|
||||
jobs=[JobSpec(func=refresh), JobSpec(func=poll, schedule="*/5 * * * *")],
|
||||
)
|
||||
]
|
||||
)
|
||||
fn_names = {f.name for f in plugin_arq_functions()}
|
||||
assert fn_names == {"plg.cal.refresh", "plg.cal.poll"} # both enqueueable
|
||||
|
||||
crons = plugin_cron_jobs()
|
||||
assert [c.name for c in crons] == ["plg.cal.poll.cron"] # only the scheduled one
|
||||
|
||||
|
||||
async def test_singleton_lock_prevents_overlap() -> None:
|
||||
calls: list[str] = []
|
||||
|
||||
async def work(ctx: dict[str, Any], **kwargs: Any) -> None:
|
||||
calls.append(str(kwargs.get("user_id")))
|
||||
|
||||
plugin = Plugin(name="cal", version="1", jobs=[JobSpec(func=work, singleton=True)])
|
||||
runner, _ = jobs._make_runner(plugin, plugin.jobs[0])
|
||||
|
||||
await runner({}, user_id="u1") # runs; lock acquired then released
|
||||
assert calls == ["u1"]
|
||||
|
||||
# Simulate an instance already holding the lock for u2 -> the run is skipped.
|
||||
held = jobs._lock_key("cal", "work", "u2")
|
||||
await get_redis().set(held, b"1", nx=True, ex=60)
|
||||
await runner({}, user_id="u2")
|
||||
assert calls == ["u1"] # unchanged
|
||||
|
||||
await get_redis().delete(held)
|
||||
await runner({}, user_id="u2") # lock free now -> runs
|
||||
assert calls == ["u1", "u2"]
|
||||
|
||||
|
||||
async def test_non_singleton_always_runs() -> None:
|
||||
calls: list[int] = []
|
||||
|
||||
async def work(ctx: dict[str, Any], **kwargs: Any) -> None:
|
||||
calls.append(1)
|
||||
|
||||
plugin = Plugin(name="cal", version="1", jobs=[JobSpec(func=work)])
|
||||
runner, _ = jobs._make_runner(plugin, plugin.jobs[0])
|
||||
await runner({}, user_id="u1")
|
||||
await runner({}, user_id="u1")
|
||||
assert calls == [1, 1] # no lock, both run
|
||||
|
||||
|
||||
async def test_sdk_enqueue_uses_namespaced_name(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
async def fake_enqueue(name: str, **kwargs: Any) -> None:
|
||||
captured["name"] = name
|
||||
captured["kwargs"] = kwargs
|
||||
|
||||
monkeypatch.setattr("app.plugins.sdk.enqueue_job", fake_enqueue)
|
||||
|
||||
async def refresh(ctx: dict[str, Any], **kwargs: Any) -> None:
|
||||
return None
|
||||
|
||||
await sdk("cal").enqueue(refresh, user_id="u1")
|
||||
assert captured["name"] == "plg.cal.refresh"
|
||||
assert captured["kwargs"] == {"user_id": "u1"}
|
||||
Reference in New Issue
Block a user