# Plugin System Overview Plugins extend Steward with new data sources, UI pages, scheduled tasks, and dashboard widgets. Only enabled plugins are imported — disabled or unlisted plugins have zero runtime overhead. --- ## How Plugins Are Loaded Plugin loading happens in step 9 of `create_app()`, after all core blueprints and tasks are registered. The entrypoint is `load_plugins(app)` in `steward/core/plugin_manager.py`. For each plugin listed as `enabled: true` in the `PLUGINS` config: 1. **Directory check** — `plugins//` must exist 2. **`plugin.yaml` check** — file must exist and be valid YAML 3. **Name validation** — `plugin.yaml` `name` field must match the directory name exactly 4. **Version check** — if `min_app_version` is set, the running app version must be >= that value (uses SemVer comparison) 5. **Config merge** — `plugin.yaml` `config` defaults are merged with user overrides from app settings; result stored in `app.config["PLUGINS"][name]` 6. **Import** — `importlib.import_module(name)` imports the plugin package (the plugins directory is prepended to `sys.path`) 7. **Export validation** — plugin must export `setup` and `get_scheduled_tasks` 8. **`setup(app)`** called 9. **Blueprint registration** — if plugin exports `get_blueprint()`, the returned blueprint is registered at `/plugins//` 10. **Task registration** — `get_scheduled_tasks()` results are appended to `app._task_registry` If any step fails, the plugin is skipped with an error log and startup continues. --- ## Plugin Directory Structure ``` plugins/ └── myplugin/ ├── __init__.py # Required: setup(), get_scheduled_tasks(), optionally get_blueprint() ├── plugin.yaml # Required: name, version, description ├── models.py # SQLAlchemy models (optional if plugin has no DB tables) ├── routes.py # Quart Blueprint (optional if plugin has no UI) ├── scheduler.py # Task logic (optional if plugin has no background tasks) ├── migrations/ # Alembic migrations for plugin DB tables │ ├── env.py │ ├── script.py.mako │ └── versions/ └── templates/ └── myplugin/ # Jinja2 templates (namespaced under plugin name) ├── index.html └── widget.html ``` --- ## plugin.yaml Schema ```yaml # Required name: myplugin # Must match directory name exactly version: "1.0.0" # SemVer description: "Does a thing" # Optional author: "Your Name" min_app_version: "0.1.0" # Minimum Steward version required # Default config — merged with user overrides at runtime # Access at runtime via: app.config["PLUGINS"]["myplugin"]["my_setting"] config: my_setting: "default_value" poll_interval_seconds: 60 ``` --- ## Required Python Exports Every plugin's `__init__.py` must export: ### `setup(app: Quart) -> None` Called once during app startup. Use it to store the `app` reference for use in scheduled tasks, and to import models so they register with SQLAlchemy metadata. ```python _app = None def setup(app): global _app _app = app from .models import MyModel # noqa: registers model with Base.metadata ``` ### `get_scheduled_tasks() -> list[ScheduledTask]` Returns a list of `ScheduledTask` objects. Return `[]` if the plugin has no background tasks. Called after `setup()`, so any app references set in `setup()` are available. ```python from steward.core.scheduler import ScheduledTask def get_scheduled_tasks(): app = _app async def my_task(): async with app.db_sessionmaker() as session: async with session.begin(): # do work pass return [ ScheduledTask( name="myplugin_task", coro_factory=my_task, interval_seconds=app.config["PLUGINS"]["myplugin"]["poll_interval_seconds"], run_on_startup=True, ) ] ``` ### `get_blueprint() -> Blueprint` (optional) Return a Quart `Blueprint`. The plugin manager mounts it automatically at `/plugins//`. Do not set `url_prefix` on the blueprint itself. ```python def get_blueprint(): from .routes import my_bp return my_bp ``` --- ## Plugin Migrations Plugins manage their own Alembic migrations in `migrations/versions/`. Plugin migrations run after core migrations, so the core schema (including `app_settings`) is always available. Each plugin's initial revision declares a dependency on the core migration head using `depends_on` (not `down_revision`), keeping the plugin on a separate migration branch: ```python # In the plugin's first migration file: depends_on = ("0004_core_head_revision_id",) down_revision = None branch_labels = ("myplugin",) ``` Revision IDs should be prefixed with the plugin name to avoid collisions: ``` myplugin_001_initial.py myplugin_002_add_column.py ``` If a plugin is disabled after its migrations have been applied, its tables remain in the database. Re-enabling it skips already-applied migrations and resumes normally. --- ## Dashboard Widgets A plugin can contribute a dashboard widget by: 1. Adding a `GET /widget` route to its blueprint that returns an HTMX HTML fragment 2. The dashboard template polling that endpoint with HTMX The dashboard (`steward/templates/dashboard/index.html`) currently includes the Traefik widget conditionally based on `traefik_enabled`. To add a new plugin widget, the dashboard route and template both need to be updated to detect and render the new widget. See the Traefik widget implementation as a reference. --- ## Available Context in Plugins Inside scheduled tasks and request handlers, the following is available via `app` or `current_app`: | Attribute | Type | Description | |---|---|---| | `app.config["PLUGINS"]["myplugin"]` | dict | Merged plugin config (defaults + user overrides) | | `app.db_sessionmaker` | async_sessionmaker | Open DB sessions | | `app.logger` | Logger | Standard Python logger | | `app.config["SMTP"]` | dict | Email config | | `app.config["WEBHOOK"]` | dict | Webhook config | During `setup(app)`, do not open DB sessions — the event loop is not yet running. Store the `app` reference and open sessions inside coroutines only.