Files
bvandeusen 88ab5b917e chore: rename project Roundtable → Steward
Renames the Python package directory, CLI command, env var prefix,
docker-compose service/container/image, Postgres role/db, and all
visible branding. Marketing form is "Fabled Steward".

Clean break from the previous rebrand: drops the fabledscryer→roundtable
import shim in __init__.py and the FABLEDSCRYER_* env var fallback in
config.py and migrations/env.py. Env vars are now STEWARD_* only.

Heads-up for existing deployments:
- Postgres user/db renamed fabledscryer → steward in docker-compose.yml.
  Existing volumes need the role/db renamed inside Postgres, or override
  POSTGRES_USER/POSTGRES_DB to keep the old names.
- Host-agent systemd unit is now steward-agent.service. Existing agents
  keep running under the old name; reinstall to switch.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-25 16:20:14 -04:00

6.2 KiB

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 checkplugins/<name>/ must exist
  2. plugin.yaml check — file must exist and be valid YAML
  3. Name validationplugin.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 mergeplugin.yaml config defaults are merged with user overrides from app settings; result stored in app.config["PLUGINS"][name]
  6. Importimportlib.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/<name>/
  10. Task registrationget_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

# 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.

_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.

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/<name>/. Do not set url_prefix on the blueprint itself.

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:

# 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.