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>
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:
- Directory check —
plugins/<name>/must exist plugin.yamlcheck — file must exist and be valid YAML- Name validation —
plugin.yamlnamefield must match the directory name exactly - Version check — if
min_app_versionis set, the running app version must be >= that value (uses SemVer comparison) - Config merge —
plugin.yamlconfigdefaults are merged with user overrides from app settings; result stored inapp.config["PLUGINS"][name] - Import —
importlib.import_module(name)imports the plugin package (the plugins directory is prepended tosys.path) - Export validation — plugin must export
setupandget_scheduled_tasks setup(app)called- Blueprint registration — if plugin exports
get_blueprint(), the returned blueprint is registered at/plugins/<name>/ - Task registration —
get_scheduled_tasks()results are appended toapp._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:
- Adding a
GET /widgetroute to its blueprint that returns an HTMX HTML fragment - 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.