Plugin lifecycle: the installer refuses four kinds, changes reach one process, sync loses state, nothing rolls back #228

Closed
opened 2026-09-15 21:23:27 +00:00 by tiagoagueda · 0 comments
Owner

Installing, disabling and restoring plugins has gaps that contradict Writing a plugin and the plugins page. Found in the 2026-09-15 code audit.

1. The installer refuses transport, outbox, feature and importer plugins

  • plugins/installing.py:66 has PLUGIN_GROUPS = (sources, notifiers, stores, syncs).
  • _plugin_entry_points (:306) ignores every other group, so check() refuses a transport-only wheel with "declares no Postulo entry point".
  • _forget_metadata_cache (:778) refreshes only those four kinds.
  • This contradicts the wiki's Transports section and notifications/text.py:13-18 ("the package is somebody else's"). No test covers the other groups.

Fix: derive the list from the non-empty values of registry.GROUPS, refresh every group, and add a test per kind.

2. Plugin changes reach only the process that made them

  • registry._cache is per process. Install, disable and remove refresh it only in the worker that served the request (installing.py:773-782, core/server_views.py:1285-1286).
  • The image runs three gunicorn workers plus a separate scheduler container whose loop never refreshes.
  • A plugin an administrator switched off keeps running, with people's secrets, in the other workers and the scheduler.
  • A newly installed notifier is "not installed" in the scheduler, so service.py silently skips it.
  • activate() runs only at ready(), so on a first install the directory never reaches the other workers' import path.
  • "Anything else is available now" (server_views.py:1205) is not true.

Fix: a generation stamp (the plugins.json mtime, or a database or cache key) checked in plugins(); re-run activate() and rebuild when it changes. The scheduler checks once per pass.

3. plugins sync restores the wrong state

  • installing.py:742-749 calls install_wheel without disabled or requires_postulo, so a disabled plugin comes back enabled and the compatibility marker is reset.
  • The fetch (management/commands/plugins.py:121-123) downloads listing.latest but checks it against the recorded entry.sha256, so restoring from a catalogue fails as soon as the catalogue has moved on.

Fix: carry every recorded field through, and fetch the recorded version's release.

4. No staging, no rollback, leftover dependencies

  • run_install does --target <live dir> --upgrade in place (:469-505), with no staging directory and no import check, so a broken upgrade replaces the working version.
  • remove() deletes only the plugin's own RECORD files (:615-636); its dependencies stay importable.
  • Constraints pin the core environment but not other plugins, so one plugin can move another's dependency.

Fix: install into a staging directory, import every entry point in a subprocess, then swap and keep the previous version. Reference-count dependencies before removing them.

5. A plugin the registry rejects still registers its templates and themes

registry.py:196-199 registers locale and themes before the interface and kind checks (:201-216). Third-party imports also happen lazily inside whichever request first calls plugins(), guarded only by except Exception, so a SystemExit or a hang at import reaches a live request.

Fix: register after the checks, load every group eagerly at ready() so failures surface at boot, and guard BaseException except KeyboardInterrupt.

6. Plugins cannot own tables or pages, though the docs say they can

  • CONTRIBUTING.md:558 says to ship your own migrations and be in INSTALLED_APPS, but INSTALLED_APPS is fixed with no hook.
  • Nothing mounts plugin URLs.
  • The scheduler runs with POSTULO_SKIP_MIGRATE.
  • server_views.py:1205 and plugins.py:88 promise "pages of its own" after a restart.

Fix: either add postulo.apps and postulo.urls entry-point groups (read at settings import after activate(), with migrate in the entrypoint), or state plainly that a plugin with state or pages must be built into the image.

Installing, disabling and restoring plugins has gaps that contradict *Writing a plugin* and the plugins page. Found in the 2026-09-15 code audit. ## 1. The installer refuses transport, outbox, feature and importer plugins - `plugins/installing.py:66` has `PLUGIN_GROUPS = (sources, notifiers, stores, syncs)`. - `_plugin_entry_points` (`:306`) ignores every other group, so `check()` refuses a transport-only wheel with "declares no Postulo entry point". - `_forget_metadata_cache` (`:778`) refreshes only those four kinds. - This contradicts the wiki's *Transports* section and `notifications/text.py:13-18` ("the package is somebody else's"). No test covers the other groups. **Fix:** derive the list from the non-empty values of `registry.GROUPS`, refresh every group, and add a test per kind. ## 2. Plugin changes reach only the process that made them - `registry._cache` is per process. Install, disable and remove refresh it only in the worker that served the request (`installing.py:773-782`, `core/server_views.py:1285-1286`). - The image runs three gunicorn workers plus a separate scheduler container whose loop never refreshes. - A plugin an administrator switched off keeps running, with people's secrets, in the other workers and the scheduler. - A newly installed notifier is "not installed" in the scheduler, so `service.py` silently skips it. - `activate()` runs only at `ready()`, so on a first install the directory never reaches the other workers' import path. - "Anything else is available now" (`server_views.py:1205`) is not true. **Fix:** a generation stamp (the `plugins.json` mtime, or a database or cache key) checked in `plugins()`; re-run `activate()` and rebuild when it changes. The scheduler checks once per pass. ## 3. `plugins sync` restores the wrong state - `installing.py:742-749` calls `install_wheel` without `disabled` or `requires_postulo`, so a disabled plugin comes back enabled and the compatibility marker is reset. - The fetch (`management/commands/plugins.py:121-123`) downloads `listing.latest` but checks it against the recorded `entry.sha256`, so restoring from a catalogue fails as soon as the catalogue has moved on. **Fix:** carry every recorded field through, and fetch the recorded version's release. ## 4. No staging, no rollback, leftover dependencies - `run_install` does `--target <live dir> --upgrade` in place (`:469-505`), with no staging directory and no import check, so a broken upgrade replaces the working version. - `remove()` deletes only the plugin's own RECORD files (`:615-636`); its dependencies stay importable. - Constraints pin the core environment but not other plugins, so one plugin can move another's dependency. **Fix:** install into a staging directory, import every entry point in a subprocess, then swap and keep the previous version. Reference-count dependencies before removing them. ## 5. A plugin the registry rejects still registers its templates and themes `registry.py:196-199` registers locale and themes before the interface and kind checks (`:201-216`). Third-party imports also happen lazily inside whichever request first calls `plugins()`, guarded only by `except Exception`, so a `SystemExit` or a hang at import reaches a live request. **Fix:** register after the checks, load every group eagerly at `ready()` so failures surface at boot, and guard `BaseException` except `KeyboardInterrupt`. ## 6. Plugins cannot own tables or pages, though the docs say they can - `CONTRIBUTING.md:558` says to ship your own migrations and be in `INSTALLED_APPS`, but `INSTALLED_APPS` is fixed with no hook. - Nothing mounts plugin URLs. - The scheduler runs with `POSTULO_SKIP_MIGRATE`. - `server_views.py:1205` and `plugins.py:88` promise "pages of its own" after a restart. **Fix:** either add `postulo.apps` and `postulo.urls` entry-point groups (read at settings import after `activate()`, with `migrate` in the entrypoint), or state plainly that a plugin with state or pages must be built into the image.
tiagoagueda added this to the 0.4.0 milestone 2026-09-15 21:33:26 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Postulo/postulo#228
No description provided.