Repository navigation
Plugin SDK + discovery (GET /v1/plugins/types) - #23
Merged
Merged
Conversation
First half of the plugin-surface kernel commitment (PLAN.md step 10).
Lands what plugin authors import + the loader that walks
CURFEW_PLUGINS_DIRS at startup. Operator-facing surface is just
`GET /v1/plugins/types` — assignment CRUD + reconcile dispatch + the
sentinel-writing reftest plugin land in step 11, after which the kernel
acceptance plugin path passes end-to-end.
What's in:
- `core/curfew/plugin.py` — the SDK module plugin authors import. One
base class (`Plugin`) with `async def reconcile(scope) -> ReconcileResult`,
plus `Scope` (`user, locked, reasons` — no `target_apps` yet, deferred
until first concrete consumer per PLAN.md), `ReconcileResult` (with
`ok()` and `error(msg)` helpers), `PluginManifest` dataclass.
- `core/curfew/plugin_loader.py` — `discover_plugins(dirs)` walks
colon-separated dirs, parses manifest.toml (required: type, name,
version, description, config_schema), imports plugin.py via
`spec_from_file_location` under a synthetic `curfew_plugin_<type>`
module name, picks the leaf `Plugin` subclass (so plugins can have
internal helper bases like `BaseAdGuardClient(Plugin)` plus a leaf),
resolves the Pydantic config model by name. Per-plugin errors are
captured on the registry entry, never raised — one bad plugin folder
doesn't break startup (PLUGINS.md "What can go wrong"). Later-wins on
duplicate `type` per ADR-013.
- `GET /v1/plugins/types` — operator-visible list of discovered types
including any per-plugin discovery errors. Reads from
`app.state.plugin_registry` built once at startup.
- `plugins/reftest-plugin/` — minimal stub (no-op reconcile) so the
default `CURFEW_PLUGINS_DIRS` entry has at least one valid plugin to
prove discovery works. Step 11 turns this into the sentinel writer.
Notable design choices:
- **Rename `models.Plugin` → `models.PluginAssignment`** to free up the
`Plugin` name for the SDK base class (which is what plugin authors
import per PLUGINS.md). The DB row represents the operator's
*assignment* of a plugin type to a set of users — the name matches
the CLI verbs (`assign`/`unassign`). Tablename `plugins` is unchanged;
no migration needed.
- **No `requirements.txt` auto-install.** PLAN.md ADR-013 lists it as
optional; running pip-install at every startup is slow + a failure
mode for no immediate gain. Reftest has no deps; real plugins land
this when they need it. Loader logs an info line and records
`has_requirements_txt` on the registry entry so the operator sees it.
- **Errors don't break startup.** Non-negotiable for a homelab — one
operator-authored plugin shouldn't take down the whole core. Loader
catches per-plugin and surfaces via `/v1/plugins/types`.
- **Leaf-class entry-point selection.** Filters to subclasses defined
in the plugin's own module (`__module__` check) so importing
`Plugin` itself doesn't count. Zero subclasses → error; multiple
leaves → error with names listed.
Drive-by fix:
- `api/tests/test_migration.py` and one test in `core/tests/test_config.py`
now `monkeypatch.delenv("CURFEW_DB_PATH")` so they're not redirected
by `api/migrations/env.py`'s env-var override. `just test` was
failing 12 migration tests + 1 config test on every PR since the
justfile started pinning `CURFEW_DB_PATH`; without this fix `just
check` is broken for the whole repo.
Verified:
- `just check` clean (ruff, ruff format, mypy, 207 pytest, web-check
with 23 vitest + build).
- New test count: 6 in test_plugin_sdk, 18 in test_plugin_loader, 5 in
test_plugins_types — covering missing manifest, malformed manifest,
no Plugin subclass, multiple leaves → error, helper-base + leaf,
config_schema missing or wrong type, override-on-duplicate,
per-plugin error containment.
Out of scope (step 11): plugins-table CRUD endpoints, in-memory
instance manager (instantiate on assign / re-instantiate on PATCH /
drop on unassign), reconcile dispatch via BackgroundTasks on
lock-toggle, safety-net resync as lifespan task, per-call timeout,
reftest sentinel logic, e2e exercising kernel acceptance steps 11–16.
4 tasks done
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
First half of the plugin-surface kernel commitment (PLAN.md step 10). Lands what plugin authors import + the loader that walks
CURFEW_PLUGINS_DIRSat startup. Operator-facing surface is justGET /v1/plugins/types— assignment CRUD + reconcile dispatch + the sentinel-writing reftest plugin land in step 11, after which the kernel acceptance plugin path passes end-to-end.What's in
core/curfew/plugin.py— SDK module plugin authors import:Pluginbase class withasync def reconcile(scope) -> ReconcileResult,Scope(user, locked, reasons— notarget_appsyet, deferred until first concrete consumer per PLAN.md),ReconcileResult(withok()/error(msg)helpers),PluginManifest.core/curfew/plugin_loader.py—discover_plugins(dirs)walks colon-separated dirs, parsesmanifest.toml, importsplugin.pyunder a syntheticcurfew_plugin_<type>module, picks the leafPluginsubclass (so authors can have helper bases likeBaseAdGuardClient(Plugin)plus the leaf), resolves the Pydantic config model by name. Per-plugin errors captured on the registry entry, never raised — one bad plugin folder doesn't break startup. Later-wins on duplicatetypeper ADR-013.GET /v1/plugins/types— operator-visible list of discovered types including any per-plugin errors. Reads fromapp.state.plugin_registrybuilt once at startup.plugins/reftest-plugin/— minimal stub (no-op reconcile) so the defaultCURFEW_PLUGINS_DIRSentry has a valid plugin proving discovery works. Step 11 turns this into the sentinel writer.Notable design choices
models.Plugin→models.PluginAssignmentto free up thePluginname for the SDK base class (what authors import per PLUGINS.md). DB row represents the operator's assignment — name matches CLI verbs (assign/unassign). Tablenamepluginsunchanged; no migration.requirements.txtauto-install. ADR-013 lists it as optional; running pip-install at every startup is slow + a failure mode for no immediate gain. Reftest has no deps; real plugins land this when they need it. Loader logs an info line and recordshas_requirements_txton the registry entry so the operator sees it./v1/plugins/types.Pluginitself doesn't count. Zero subclasses → error; multiple leaves → error with names listed.Drive-by fix
api/tests/test_migration.py+ one test incore/tests/test_config.pynowmonkeypatch.delenv("CURFEW_DB_PATH")so they're not redirected byapi/migrations/env.py's env-var override.just testwas silently failing 12 migration tests + 1 config test on every PR since the justfile started pinningCURFEW_DB_PATH; without this fixjust checkis broken for the whole repo.Out of scope (step 11)
Plugins-table CRUD endpoints, in-memory instance manager (instantiate on assign / re-instantiate on PATCH / drop on unassign), reconcile dispatch via
BackgroundTaskson lock-toggle, safety-net resync as lifespan task, per-call timeout, reftest sentinel logic, e2e exercising kernel acceptance steps 11–16.Test plan
just checkclean (ruff, ruff format, mypy, 207 pytest, 23 vitest, web build)test_plugin_sdk.py)test_plugin_loader.py) — missing manifest, malformed manifest, noPluginsubclass, multiple leaves → error, helper-base + leaf, config_schema missing or wrong type, override-on-duplicate, multi-dir, per-plugin error containmenttest_plugins_types.py) — auth gate, empty registry, successful plugin shape, failed plugin shape, alphabetical orderingplugins/, restart, see it surfaced with its error inGET /v1/plugins/types