get_anomaly gives each occurrence against what is usual at that hour, the metric around the newest one with its normal, what else happened on the resource's host around each occurrence (alert transitions, Ansible runs, other anomalies there and a count elsewhere, plus plugins' own events through a new mcp_events hook: the host agent's maintenance jobs and downtime, Docker's container starts, deaths and OOM kills), and how earlier anomalies on the same metric were judged. get_metric_normal reads the learned slots and the level that would be flagged. review_anomaly takes one id or several, all or nothing, with a note. get_anomaly_settings and (admin) set_anomaly_settings read and change the review window and each normal's floor, as Settings → Thresholds does. metric_series.points takes an optional `until`; core.alerts gains host_of_resource; floor_setting names its floor. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
433 lines
15 KiB
Markdown
433 lines
15 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`.
|
|
|
|
### What happened on a host
|
|
|
|
`get_anomaly` shows, beside a metric anomaly, what else happened on its host around each occurrence. A plugin that records events on a host adds them by exporting an optional `mcp_events(db, host_id, host_name, since, until)` that returns events built with `steward.mcp.tools._events.event`:
|
|
|
|
```python
|
|
# plugins/myplugin/__init__.py
|
|
async def mcp_events(db, host_id, host_name, since, until) -> list[dict]:
|
|
from steward.mcp.tools._events import event
|
|
return [event(r.at, "backup_job", f"Backup {r.name}: {r.state}", until=r.finished_at)
|
|
for r in await _jobs_on(db, host_id, since, until)]
|
|
```
|
|
|
|
- Return what happened between `since` and `until`, oldest first: something that lasted gives `until`. Keep it to tens of rows; the caller keeps the first 40.
|
|
- It is called only while the plugin is enabled, with its own session. If it raises, the anomaly is still returned without the plugin's events. The host agent (maintenance jobs, reboots) and Docker (container starts, deaths, OOM kills) are the examples to read.
|
|
|
|
---
|
|
|
|
## 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
|