First-party plugins have shipped inside the image since a7a281c, and the
catalog's only repository (Steward-plugins) answers 404, so Steward polled a
dead URL every minute for nothing. The operator chose to remove the catalog
entirely (#5214 "Review: readable and fast on the live instance").
Removed:
- core/plugin_index.py and the plugin_catalog_refresh startup task;
- the plugins.index_url default, and migration 0035 deletes the stored
plugins.index_url / plugins.repositories rows;
- the Settings -> Plugins repository editor, catalog, refresh, install and
reload routes and their templates;
- the download/checksum/extract install path in plugin_manager, and the
PLUGIN_INSTALL_DIR config it wrote to;
- plugins/index.yaml, docs/plugins/index.yaml.example and the guide's
"Publishing to the Catalog" section (now "Shipping a Plugin").
Kept: enabling and configuring plugins, hot-reload on enable, restart, orphan
cleanup, and the external root (/data/plugins) for operator-mounted plugins.
tests/test_no_plugin_catalog.py guards the routes, default and task.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
418 lines
14 KiB
Markdown
418 lines
14 KiB
Markdown
# Writing a Plugin
|
|
|
|
This guide walks through building a complete plugin from scratch. The Traefik plugin (`plugins/traefik/`) is the reference implementation — read it alongside this guide.
|
|
|
|
---
|
|
|
|
## Step 1: Create the Directory
|
|
|
|
```
|
|
plugins/
|
|
└── myplugin/
|
|
└── __init__.py ← start here
|
|
```
|
|
|
|
The directory name is the plugin's identity. It must match the `name` field in `plugin.yaml` and is used as the URL prefix (`/plugins/myplugin/`) and the Python import name.
|
|
|
|
---
|
|
|
|
## Step 2: Write plugin.yaml
|
|
|
|
```yaml
|
|
name: myplugin
|
|
version: "1.0.0"
|
|
description: "A short description of what this plugin monitors"
|
|
author: "Your Name or GitHub username"
|
|
license: "MIT" # any SPDX identifier, e.g. MIT, Apache-2.0, GPL-3.0
|
|
|
|
# Optional: prevents loading on older app versions
|
|
min_app_version: "0.1.0"
|
|
|
|
# Optional: where the plugin's source lives
|
|
repository_url: "https://github.com/yourname/yourrepo"
|
|
homepage: "https://github.com/yourname/yourrepo/tree/main/myplugin"
|
|
tags:
|
|
- monitoring
|
|
- http
|
|
|
|
# Default config values — users override these via the Settings UI
|
|
# or by writing to the app_settings DB table under "plugin.myplugin"
|
|
config:
|
|
target_url: "http://localhost:9090/metrics"
|
|
scrape_interval_seconds: 60
|
|
```
|
|
|
|
---
|
|
|
|
## Step 3: Define Models (if needed)
|
|
|
|
If your plugin stores data, define SQLAlchemy models using the shared `Base` from `steward.models.base`.
|
|
|
|
```python
|
|
# plugins/myplugin/models.py
|
|
from __future__ import annotations
|
|
import uuid
|
|
from datetime import datetime
|
|
from sqlalchemy import String, Float, DateTime
|
|
from sqlalchemy.orm import Mapped, mapped_column
|
|
from steward.models.base import Base
|
|
|
|
|
|
class MyPluginMetric(Base):
|
|
__tablename__ = "myplugin_metrics"
|
|
|
|
id: Mapped[str] = mapped_column(String, primary_key=True, default=lambda: str(uuid.uuid4()))
|
|
resource_name: Mapped[str] = mapped_column(String, nullable=False, index=True)
|
|
scraped_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
|
my_value: Mapped[float] = mapped_column(Float, nullable=False)
|
|
```
|
|
|
|
---
|
|
|
|
## Step 4: Write Migrations
|
|
|
|
Create `plugins/myplugin/migrations/` with the standard Alembic layout. Copy `env.py` and `script.py.mako` from `plugins/traefik/migrations/` as a starting point — the `env.py` is boilerplate and rarely needs changes.
|
|
|
|
Generate the initial migration:
|
|
|
|
```bash
|
|
# From the project root
|
|
alembic --config alembic.ini revision \
|
|
--autogenerate \
|
|
--head=steward@head \
|
|
--branch-label=myplugin \
|
|
-m "myplugin initial"
|
|
```
|
|
|
|
Edit the generated file to set `depends_on`:
|
|
|
|
```python
|
|
# In the generated revision file:
|
|
depends_on = ("0004_core_head_id",) # the core migration head ID
|
|
down_revision = None
|
|
branch_labels = ("myplugin",)
|
|
|
|
# Prefix the revision ID with the plugin name:
|
|
revision = "myplugin_001_initial"
|
|
```
|
|
|
|
---
|
|
|
|
## Step 5: Write Scheduled Task Logic
|
|
|
|
Keep task logic in a separate file so `__init__.py` stays clean.
|
|
|
|
```python
|
|
# plugins/myplugin/scheduler.py
|
|
from __future__ import annotations
|
|
import logging
|
|
from steward.core.scheduler import ScheduledTask
|
|
from steward.core.alerts import record_metric
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def make_task(app) -> ScheduledTask:
|
|
interval = int(app.config["PLUGINS"]["myplugin"]["scrape_interval_seconds"])
|
|
|
|
async def scrape():
|
|
await _do_scrape(app)
|
|
|
|
return ScheduledTask(
|
|
name="myplugin_scrape",
|
|
coro_factory=scrape,
|
|
interval_seconds=interval,
|
|
run_on_startup=True,
|
|
)
|
|
|
|
|
|
async def _do_scrape(app) -> None:
|
|
from .models import MyPluginMetric
|
|
from datetime import datetime, timezone
|
|
|
|
url = app.config["PLUGINS"]["myplugin"]["target_url"]
|
|
|
|
try:
|
|
value = await _fetch_value(url)
|
|
except Exception:
|
|
logger.exception("myplugin scrape failed (url=%s)", url)
|
|
return
|
|
|
|
now = datetime.now(timezone.utc)
|
|
|
|
async with app.db_sessionmaker() as session:
|
|
async with session.begin():
|
|
# Write to plugin's own history table
|
|
session.add(MyPluginMetric(
|
|
resource_name="my-resource",
|
|
scraped_at=now,
|
|
my_value=value,
|
|
))
|
|
|
|
# Emit to plugin_metrics so alert rules can fire
|
|
await record_metric(
|
|
session=session,
|
|
source_module="myplugin",
|
|
resource_name="my-resource",
|
|
metric_name="my_value",
|
|
value=value,
|
|
)
|
|
|
|
|
|
async def _fetch_value(url: str) -> float:
|
|
import httpx
|
|
async with httpx.AsyncClient() as client:
|
|
resp = await client.get(url)
|
|
resp.raise_for_status()
|
|
return float(resp.text.strip())
|
|
```
|
|
|
|
---
|
|
|
|
## Step 6: Write Routes (if needed)
|
|
|
|
```python
|
|
# plugins/myplugin/routes.py
|
|
from quart import Blueprint, current_app, render_template
|
|
from steward.auth.middleware import require_role
|
|
from steward.models.users import UserRole
|
|
from .models import MyPluginMetric
|
|
|
|
myplugin_bp = Blueprint("myplugin", __name__, template_folder="templates")
|
|
|
|
|
|
@myplugin_bp.get("/")
|
|
@require_role(UserRole.viewer)
|
|
async def index():
|
|
async with current_app.db_sessionmaker() as db:
|
|
from sqlalchemy import select
|
|
result = await db.execute(
|
|
select(MyPluginMetric).order_by(MyPluginMetric.scraped_at.desc()).limit(50)
|
|
)
|
|
rows = result.scalars().all()
|
|
return await render_template("myplugin/index.html", rows=rows)
|
|
|
|
|
|
@myplugin_bp.get("/widget")
|
|
@require_role(UserRole.viewer)
|
|
async def widget():
|
|
"""HTMX fragment for the dashboard widget."""
|
|
async with current_app.db_sessionmaker() as db:
|
|
from sqlalchemy import select
|
|
result = await db.execute(
|
|
select(MyPluginMetric).order_by(MyPluginMetric.scraped_at.desc()).limit(1)
|
|
)
|
|
latest = result.scalar_one_or_none()
|
|
return await render_template("myplugin/widget.html", latest=latest)
|
|
```
|
|
|
|
---
|
|
|
|
## Step 7: Write Templates
|
|
|
|
Templates live in `plugins/myplugin/templates/myplugin/` (the extra nesting avoids naming collisions with core templates).
|
|
|
|
```html
|
|
{# plugins/myplugin/templates/myplugin/index.html #}
|
|
{% extends "base.html" %}
|
|
{% block title %}My Plugin — Steward{% endblock %}
|
|
{% block content %}
|
|
<div class="page-title">My Plugin</div>
|
|
{% for row in rows %}
|
|
<p>{{ row.resource_name }} — {{ row.my_value }}</p>
|
|
{% endfor %}
|
|
{% endblock %}
|
|
```
|
|
|
|
```html
|
|
{# plugins/myplugin/templates/myplugin/widget.html — HTMX fragment, no extends #}
|
|
{% if latest %}
|
|
<div class="ping-row">
|
|
<span>{{ latest.resource_name }}</span>
|
|
<span>{{ latest.my_value }}</span>
|
|
</div>
|
|
{% else %}
|
|
<p class="empty">No data yet.</p>
|
|
{% endif %}
|
|
```
|
|
|
|
---
|
|
|
|
## Step 8: Wire Up __init__.py
|
|
|
|
```python
|
|
# plugins/myplugin/__init__.py
|
|
from __future__ import annotations
|
|
|
|
_app = None
|
|
|
|
|
|
def setup(app) -> None:
|
|
global _app
|
|
_app = app
|
|
from .models import MyPluginMetric # noqa: registers model with Base.metadata
|
|
|
|
|
|
def get_scheduled_tasks() -> list:
|
|
from .scheduler import make_task
|
|
return [make_task(_app)]
|
|
|
|
|
|
def get_blueprint():
|
|
from .routes import myplugin_bp
|
|
return myplugin_bp
|
|
```
|
|
|
|
---
|
|
|
|
## Step 9: Enable the Plugin
|
|
|
|
Plugin config is stored in the `app_settings` DB table. The easiest way to enable a plugin is via the Settings UI, or by inserting directly:
|
|
|
|
```sql
|
|
INSERT INTO app_settings (key, value_json, updated_at)
|
|
VALUES ('plugin.myplugin', '{"enabled": true, "target_url": "http://localhost:9090/metrics"}', now());
|
|
```
|
|
|
|
On next startup, the plugin will be loaded, its migrations applied, and its blueprint and tasks registered.
|
|
|
|
---
|
|
|
|
## Using record_metric()
|
|
|
|
`record_metric()` is how plugins feed data into the alert pipeline. Any metric you write here can be the target of an alert rule created in the UI.
|
|
|
|
```python
|
|
from steward.core.alerts import record_metric
|
|
|
|
# Must be inside an active transaction
|
|
async with session.begin():
|
|
await record_metric(
|
|
session=session,
|
|
source_module="myplugin", # matches alert rule source_module
|
|
resource_name="my-server", # matches alert rule resource_name
|
|
metric_name="response_time_ms", # matches alert rule metric_name
|
|
value=42.3,
|
|
)
|
|
```
|
|
|
|
`record_metric()` writes to `plugin_metrics` and evaluates all matching alert rules inline. Notifications are deferred outside the transaction. It propagates `SQLAlchemyError` on DB failure — don't swallow it.
|
|
|
|
---
|
|
|
|
## Auth in Routes
|
|
|
|
Use the `@require_role` decorator from `steward.auth.middleware`:
|
|
|
|
```python
|
|
from steward.auth.middleware import require_role
|
|
from steward.models.users import UserRole
|
|
|
|
@myplugin_bp.get("/admin-only")
|
|
@require_role(UserRole.admin)
|
|
async def admin_page():
|
|
...
|
|
|
|
@myplugin_bp.get("/read-only")
|
|
@require_role(UserRole.viewer) # viewer, operator, and admin can all access
|
|
async def read_page():
|
|
...
|
|
```
|
|
|
|
Role hierarchy: `admin > operator > viewer`. Requiring `viewer` grants access to all three roles.
|
|
|
|
---
|
|
|
|
## Audit Log Entries
|
|
|
|
A change made through a plugin's pages is audited with `steward.core.audit.log_audit`, naming the thing it changed by `entity_type` and `entity_id`. So the audit page shows that thing's current name, linked to its page, export `get_audit_entities()` from `__init__.py`:
|
|
|
|
```python
|
|
# plugins/myplugin/__init__.py
|
|
def get_audit_entities() -> dict:
|
|
from .models import Target
|
|
return {"myplugin_target": (Target, "/plugins/myplugin/targets/{}")}
|
|
```
|
|
|
|
The model needs `id` and `name` columns. An entity deleted since keeps the name the entry recorded in its detail (`detail={"name": ...}`). Add the action's wording to `ACTIONS` in `steward/audit/service.py` so the row reads as a sentence.
|
|
|
|
---
|
|
|
|
## Agent Tools (MCP)
|
|
|
|
Steward serves an MCP endpoint at `/mcp` that AI clients reach with a personal API token created on the AI access page. A plugin adds tools to it by exporting an optional `register_mcp_tools(register)` from `__init__.py`:
|
|
|
|
```python
|
|
# plugins/myplugin/__init__.py
|
|
def register_mcp_tools(register) -> None:
|
|
from .mcp_tools import list_targets, rescan_target
|
|
from steward.models.users import UserRole
|
|
register(list_targets, role=UserRole.viewer, read_only=True)
|
|
register(rescan_target, role=UserRole.operator, read_only=False)
|
|
```
|
|
|
|
```python
|
|
# plugins/myplugin/mcp_tools.py
|
|
from steward.mcp._context import current_caller, mcp_app
|
|
|
|
|
|
async def list_targets(limit: int = 50) -> dict:
|
|
"""What this tool is for, when to use it over its siblings, what each
|
|
argument's empty value means, and what comes back. This docstring is the
|
|
tool's description: it is all the agent knows about it."""
|
|
app = mcp_app() # Quart's current_app does not exist here
|
|
async with app.db_sessionmaker() as db:
|
|
...
|
|
return {"targets": [...], "total": n, "truncated": n > limit}
|
|
```
|
|
|
|
- `role` and `read_only` are required. A tool is listed and callable only for callers whose role reaches `role`, and only while the plugin is enabled.
|
|
- Tools run outside Quart's request pipeline: use `mcp_app()` and `current_caller()` from `steward.mcp._context`, never `current_app` or `session`.
|
|
- Refuse by raising `ValueError("what was wrong, and what to do instead")`. The agent reads that text. Any other exception reaches it only as a generic failure.
|
|
- Tool names are global across core and every plugin; a name already taken fails registration and the plugin contributes no tools (logged).
|
|
- Hot-reloading the plugin replaces its tools.
|
|
- Follow the response conventions the core tools use (`steward/mcp/tools/_shape.py`): times via `iso()` plus `age_s()`, lists through `bounded()` so a cut is never silent, series through `summarize()`, and lookups by name or id through `pick()`.
|
|
|
|
### Contributing to get_overview
|
|
|
|
`get_overview` is the agent's first call: what is wrong right now, ranked. A plugin adds its own problems to it by exporting an optional `mcp_overview(db)` that returns findings built with `steward.mcp.tools.overview.finding`:
|
|
|
|
```python
|
|
# plugins/myplugin/__init__.py
|
|
async def mcp_overview(db) -> list[dict]:
|
|
from steward.mcp.tools.overview import finding
|
|
return [
|
|
finding("warning", "target_unreachable", t.name, f"{t.name} has not answered for {t.misses} scans",
|
|
since=t.last_ok_at, ids={"target_id": t.id}, next=f'get_target(target="{t.id}")')
|
|
for t in await _unreachable_targets(db)
|
|
]
|
|
```
|
|
|
|
- `severity` is `"critical"` (something is down or about to be) or `"warning"` (degraded, or heading that way). Report problems only; a healthy plugin returns `[]`.
|
|
- It is called only while the plugin is enabled, with its own session. If it raises, the overview still returns, and names the plugin under `unavailable`.
|
|
|
|
---
|
|
|
|
## Shipping a Plugin
|
|
|
|
Steward never downloads plugins. There is no catalog and no install button: the app runs with no outbound network.
|
|
|
|
- **First-party plugins** live in this repository under `plugins/` and ship inside the image, so they version with core.
|
|
- **A third-party plugin** is a folder you put in the external plugin root, `/data/plugins` by default (`STEWARD_PLUGIN_DIR` moves it). Restart Steward and the plugin appears in Settings → Plugins, where you enable it. A bundled plugin of the same name wins over an external one.
|
|
|
|
---
|
|
|
|
## Checklist
|
|
|
|
- [ ] `plugins/myplugin/` directory created
|
|
- [ ] `plugin.yaml` with correct `name` (matches directory), `author`, `license`, `tags`
|
|
- [ ] `__init__.py` exports `setup()` and `get_scheduled_tasks()`
|
|
- [ ] Models import inside `setup()` to register with metadata
|
|
- [ ] Migrations use `depends_on` pointing to core head, not `down_revision`
|
|
- [ ] Revision IDs prefixed with plugin name
|
|
- [ ] `record_metric()` called inside `session.begin()`
|
|
- [ ] Routes use `@require_role` decorator
|
|
- [ ] MCP tools (if any) registered via `register_mcp_tools(register)` with a role and `read_only`
|
|
- [ ] Templates namespaced under `templates/myplugin/`
|
|
- [ ] Plugin enabled in app settings
|