refactor(plugins): no plugin catalog; plugins ship in the image or are mounted (#5374)
CI / lint (push) Successful in 3s
CI / integration (push) Failing after 1m12s
CI / unit (push) Successful in 1m25s
CI / publish (push) Skipped

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>
This commit is contained in:
2026-10-08 13:15:55 -04:00
co-authored by Claude Opus 5.5
parent 501a345aed
commit f67678998c
17 changed files with 70 additions and 1258 deletions
-113
View File
@@ -1,113 +0,0 @@
# steward-plugins / index.yaml
#
# This file is the catalog index for the steward plugin repository.
# It is fetched by the app's Settings → Plugins → Plugin Catalog UI.
#
# Steward reads this file from:
# https://git.fabledsword.com/bvandeusen/Steward-plugins/raw/branch/main/index.yaml
#
# After adding or updating a plugin entry, commit and push — the change is
# live immediately for anyone whose app fetches the catalog (cache TTL: 5 min).
#
# REQUIRED fields for each plugin entry:
# name - must match the plugin's directory name exactly
# version - semver string
# download_url - direct URL to a zip file containing the plugin
# checksum_sha256 - SHA-256 hex digest of the zip (leave empty to skip verification)
#
# OPTIONAL fields:
# description, author, license, min_app_version,
# repository_url, homepage, tags
#
# Generating a checksum:
# sha256sum traefik.zip
# shasum -a 256 traefik.zip (macOS)
#
# Download URL conventions:
# Gitea release assets (recommended):
# https://git.fabledsword.com/bvandeusen/Steward-plugins/releases/download/traefik-v1.0.0/traefik.zip
# Upload the zip as a release attachment in Gitea; paste the URL here.
# Source archive tarballs work too but release assets are preferred (smaller, plugin-only).
version: 1
updated: "2026-03-22"
plugins:
- name: http
version: "1.0.0"
description: "Synthetic HTTP endpoint monitoring — status code, response time, content match, TLS expiry"
author: "Steward"
license: "MIT"
min_app_version: "0.1.0"
repository_url: "https://git.fabledsword.com/bvandeusen/Steward-plugins"
homepage: "https://git.fabledsword.com/bvandeusen/Steward-plugins/src/branch/main/http"
download_url: "https://git.fabledsword.com/bvandeusen/Steward-plugins/releases/download/http-v1.0.0/http.zip"
checksum_sha256: ""
tags:
- monitoring
- http
- synthetic
- uptime
- name: docker
version: "1.0.0"
description: "Docker container status, resource usage, and restart tracking via Docker socket"
author: "Steward"
license: "MIT"
min_app_version: "0.1.0"
repository_url: "https://git.fabledsword.com/bvandeusen/Steward-plugins"
homepage: "https://git.fabledsword.com/bvandeusen/Steward-plugins/src/branch/main/docker"
download_url: "https://git.fabledsword.com/bvandeusen/Steward-plugins/releases/download/docker-v1.0.0/docker.zip"
checksum_sha256: ""
tags:
- containers
- docker
- infrastructure
- name: traefik
version: "1.0.0"
description: "Traefik reverse proxy metrics and access log integration"
author: "Steward"
license: "MIT"
min_app_version: "0.1.0"
repository_url: "https://git.fabledsword.com/bvandeusen/Steward-plugins"
homepage: "https://git.fabledsword.com/bvandeusen/Steward-plugins/src/branch/main/traefik"
download_url: "https://git.fabledsword.com/bvandeusen/Steward-plugins/releases/download/traefik-v1.0.0/traefik.zip"
checksum_sha256: "" # fill in after running: sha256sum traefik.zip
tags:
- proxy
- metrics
- access-log
- name: unifi
version: "1.0.0"
description: "UniFi Network controller integration — WAN health, devices, clients, DPI"
author: "Steward"
license: "MIT"
min_app_version: "0.1.0"
repository_url: "https://git.fabledsword.com/bvandeusen/Steward-plugins"
homepage: "https://git.fabledsword.com/bvandeusen/Steward-plugins/src/branch/main/unifi"
download_url: "https://git.fabledsword.com/bvandeusen/Steward-plugins/releases/download/unifi-v1.0.0/unifi.zip"
checksum_sha256: ""
tags:
- network
- unifi
- ubiquiti
- name: host_agent
version: "1.0.0"
description: "Remote Linux host resource monitoring via a lightweight Python push agent"
author: "Steward"
license: "MIT"
min_app_version: "0.1.0"
repository_url: "https://git.fabledsword.com/bvandeusen/Steward-plugins"
homepage: "https://git.fabledsword.com/bvandeusen/Steward-plugins/src/branch/main/host_agent"
download_url: "https://git.fabledsword.com/bvandeusen/Steward-plugins/releases/download/host_agent-v1.0.0/host_agent.zip"
checksum_sha256: ""
tags:
- host
- monitoring
- cpu
- memory
- storage
+5 -77
View File
@@ -28,7 +28,7 @@ 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: shown in the catalog UI
# Optional: where the plugin's source lives
repository_url: "https://github.com/yourname/yourrepo"
homepage: "https://github.com/yourname/yourrepo/tree/main/myplugin"
tags:
@@ -393,83 +393,12 @@ async def mcp_overview(db) -> list[dict]:
---
## Publishing to the Catalog
## Shipping a Plugin
The official plugin catalog is hosted at `https://git.fabledsword.com/bvandeusen/Steward-plugins`. Anyone can submit a plugin by opening a pull request — first-party and third-party plugins are treated identically by the catalog system.
Steward never downloads plugins. There is no catalog and no install button: the app runs with no outbound network.
### Repo layout
```
steward-plugins/
├── index.yaml ← catalog index — the only file Steward fetches
├── myplugin/
│ ├── plugin.yaml
│ ├── __init__.py
│ └── ...
└── .github/
└── workflows/
└── publish.yml ← packages each plugin dir into a zip on release
```
### index.yaml entry
Each plugin needs a corresponding entry in `index.yaml`. See `docs/plugins/index.yaml.example` for the full schema. The minimum required fields are:
```yaml
- name: myplugin
version: "1.0.0"
description: "What this plugin does"
author: "Your Name"
license: "MIT"
min_app_version: "0.1.0"
repository_url: "https://github.com/yourname/yourrepo"
homepage: "https://github.com/yourname/yourrepo/tree/main/myplugin"
download_url: "https://github.com/yourname/yourrepo/releases/download/myplugin-v1.0.0/myplugin.zip"
checksum_sha256: "" # fill in after zipping — see below
tags:
- monitoring
```
### Creating a release zip
The zip must contain the plugin files at the top level OR inside a single directory named after the plugin. Both layouts work:
```
# Layout A — flat (preferred)
myplugin.zip
├── plugin.yaml
├── __init__.py
└── ...
# Layout B — single top-level directory (also accepted, GitHub archive default)
myplugin.zip
└── myplugin/
├── plugin.yaml
└── ...
```
Generate the zip and its checksum:
```bash
cd steward-plugins
zip -r myplugin.zip myplugin/
sha256sum myplugin.zip # paste this into index.yaml checksum_sha256
```
Upload `myplugin.zip` as a GitHub release asset, then update `index.yaml` with the release download URL and checksum.
### How install works
When a user clicks **Install** in Settings → Plugins:
1. The app downloads the zip from `download_url`
2. Verifies the SHA-256 checksum (if provided)
3. Extracts the plugin into its `PLUGIN_DIR`
4. Runs any pending Alembic migrations for the plugin
5. Attempts a **hot-reload** — registers the blueprint and scheduled tasks without restarting
6. If the plugin was previously loaded (blueprint already mounted), a **restart** is required to pick up the new code
Hot-reload works reliably for brand-new plugin installs. Updates to already-active plugins require a restart, which can be triggered from the same settings page.
- **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.
---
@@ -486,4 +415,3 @@ Hot-reload works reliably for brand-new plugin installs. Updates to already-acti
- [ ] 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
- [ ] `index.yaml` entry added with `download_url` and `checksum_sha256`
-74
View File
@@ -1,74 +0,0 @@
# steward-plugins / index.yaml
#
# Plugin catalog for Steward.
# Fetched by the app at: Settings → Plugins → Plugin Catalog
#
# After updating an entry, commit and push — changes are live within 5 minutes
# (the app caches this file in-process for 300 seconds).
#
# See docs/plugins/index.yaml.example in the main app repo for the full schema.
version: 1
updated: "2026-03-22"
plugins:
- name: docker
version: "2.0.0"
description: "Per-host Docker container status + resource usage, collected by the Steward host agent"
author: "Steward"
license: "MIT"
min_app_version: "0.1.0"
repository_url: "https://git.fabledsword.com/bvandeusen/Steward-plugins"
homepage: "https://git.fabledsword.com/bvandeusen/Steward-plugins/src/branch/main/docker"
download_url: "https://git.fabledsword.com/bvandeusen/Steward-plugins/releases/download/docker-v2.0.0/docker.zip"
checksum_sha256: ""
tags:
- containers
- docker
- infrastructure
- name: traefik
version: "1.0.0"
description: "Traefik reverse proxy metrics and access log integration"
author: "Steward"
license: "MIT"
min_app_version: "0.1.0"
repository_url: "https://git.fabledsword.com/bvandeusen/Steward-plugins"
homepage: "https://git.fabledsword.com/bvandeusen/Steward-plugins/src/branch/main/traefik"
download_url: "https://git.fabledsword.com/bvandeusen/Steward-plugins/releases/download/traefik-v1.0.0/traefik.zip"
checksum_sha256: ""
tags:
- proxy
- metrics
- access-log
- name: unifi
version: "1.0.0"
description: "UniFi Network controller integration — WAN health, devices, clients, DPI"
author: "Steward"
license: "MIT"
min_app_version: "0.1.0"
repository_url: "https://git.fabledsword.com/bvandeusen/Steward-plugins"
homepage: "https://git.fabledsword.com/bvandeusen/Steward-plugins/src/branch/main/unifi"
download_url: "https://git.fabledsword.com/bvandeusen/Steward-plugins/releases/download/unifi-v1.0.0/unifi.zip"
checksum_sha256: ""
tags:
- network
- unifi
- ubiquiti
- name: ups
version: "1.0.0"
description: "UPS monitoring via NUT (Network UPS Tools) with Ansible shutdown automation"
author: "Steward"
license: "MIT"
min_app_version: "0.1.0"
repository_url: "https://git.fabledsword.com/bvandeusen/Steward-plugins"
homepage: "https://git.fabledsword.com/bvandeusen/Steward-plugins/src/branch/main/ups"
download_url: "https://git.fabledsword.com/bvandeusen/Steward-plugins/releases/download/ups-v1.0.0/ups.zip"
checksum_sha256: ""
tags:
- ups
- power
- nut
-22
View File
@@ -28,14 +28,12 @@ def create_app(
"secret_key": "test-secret-key",
"db_connect_timeout": DB_CONNECT_TIMEOUT_SECONDS,
"plugin_dirs": ["plugins"],
"plugin_install_dir": "plugins",
}
app.config.update(
SECRET_KEY=bootstrap["secret_key"],
DATABASE_URL=bootstrap["database_url"],
PLUGIN_DIRS=bootstrap["plugin_dirs"],
PLUGIN_INSTALL_DIR=bootstrap["plugin_install_dir"],
TESTING=testing,
# Lax keeps the session cookie off cross-site POSTs and iframes; the
# CSRF token below is the check that does not depend on the browser.
@@ -477,26 +475,6 @@ def _register_core_tasks(app: Quart) -> None:
)
)
# Fetch the plugin catalog from the configured repositories as each copy
# falls due (plugin_index.refresh_due), so Settings → Plugins only reads
# what is stored (Background Jobs ruling, 2026-10-08). The repositories are
# read on every tick, so an edit in Settings is fetched within a minute.
async def run_catalog_refresh():
from .core.plugin_index import refresh_due, repo_urls
from .core.settings import get_all_settings
async with app.db_sessionmaker() as db:
settings = await get_all_settings(db)
await refresh_due(repo_urls(settings))
app._task_registry.append(
ScheduledTask(
name="plugin_catalog_refresh",
coro_factory=run_catalog_refresh,
interval_seconds=60,
run_on_startup=True,
)
)
# Fire due Ansible schedules (recurring playbook runs). Checks every minute;
# each schedule's own interval gates whether it actually runs.
async def run_ansible_schedules():
+1 -3
View File
@@ -55,7 +55,7 @@ def load_bootstrap(config_path: Path | str | None = None) -> dict[str, Any]:
# • bundled — first-party plugins shipped inside the image at repo-root
# `plugins/`; they version atomically with core and are read-only at runtime.
# • external — operator-mounted dir for third-party plugins, persisted in the
# /data volume. Downloads/installs land here, never in the bundled dir.
# /data volume. Steward never downloads plugins; the operator puts them here.
# Bundled is scanned first, so on a name collision the first-party plugin wins.
bundled_plugin_dir = raw.get("plugin_dir", "plugins")
external_plugin_dir = (
@@ -72,8 +72,6 @@ def load_bootstrap(config_path: Path | str | None = None) -> dict[str, Any]:
"secret_key": secret_key,
"db_connect_timeout": db_connect_timeout,
"plugin_dirs": plugin_dirs,
# Installs/downloads target the external (writable, persistent) dir.
"plugin_install_dir": external_plugin_dir or bundled_plugin_dir,
}
-135
View File
@@ -1,135 +0,0 @@
# steward/core/plugin_index.py
"""Remote plugin catalog: fetch, parse, and cache the plugin index.yaml."""
from __future__ import annotations
import logging
import time
from typing import Any
logger = logging.getLogger(__name__)
# The catalog is fetched by the scheduled plugin_catalog_refresh task, never by
# a page load (Background Jobs ruling, 2026-10-08): a repository's copy is
# fetched when it has none or it is older than CATALOG_MAX_AGE, and a repository
# that failed is tried again after RETRY_AFTER. Refresh on Settings → Plugins and
# installing a plugin fetch at once, because the operator asked.
CATALOG_MAX_AGE = 3600 # seconds
RETRY_AFTER = 300 # seconds
# url → (raw index, fetched_at monotonic); url → failed_at monotonic
_url_cache: dict[str, tuple[dict, float]] = {}
_failed_at: dict[str, float] = {}
class CatalogPlugin:
"""One entry from the remote plugin index."""
__slots__ = (
"name", "version", "description", "author",
"min_app_version", "download_url", "checksum_sha256",
"repository_url", "homepage", "license", "tags",
)
def __init__(self, data: dict[str, Any]) -> None:
self.name: str = data.get("name", "")
self.version: str = data.get("version", "0.0.0")
self.description: str = data.get("description", "")
self.author: str = data.get("author", "")
self.min_app_version: str | None = data.get("min_app_version")
self.download_url: str = data.get("download_url", "")
self.checksum_sha256: str = data.get("checksum_sha256", "")
self.repository_url: str = data.get("repository_url", "")
self.homepage: str = data.get("homepage", "")
self.license: str = data.get("license", "")
self.tags: list[str] = data.get("tags", [])
def plugin_repos(settings: dict) -> list[dict]:
"""The configured plugin repositories, [{"name", "url"}], from settings.
Migrates the legacy plugins.index_url single-URL field to the list format
on first access if plugins.repositories is not yet set.
"""
repos = settings.get("plugins.repositories")
if repos and isinstance(repos, list):
return repos
legacy = settings.get("plugins.index_url", "").strip()
if legacy:
return [{"name": "Default", "url": legacy}]
return []
def repo_urls(settings: dict) -> list[str]:
return [r["url"].strip() for r in plugin_repos(settings) if (r.get("url") or "").strip()]
def _plugins(raw: dict) -> list[CatalogPlugin]:
return [CatalogPlugin(p) for p in raw.get("plugins", [])]
async def _fetch_one(url: str) -> bool:
"""Fetch one index URL into the cache. True when it was fetched."""
import httpx
import yaml
try:
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.get(url)
resp.raise_for_status()
raw = yaml.safe_load(resp.text) or {}
except Exception as exc:
# The tick retries every RETRY_AFTER seconds, so a repository that
# answers 404 or is unreachable is one line each time, not a traceback.
logger.warning("Plugin catalog not fetched from %s: %s", url, exc)
_failed_at[url] = time.monotonic()
return False
_url_cache[url] = (raw, time.monotonic())
_failed_at.pop(url, None)
logger.info("Plugin catalog fetched from %s: %d plugin(s)", url, len(raw.get("plugins", [])))
return True
def _merge(urls: list[str]) -> list[CatalogPlugin]:
"""Plugins from the cached copies, deduplicated by name: the first
repository in the list wins."""
seen: dict[str, CatalogPlugin] = {}
for url in urls:
if url in _url_cache:
for plugin in _plugins(_url_cache[url][0]):
seen.setdefault(plugin.name, plugin)
return list(seen.values())
def cached_catalog(urls: list[str]) -> tuple[list[CatalogPlugin], list[str]]:
"""(the merged catalog from what is cached, the URLs with no copy yet).
Reads the cache only: this is what a page shows."""
return _merge(urls), [u for u in urls if u not in _url_cache]
async def fetch_catalog(urls: list[str]) -> list[CatalogPlugin]:
"""Fetch every URL now, then the merged catalog (a failed URL keeps its
last copy). For an operator's Refresh or install, not for a page load."""
for url in urls:
await _fetch_one(url)
return _merge(urls)
async def refresh_due(urls: list[str], now: float | None = None) -> int:
"""The scheduled task's tick: fetch each URL with no copy, or one older
than CATALOG_MAX_AGE, unless it failed within RETRY_AFTER. Returns how
many were fetched."""
now = time.monotonic() if now is None else now
fetched = 0
for url in urls:
cached = _url_cache.get(url)
if cached and now - cached[1] < CATALOG_MAX_AGE:
continue
if url in _failed_at and now - _failed_at[url] < RETRY_AFTER:
continue
fetched += await _fetch_one(url)
return fetched
def clear_catalog_cache() -> None:
"""Forget every cached copy and failure, so the next tick of the scheduled
task fetches the repositories again (after they are edited)."""
_url_cache.clear()
_failed_at.clear()
+1 -180
View File
@@ -1,11 +1,8 @@
# steward/core/plugin_manager.py
from __future__ import annotations
import hashlib
import logging
import os
import shutil
import sys
import zipfile
from pathlib import Path
from typing import TYPE_CHECKING
@@ -290,184 +287,8 @@ def load_plugins(app: "Quart") -> None:
logger.info("Plugin %r loaded (v%s)", name, meta.get("version", "?"))
# Bounds on what an install may bring in: the download itself, and what it
# unpacks to (a small zip can expand to fill the disk).
MAX_PLUGIN_DOWNLOAD_BYTES = 50 * 1024 * 1024
MAX_PLUGIN_UNPACKED_BYTES = 200 * 1024 * 1024
class PluginInstallError(Exception):
"""An install refused or failed; the message is shown to the admin."""
def check_plugin_name(name: str) -> None:
"""A plugin is imported by its folder name, so the name must be a plain
Python identifier — which also keeps it from naming a path."""
if not (name.isascii() and name.isidentifier()):
raise PluginInstallError(f"{name!r} is not a valid plugin name")
def verify_checksum(content: bytes, expected_sha256: str) -> None:
"""Refuse a download whose sha256 is missing from the catalog or differs."""
if not expected_sha256:
raise PluginInstallError(
"The catalog lists no checksum for this plugin, so Steward cannot "
"tell it is the file the catalog meant. Not installed.")
actual = hashlib.sha256(content).hexdigest()
if actual != expected_sha256.strip().lower():
raise PluginInstallError(f"Checksum mismatch: expected {expected_sha256}, got {actual}")
def extract_plugin(content: bytes, name: str, dest: Path) -> None:
"""Unpack a plugin zip into `dest`, which must not exist yet.
One top-level folder whose name starts with the plugin name is stripped:
name/ (clean plugin zip)
name-v1.0.0/ (release tag archive)
otherwise the zip is unpacked as it stands. Every member must land inside
`dest` — an absolute path or a `..` that climbs out refuses the whole zip —
and the unpacked total is capped.
"""
import io
root = dest.resolve()
with zipfile.ZipFile(io.BytesIO(content)) as zf:
infos = zf.infolist()
if sum(i.file_size for i in infos) > MAX_PLUGIN_UNPACKED_BYTES:
raise PluginInstallError(
f"Unpacks to more than {MAX_PLUGIN_UNPACKED_BYTES // (1024 * 1024)} MB")
top_level = {i.filename.split("/")[0] for i in infos}
prefix = ""
if len(top_level) == 1:
candidate = next(iter(top_level))
if candidate.lower().startswith(name.lower()) and any(
i.filename.startswith(candidate + "/") for i in infos):
prefix = candidate + "/"
planned: list[tuple[zipfile.ZipInfo, Path]] = []
for info in infos:
if prefix and not info.filename.startswith(prefix):
continue
rel = info.filename[len(prefix):]
if not rel:
continue
target = (root / rel).resolve()
if not target.is_relative_to(root) or target == root:
raise PluginInstallError(f"Refused: {info.filename!r} points outside the plugin folder")
planned.append((info, target))
root.mkdir(parents=True)
for info, target in planned:
if info.is_dir():
target.mkdir(parents=True, exist_ok=True)
continue
target.parent.mkdir(parents=True, exist_ok=True)
with zf.open(info) as src, open(target, "wb") as out:
shutil.copyfileobj(src, out)
async def _download(url: str) -> bytes:
import httpx
chunks: list[bytes] = []
size = 0
async with httpx.AsyncClient(timeout=60, follow_redirects=True) as client:
async with client.stream("GET", url) as resp:
resp.raise_for_status()
async for chunk in resp.aiter_bytes():
size += len(chunk)
if size > MAX_PLUGIN_DOWNLOAD_BYTES:
raise PluginInstallError(
f"Download is larger than {MAX_PLUGIN_DOWNLOAD_BYTES // (1024 * 1024)} MB")
chunks.append(chunk)
return b"".join(chunks)
async def download_and_install_plugin(
app: "Quart",
name: str,
download_url: str,
expected_sha256: str,
) -> tuple[bool, str]:
"""Download a plugin zip named by the catalog, verify its checksum, and
unpack it into the install dir, replacing any earlier copy whole.
The URL and checksum must come from the catalog entry, never from the
request. Returns (success, message). Does NOT hot-reload — call
hot_reload_plugin() after this to activate a new plugin without a restart.
"""
# Downloads always land in the external (writable, persistent) install dir,
# never the read-only bundled dir.
plugin_dir = Path(app.config["PLUGIN_INSTALL_DIR"]).resolve()
# Ensure the install dir is on sys.path (may not be set yet if no plugins
# were enabled at startup)
plugin_dir_str = str(plugin_dir)
if plugin_dir_str not in sys.path:
sys.path.insert(0, plugin_dir_str)
try:
check_plugin_name(name)
if not download_url:
raise PluginInstallError("The catalog lists no download URL for this plugin")
logger.info("Downloading plugin %r from %s", name, download_url)
try:
content = await _download(download_url)
except PluginInstallError:
raise
except Exception as exc:
raise PluginInstallError(f"Download failed: {exc}") from exc
verify_checksum(content, expected_sha256)
# Unpack beside the live copy, then swap: a failed or refused zip
# leaves the installed plugin untouched, and an update drops files the
# new version no longer ships.
plugin_dir.mkdir(parents=True, exist_ok=True)
dest = plugin_dir / name
staging = plugin_dir / f".{name}.installing"
old = plugin_dir / f".{name}.replaced"
for leftover in (staging, old):
shutil.rmtree(leftover, ignore_errors=True)
try:
extract_plugin(content, name, staging)
except PluginInstallError:
raise
except Exception as exc:
raise PluginInstallError(f"Extraction failed: {exc}") from exc
try:
if dest.exists():
dest.rename(old)
staging.rename(dest)
except OSError as exc:
if old.exists() and not dest.exists():
old.rename(dest) # put the previous version back
raise PluginInstallError(f"Could not move the plugin into place: {exc}") from exc
shutil.rmtree(old, ignore_errors=True)
except PluginInstallError as exc:
shutil.rmtree(plugin_dir / f".{name}.installing", ignore_errors=True)
logger.error("Plugin %r: %s", name, exc)
return False, str(exc)
# Run migrations for the newly installed plugin.
# Include all plugin migration dirs so Alembic can resolve any previously-stamped
# revisions from other plugins already in alembic_version.
try:
mdir = plugin_dir / name / "migrations"
if mdir.exists():
from steward.core.migration_runner import (
run_plugin_migrations,
discover_all_in,
)
# Span every plugin root so Alembic can resolve bundled-plugin
# revisions already stamped in alembic_version.
all_dirs = discover_all_in([Path(d).resolve() for d in app.config["PLUGIN_DIRS"]])
run_plugin_migrations(app.config["DATABASE_URL"], all_dirs)
except Exception:
logger.exception("Plugin %r: migration failed after install", name)
return False, "Plugin extracted but migrations failed — check logs"
logger.info("Plugin %r installed successfully", name)
return True, "Installed"
def hot_reload_plugin(app: "Quart", name: str) -> tuple[bool, str]:
"""Activate a newly installed plugin without restarting the app.
"""Activate a plugin that was just enabled without restarting the app.
This works for plugins that were NOT previously loaded. For plugins that
are already registered (blueprint already mounted), a full restart is
-1
View File
@@ -118,7 +118,6 @@ DEFAULTS: dict[str, Any] = {
# the agent's ~30s cadence, then roll up to hourly averages kept much longer.
"metrics.retention.raw_days": 7,
"metrics.retention.rollup_days": 90,
"plugins.index_url": "https://git.fabledsword.com/bvandeusen/Steward-plugins/raw/branch/main/index.yaml",
# Default-enabled plugins. These are the generic, non-vendor-specific
# bundled plugins (protocols/standards, not a single product) — useful on
# almost any install, so a fresh deployment comes up monitoring rather than
@@ -0,0 +1,29 @@
"""Drop the plugin catalog's stored settings
Steward no longer has a plugin catalog (#5374): first-party plugins ship in the
image and a third-party plugin is a folder the operator mounts, so nothing reads
`plugins.index_url` or `plugins.repositories` any more. The removed feature
takes its rows with it, as 0025 did for the http plugin.
Revision ID: 0035_drop_plugin_catalog_settings
Revises: 0034_user_time_zone
Create Date: 2026-10-08
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "0035_drop_plugin_catalog_settings"
down_revision: Union[str, None] = "0034_user_time_zone"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.execute(sa.text(
"DELETE FROM app_settings WHERE key IN ('plugins.index_url', 'plugins.repositories')"))
def downgrade() -> None:
# Deliberately empty: nothing would read the rows.
pass
+2 -200
View File
@@ -7,7 +7,6 @@ from quart import Blueprint, current_app, render_template, request, redirect, ur
from steward.auth.middleware import require_role
from steward.core.audit import audit_request, log_audit
from steward.core.plugin_index import plugin_repos, repo_urls
from steward.core.logging_setup import LEVELS as LOG_LEVELS, set_log_level
from steward.models.users import UserRole
from steward.core.settings import (
@@ -605,17 +604,6 @@ async def send_report_now():
)
# ── Plugin repository helpers ─────────────────────────────────────────────────
async def _save_plugin_repos(db, repos: list[dict]) -> None:
await set_setting(db, "plugins.repositories", repos)
async def _plugin_repos_partial(settings: dict):
repos = plugin_repos(settings)
return await render_template("settings/_plugin_repos.html", repos=repos)
def _build_plugin_cfg_from_form(plugin: dict, form) -> dict:
"""Extract config values for one plugin from a form submission."""
name = plugin["_dir"]
@@ -678,11 +666,10 @@ async def plugins():
for p in discovered:
p["_kind"] = p.get("kind") or (
"capability" if p["_dir"] in CAPABILITY_PLUGINS else "integration")
repos = plugin_repos(settings)
# Config left behind by plugins that are no longer installed. Surfaced for an
# explicit operator decision rather than cleaned up automatically: an external
# plugin can be missing because /data/plugins isn't mounted or an install
# failed, and silently dropping its row would destroy stored credentials.
# plugin can be missing because /data/plugins isn't mounted, and silently
# dropping its row would destroy stored credentials.
#
# Load failures are folded in so every plugin counted by the admin banner has
# a row here. A plugin that failed BECAUSE it has no directory is not in
@@ -700,7 +687,6 @@ async def plugins():
integrations=[p for p in discovered if p["_kind"] != "capability"],
discovered_plugins=discovered,
orphaned_plugins=orphans,
repos=repos,
settings=settings,
)
@@ -723,8 +709,6 @@ async def plugin_orphan_remove(name: str):
if removed:
await _reload_app_config()
from steward.core.plugin_index import clear_catalog_cache
clear_catalog_cache()
await log_audit(
current_app, session.get("user_id"), session.get("username", ""),
"plugin.settings_removed", entity_type="plugin", entity_id=name)
@@ -772,8 +756,6 @@ async def save_plugin_detail(name: str):
await set_setting(db, f"plugin.{name}", plugin_cfg)
await _reload_app_config()
from steward.core.plugin_index import clear_catalog_cache
clear_catalog_cache()
new_enabled = plugin_cfg.get("enabled", False)
if old_enabled != new_enabled:
@@ -787,186 +769,6 @@ async def save_plugin_detail(name: str):
return redirect(url_for("settings.plugin_detail", name=name))
# ── Plugin repository HTMX routes ─────────────────────────────────────────────
@settings_bp.post("/plugins/repos/add")
@require_role(UserRole.admin)
async def plugin_repos_add():
form = await request.form
async with current_app.db_sessionmaker() as db:
settings = await get_all_settings(db)
repos = plugin_repos(settings)
url = form.get("url", "").strip()
repo_name = form.get("name", "").strip() or url
if url:
repos.append({"name": repo_name, "url": url})
async with current_app.db_sessionmaker() as db:
async with db.begin():
await _save_plugin_repos(db, repos)
from steward.core.plugin_index import clear_catalog_cache
clear_catalog_cache()
return await _plugin_repos_partial({"plugins.repositories": repos})
@settings_bp.post("/plugins/repos/<int:idx>/remove")
@require_role(UserRole.admin)
async def plugin_repos_remove(idx: int):
async with current_app.db_sessionmaker() as db:
settings = await get_all_settings(db)
repos = plugin_repos(settings)
if 0 <= idx < len(repos):
repos.pop(idx)
async with current_app.db_sessionmaker() as db:
async with db.begin():
await _save_plugin_repos(db, repos)
from steward.core.plugin_index import clear_catalog_cache
clear_catalog_cache()
return await _plugin_repos_partial({"plugins.repositories": repos})
@settings_bp.post("/plugins/repos/<int:idx>/save")
@require_role(UserRole.admin)
async def plugin_repos_save(idx: int):
form = await request.form
async with current_app.db_sessionmaker() as db:
settings = await get_all_settings(db)
repos = plugin_repos(settings)
if 0 <= idx < len(repos):
repos[idx] = {
"name": form.get("name", "").strip() or repos[idx]["name"],
"url": form.get("url", "").strip() or repos[idx]["url"],
}
async with current_app.db_sessionmaker() as db:
async with db.begin():
await _save_plugin_repos(db, repos)
from steward.core.plugin_index import clear_catalog_cache
clear_catalog_cache()
return await _plugin_repos_partial({"plugins.repositories": repos})
# ── Plugin catalog (HTMX partial) ─────────────────────────────────────────────
@settings_bp.get("/plugins/catalog/")
@require_role(UserRole.admin)
async def plugins_catalog():
"""HTMX partial: the plugin catalog as the scheduled plugin_catalog_refresh
task last fetched it. A page load never reaches a repository (Background
Jobs ruling, 2026-10-08); Refresh does, because the operator asked."""
return await _catalog_partial(fetch=False)
@settings_bp.post("/plugins/catalog/refresh")
@require_role(UserRole.admin)
async def plugins_catalog_refresh():
"""HTMX partial: fetch every configured repository now, then the catalog."""
return await _catalog_partial(fetch=True)
async def _catalog_partial(fetch: bool):
from steward.core.plugin_index import cached_catalog, fetch_catalog
from steward.core.plugin_manager import _LOADED_PLUGINS
async with current_app.db_sessionmaker() as db:
settings = await get_all_settings(db)
urls = repo_urls(settings)
if not urls:
return await render_template(
"settings/_catalog_partial.html",
catalog=[], installed_names=set(), installed_versions={}, loaded_names=set(),
error="No plugin repositories configured.",
)
if fetch:
await fetch_catalog(urls)
catalog, missing = cached_catalog(urls)
error = None
if missing:
error = (f"Not fetched yet: {', '.join(missing)}. Steward fetches each repository within a "
"minute of starting or of a change here, then hourly; Refresh fetches now.")
elif not catalog:
error = "The configured repositories list no plugins."
discovered = _discover_plugins()
return await render_template(
"settings/_catalog_partial.html",
catalog=catalog,
installed_names={p["_dir"] for p in discovered},
installed_versions={p["_dir"]: p.get("version", "0.0.0") for p in discovered},
loaded_names=_LOADED_PLUGINS,
error=error,
)
# ── Plugin install ─────────────────────────────────────────────────────────────
@settings_bp.post("/plugins/install/<name>")
@require_role(UserRole.admin)
async def install_plugin(name: str):
"""Download and install a plugin from the catalog, then hot-reload it.
Only the plugin's name comes from the request. The download URL and
checksum are read from the configured catalogs, so a request cannot point
Steward at a file of its own choosing.
"""
from steward.core.plugin_index import fetch_catalog
from steward.core.plugin_manager import download_and_install_plugin, hot_reload_plugin
async with current_app.db_sessionmaker() as db:
settings = await get_all_settings(db)
entry = next((p for p in await fetch_catalog(repo_urls(settings)) if p.name == name), None)
if entry is None:
return await render_template(
"settings/_install_result.html",
name=name, success=False,
message="No configured plugin repository lists this plugin.",
)
ok, msg = await download_and_install_plugin(
current_app._get_current_object(), entry.name, entry.download_url, entry.checksum_sha256
)
await log_audit(
current_app, session.get("user_id"), session.get("username", ""),
"plugin.install" if ok else "plugin.install_failed", entity_type="plugin",
entity_id=name, detail={"version": entry.version, "url": entry.download_url,
"message": msg})
if not ok:
return await render_template(
"settings/_install_result.html",
name=name, success=False, message=msg,
)
# Attempt hot-reload
ok2, msg2 = hot_reload_plugin(current_app._get_current_object(), name)
if ok2:
return await render_template(
"settings/_install_result.html",
name=name, success=True,
message=f"Installed and activated. {msg2}",
)
else:
return await render_template(
"settings/_install_result.html",
name=name, success=True,
message=f"Installed. {msg2}",
restart_required=True,
)
# ── Plugin hot-reload ──────────────────────────────────────────────────────────
@settings_bp.post("/plugins/reload/<name>")
@require_role(UserRole.admin)
async def reload_plugin(name: str):
"""Hot-reload a plugin that was installed but not yet active."""
from steward.core.plugin_manager import hot_reload_plugin
ok, msg = hot_reload_plugin(current_app._get_current_object(), name)
return await render_template(
"settings/_install_result.html",
name=name, success=ok, message=msg,
restart_required=(not ok and "restart" in msg.lower()),
)
# ── App restart ───────────────────────────────────────────────────────────────
@settings_bp.post("/plugins/restart/")
@@ -1,114 +0,0 @@
{# settings/_catalog_partial.html — HTMX partial for plugin catalog #}
<div id="plugin-catalog">
{% if error %}
<div style="color:var(--text-muted);font-size:0.85rem;padding:0.5rem 0.75rem;background:var(--bg);border:1px solid var(--border);border-radius:6px;margin-bottom:0.75rem;">
{{ error }}
</div>
{% endif %}
{% if catalog %}
<div style="display:grid;gap:0.75rem;">
{% for plugin in catalog %}
{% set is_installed = plugin.name in installed_names %}
{% set is_loaded = plugin.name in loaded_names %}
{% set installed_ver = installed_versions.get(plugin.name, "") %}
{# update_available: catalog version is strictly newer than what's on disk #}
{% set update_available = is_installed and installed_ver and installed_ver != plugin.version %}
<div class="card" style="margin-bottom:0;padding:1rem 1.25rem;{% if update_available %}border-color:var(--gold);{% endif %}">
<div style="display:flex;align-items:flex-start;gap:1rem;">
<div style="flex:1;min-width:0;">
<div style="display:flex;align-items:center;gap:0.6rem;flex-wrap:wrap;">
<span style="font-weight:500;color:var(--text);font-size:0.9rem;">{{ plugin.name }}</span>
<span style="font-size:0.75rem;color:var(--text-muted);">v{{ plugin.version }}</span>
{% if update_available %}
<span class="badge badge-gold" style="font-size:0.68rem;">
Update available — installed v{{ installed_ver }}
</span>
{% elif is_loaded %}
<span class="badge badge-green" style="font-size:0.68rem;">Active</span>
{% elif is_installed %}
<span class="badge" style="background:var(--bg-elevated);color:var(--text-muted);font-size:0.68rem;">Installed</span>
{% endif %}
{% for tag in plugin.tags %}
<span style="font-size:0.68rem;padding:0.1em 0.4em;background:var(--bg-elevated);color:var(--text-muted);border-radius:3px;">{{ tag }}</span>
{% endfor %}
</div>
{% if plugin.description %}
<div style="font-size:0.82rem;color:var(--text-muted);margin-top:0.25rem;">{{ plugin.description }}</div>
{% endif %}
<div style="font-size:0.75rem;color:var(--text-dim);margin-top:0.15rem;display:flex;gap:0.75rem;align-items:center;flex-wrap:wrap;">
{% if plugin.author %}<span>by {{ plugin.author }}</span>{% endif %}
{% if plugin.license %}<span style="color:var(--text-muted);">{{ plugin.license }}</span>{% endif %}
{% if plugin.min_app_version %}<span style="color:var(--text-muted);">requires v{{ plugin.min_app_version }}+</span>{% endif %}
</div>
</div>
<div style="display:flex;gap:0.5rem;flex-shrink:0;align-items:center;">
{% if plugin.homepage %}
<a href="{{ plugin.homepage }}" target="_blank" rel="noopener"
class="btn btn-ghost btn-sm" style="font-size:0.78rem;">Docs</a>
{% elif plugin.repository_url %}
<a href="{{ plugin.repository_url }}" target="_blank" rel="noopener"
class="btn btn-ghost btn-sm" style="font-size:0.78rem;">Source</a>
{% endif %}
{% if (update_available or not is_installed) and not plugin.checksum_sha256 %}
{# Steward installs only what it can verify against the catalog's checksum #}
<span style="font-size:0.75rem;color:var(--text-muted);max-width:14rem;text-align:right;"
title="Add checksum_sha256 to this plugin's entry in the repository's index.yaml">
Can't {{ "update" if update_available else "install" }}: the catalog lists no checksum
</span>
{% elif update_available %}
{# Installed but outdated — offer update (download + restart) #}
<form method="post"
action="/settings/plugins/install/{{ plugin.name }}"
hx-post="/settings/plugins/install/{{ plugin.name }}"
hx-target="#catalog-result-{{ plugin.name }}"
hx-swap="outerHTML"
style="margin:0;">{{ csrf_field() }}
<button type="submit" class="btn btn-warn btn-sm" style="font-size:0.78rem;">Update</button>
</form>
{% elif is_loaded %}
{# Active and up to date — no action needed #}
{% elif is_installed %}
{# Installed but not active — offer hot-reload #}
<form method="post"
action="/settings/plugins/reload/{{ plugin.name }}"
hx-post="/settings/plugins/reload/{{ plugin.name }}"
hx-target="#catalog-result-{{ plugin.name }}"
hx-swap="outerHTML"
style="margin:0;">{{ csrf_field() }}
<button type="submit" class="btn btn-sm" style="font-size:0.78rem;">Activate</button>
</form>
{% else %}
{# Not installed — offer install #}
<form method="post"
action="/settings/plugins/install/{{ plugin.name }}"
hx-post="/settings/plugins/install/{{ plugin.name }}"
hx-target="#catalog-result-{{ plugin.name }}"
hx-swap="outerHTML"
style="margin:0;">{{ csrf_field() }}
<button type="submit" class="btn btn-sm" style="font-size:0.78rem;">Install</button>
</form>
{% endif %}
</div>
</div>
{# Inline result placeholder — replaced by _install_result.html after action #}
<div id="catalog-result-{{ plugin.name }}"></div>
</div>
{% endfor %}
</div>
{% elif not error %}
<div style="color:var(--text-muted);font-size:0.85rem;">No plugins listed in the catalog.</div>
{% endif %}
</div>
@@ -1,20 +0,0 @@
{# settings/_install_result.html — inline result after install/reload action #}
<div id="catalog-result-{{ name }}"
style="margin-top:0.6rem;padding:0.5rem 0.75rem;border-radius:5px;font-size:0.82rem;
background:{% if success %}var(--green-dim){% else %}var(--red-dim){% endif %};
color:{% if success %}var(--green){% else %}#ff8090{% endif %};">
{{ message }}
{% if restart_required %}
&nbsp;·&nbsp;
<form method="post" action="/settings/plugins/restart/"
hx-post="/settings/plugins/restart/"
hx-target="#restart-banner"
hx-swap="outerHTML"
style="display:inline;margin:0;">{{ csrf_field() }}
<button type="submit"
style="background:none;border:none;padding:0;color:var(--yellow);cursor:pointer;font-size:0.82rem;text-decoration:underline;">
Restart now
</button>
</form>
{% endif %}
</div>
@@ -1,99 +0,0 @@
{# settings/_plugin_repos.html — HTMX partial for plugin repository management #}
<div id="plugin-repos">
{% if repos %}
<div style="display:grid;gap:0.6rem;margin-bottom:0.6rem;">
{% for repo in repos %}
{% set idx = loop.index0 %}
<div class="card" style="padding:0;overflow:visible;">
{# ── View row ────────────────────────────────────────────────────────── #}
<div id="plugin-repo-view-{{ idx }}"
style="display:flex;align-items:center;gap:0.75rem;padding:0.85rem 1rem;">
<div style="flex:1;min-width:0;">
<div style="font-weight:500;font-size:0.88rem;">{{ repo.name or repo.url }}</div>
<div style="font-size:0.77rem;color:var(--text-muted);margin-top:0.1rem;
white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">{{ repo.url }}</div>
</div>
<div style="display:flex;gap:0.4rem;flex-shrink:0;">
<button class="btn btn-ghost btn-sm" style="font-size:0.78rem;"
onclick="toggleRepoEdit({{ idx|int }})">Edit</button>
<button class="btn btn-danger btn-sm" style="font-size:0.78rem;"
hx-post="/settings/plugins/repos/{{ idx }}/remove"
hx-target="#plugin-repos"
hx-swap="outerHTML"
hx-confirm="Remove repository '{{ repo.name or repo.url }}'?">×</button>
</div>
</div>
{# ── Inline edit form ──────────────────────────────────────────────── #}
<div id="plugin-repo-edit-{{ idx }}"
style="display:none;border-top:1px solid var(--border);padding:1rem;">
<form hx-post="/settings/plugins/repos/{{ idx }}/save"
hx-target="#plugin-repos"
hx-swap="outerHTML">
<div style="display:grid;grid-template-columns:1fr 2fr;gap:0.6rem;">
<div class="form-group" style="margin:0;">
<label style="font-size:0.8rem;">Name</label>
<input type="text" name="name" value="{{ repo.name }}" placeholder="My Plugins">
</div>
<div class="form-group" style="margin:0;">
<label style="font-size:0.8rem;">Index URL</label>
<input type="url" name="url" value="{{ repo.url }}"
placeholder="https://example.com/plugins/index.yaml" required>
</div>
</div>
<div style="display:flex;gap:0.5rem;margin-top:0.75rem;">
<button type="submit" class="btn btn-sm">Save</button>
<button type="button" class="btn btn-ghost btn-sm"
onclick="toggleRepoEdit({{ idx|int }})">Cancel</button>
</div>
</form>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="card" style="color:var(--text-muted);font-size:0.88rem;text-align:center;padding:1.5rem;margin-bottom:0.6rem;">
No repositories charted. Add one below.
</div>
{% endif %}
{# ── Add repository ────────────────────────────────────────────────────────── #}
<details class="card" style="padding:0;overflow:hidden;">
<summary style="display:flex;align-items:center;gap:0.75rem;padding:0.85rem 1rem;
cursor:pointer;list-style:none;user-select:none;">
<span style="flex:1;font-weight:500;font-size:0.875rem;color:var(--text);">Add Repository</span>
<span class="btn btn-sm" style="pointer-events:none;">New ▾</span>
</summary>
<form hx-post="/settings/plugins/repos/add"
hx-target="#plugin-repos"
hx-swap="outerHTML"
style="padding:1rem;padding-top:0;border-top:1px solid var(--border);">
<div style="display:grid;grid-template-columns:1fr 2fr;gap:0.6rem;padding-top:1rem;">
<div class="form-group" style="margin:0;">
<label style="font-size:0.8rem;">Name</label>
<input type="text" name="name" placeholder="My Plugins">
</div>
<div class="form-group" style="margin:0;">
<label style="font-size:0.8rem;">Index URL</label>
<input type="url" name="url"
placeholder="https://example.com/plugins/index.yaml" required>
</div>
</div>
<button type="submit" class="btn btn-sm" style="margin-top:0.75rem;width:100%;">
Add Repository
</button>
</form>
</details>
</div>
<script>
function toggleRepoEdit(idx) {
var edit = document.getElementById('plugin-repo-edit-' + idx);
edit.style.display = edit.style.display === 'none' ? 'block' : 'none';
}
</script>
+5 -40
View File
@@ -76,7 +76,8 @@
<div class="section-title" style="margin-bottom:0.4rem;">Integrations</div>
<p style="color:var(--text-muted);font-size:0.82rem;margin:0 0 0.75rem;max-width:720px;">
External systems Steward pulls data from (e.g. Traefik, UniFi). Enable and configure each one
to surface its own dashboard. Off until you set them up.
to surface its own dashboard. Off until you set them up. A plugin of your own goes in
<code>/data/plugins</code>; restart and it appears here.
</p>
{% if integrations %}
<div style="display:grid;gap:0.6rem;max-width:720px;margin-bottom:1.5rem;">
@@ -84,7 +85,7 @@
</div>
{% else %}
<div class="card" style="color:var(--text-muted);font-size:0.9rem;margin-bottom:1.5rem;">
No integrations installed. Browse the catalog below.
No integrations installed.
</div>
{% endif %}
@@ -97,8 +98,8 @@
<p style="color:var(--text-muted);font-size:0.82rem;margin:0 0 0.75rem;">
Plugins Steward has settings for, or tried to load, but cannot find on disk — including
anything counted by the "failed to load" banner. This usually means the plugin was
removed, but it also happens when an external plugin directory isn't mounted or an
install failed part-way, so nothing is deleted automatically. Removing an entry
removed, but it also happens when an external plugin directory isn't mounted, so
nothing is deleted automatically. Removing an entry
discards that plugin's saved configuration, including any credentials.
</p>
<div style="display:grid;gap:0.6rem;">
@@ -145,40 +146,4 @@
</div>
{% endif %}
{# ── Plugin Repositories ───────────────────────────────────────────────────── #}
<div style="margin-bottom:2rem;max-width:720px;">
<div class="section-title" style="margin-bottom:0.75rem;">Plugin Repositories</div>
<p style="color:var(--text-muted);font-size:0.84rem;margin-bottom:0.75rem;">
Repositories are remote <code>index.yaml</code> files that Steward checks for available
and updated plugins. Add repos from other developers to expand the plugin catalog.
</p>
{% include "settings/_plugin_repos.html" %}
</div>
{# ── Plugin Catalog ────────────────────────────────────────────────────────── #}
<div style="max-width:720px;">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:0.75rem;">
<div class="section-title">Plugin Catalog</div>
<button class="btn btn-ghost btn-sm" style="font-size:0.78rem;"
hx-post="/settings/plugins/catalog/refresh"
hx-target="#plugin-catalog"
hx-swap="outerHTML">
Refresh
</button>
</div>
{% if repos %}
<div id="plugin-catalog"
hx-get="/settings/plugins/catalog/"
hx-trigger="load"
hx-swap="outerHTML">
<div style="color:var(--text-muted);font-size:0.85rem;padding:0.5rem 0;">Loading catalog…</div>
</div>
{% else %}
<div id="plugin-catalog" style="color:var(--text-muted);font-size:0.85rem;padding:0.5rem 0;">
Add a plugin repository above to browse available plugins.
</div>
{% endif %}
</div>
{% endblock %}
-177
View File
@@ -1,177 +0,0 @@
"""Plugin installs fetch only what the catalog names, verify it, and unpack it
only inside the plugin's own folder."""
import hashlib
import io
import zipfile
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
import pytest
from steward.core import plugin_manager as pm
def make_zip(files: dict[str, str]) -> bytes:
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as zf:
for name, text in files.items():
zf.writestr(name, text)
return buf.getvalue()
def sha(content: bytes) -> str:
return hashlib.sha256(content).hexdigest()
@pytest.mark.parametrize("name", ["..", "a/b", "evil-name", "", "x.y", "ümlaut"])
def test_name_must_be_an_identifier(name):
with pytest.raises(pm.PluginInstallError):
pm.check_plugin_name(name)
def test_checksum_is_required_and_compared():
content = b"zip"
with pytest.raises(pm.PluginInstallError, match="no checksum"):
pm.verify_checksum(content, "")
with pytest.raises(pm.PluginInstallError, match="mismatch"):
pm.verify_checksum(content, "0" * 64)
pm.verify_checksum(content, sha(content).upper())
def test_extract_strips_the_plugin_folder(tmp_path):
content = make_zip({"demo-v1.2/__init__.py": "x = 1", "demo-v1.2/sub/a.txt": "a"})
pm.extract_plugin(content, "demo", tmp_path / "demo")
assert (tmp_path / "demo" / "__init__.py").read_text() == "x = 1"
assert (tmp_path / "demo" / "sub" / "a.txt").read_text() == "a"
@pytest.mark.parametrize("member", ["../escape.py", "demo/../../escape.py", "/abs.py"])
def test_extract_refuses_members_outside_the_folder(tmp_path, member):
content = make_zip({"__init__.py": "", member: "boom"})
with pytest.raises(pm.PluginInstallError, match="outside"):
pm.extract_plugin(content, "demo", tmp_path / "plugins" / "demo")
assert not (tmp_path / "escape.py").exists()
assert not (tmp_path / "plugins" / "escape.py").exists()
assert not (tmp_path / "plugins" / "demo").exists() # nothing half-written
def test_extract_refuses_an_oversized_unpack(tmp_path, monkeypatch):
monkeypatch.setattr(pm, "MAX_PLUGIN_UNPACKED_BYTES", 10)
with pytest.raises(pm.PluginInstallError, match="Unpacks to more than"):
pm.extract_plugin(make_zip({"__init__.py": "x" * 11}), "demo", tmp_path / "demo")
def _app(tmp_path):
return SimpleNamespace(config={"PLUGIN_INSTALL_DIR": str(tmp_path), "PLUGIN_DIRS": [str(tmp_path)],
"DATABASE_URL": ""})
@pytest.mark.asyncio
async def test_update_replaces_the_folder_whole(tmp_path):
old = tmp_path / "demo"
old.mkdir()
(old / "gone.py").write_text("old")
content = make_zip({"demo/__init__.py": "new"})
with patch.object(pm, "_download", new=AsyncMock(return_value=content)):
ok, msg = await pm.download_and_install_plugin(_app(tmp_path), "demo", "https://x/demo.zip", sha(content))
assert ok, msg
assert (old / "__init__.py").read_text() == "new"
assert not (old / "gone.py").exists()
assert sorted(p.name for p in tmp_path.iterdir()) == ["demo"]
@pytest.mark.asyncio
async def test_a_refused_download_leaves_the_installed_copy(tmp_path):
old = tmp_path / "demo"
old.mkdir()
(old / "__init__.py").write_text("old")
content = make_zip({"demo/__init__.py": "new"})
with patch.object(pm, "_download", new=AsyncMock(return_value=content)):
ok, msg = await pm.download_and_install_plugin(_app(tmp_path), "demo", "https://x/demo.zip", "")
assert not ok and "no checksum" in msg
assert (old / "__init__.py").read_text() == "old"
assert sorted(p.name for p in tmp_path.iterdir()) == ["demo"]
@pytest.mark.asyncio
async def test_route_takes_url_and_checksum_from_the_catalog(client):
async with client.session_transaction() as sess:
sess.update(user_id="u", user_role="admin", username="admin", _csrf="t")
entry = SimpleNamespace(name="demo", version="1.0", download_url="https://catalog/demo.zip",
checksum_sha256="abc")
install = AsyncMock(return_value=(False, "stopped here"))
with patch("steward.settings.routes.get_all_settings", new=AsyncMock(return_value={})), \
patch("steward.settings.routes.repo_urls", return_value=["https://catalog/"]), \
patch("steward.core.plugin_index.fetch_catalog", new=AsyncMock(return_value=[entry])), \
patch("steward.core.plugin_manager.download_and_install_plugin", new=install), \
patch("steward.settings.routes.log_audit", new=AsyncMock()):
resp = await client.post("/settings/plugins/install/demo", headers={"X-CSRF-Token": "t"}, form={
"download_url": "https://attacker/evil.zip", "checksum_sha256": "dead"})
assert resp.status_code == 200
assert install.await_args.args[1:] == ("demo", "https://catalog/demo.zip", "abc")
install.reset_mock()
resp = await client.post("/settings/plugins/install/other", headers={"X-CSRF-Token": "t"})
assert "No configured plugin repository lists this plugin" in await resp.get_data(as_text=True)
install.assert_not_awaited()
# ── The catalog is fetched by a scheduled task, never by a page load ─────────
# Background Jobs ruling (operator, 2026-10-08): a Steward page reads stored
# records; the sources are contacted by scheduled collection, or because the
# operator asked (Refresh, install).
from steward.core import plugin_index as pi # noqa: E402
@pytest.fixture
def fresh_catalog():
pi.clear_catalog_cache()
yield
pi.clear_catalog_cache()
def _fetcher(fetched, fail=()):
async def fetch(url):
fetched.append(url)
if url in fail:
pi._failed_at[url] = 0.0
return False
pi._url_cache[url] = ({"plugins": [{"name": f"p-{url[-1]}", "version": "1.0"}]}, 0.0)
return True
return fetch
@pytest.mark.asyncio
async def test_the_tick_fetches_what_is_due_and_waits_out_a_failure(fresh_catalog):
fetched = []
with patch.object(pi, "_fetch_one", new=_fetcher(fetched, fail={"https://r/b"})):
assert await pi.refresh_due(["https://r/a", "https://r/b"], now=10.0) == 1
assert fetched == ["https://r/a", "https://r/b"]
fetched.clear()
# a fresh copy is left alone; the failure is not retried before RETRY_AFTER
assert await pi.refresh_due(["https://r/a", "https://r/b"], now=pi.RETRY_AFTER - 1.0) == 0
assert fetched == []
assert await pi.refresh_due(["https://r/a", "https://r/b"], now=pi.RETRY_AFTER + 1.0) == 0
assert fetched == ["https://r/b"]
fetched.clear()
assert await pi.refresh_due(["https://r/a"], now=pi.CATALOG_MAX_AGE + 1.0) == 1
assert fetched == ["https://r/a"]
@pytest.mark.asyncio
async def test_the_catalog_page_reads_the_cache_and_only_refresh_fetches(client, fresh_catalog):
async with client.session_transaction() as sess:
sess.update(user_id="u", user_role="admin", username="admin", _csrf="t")
fetched = []
with patch("steward.settings.routes.get_all_settings", new=AsyncMock(return_value={})), \
patch("steward.settings.routes.repo_urls", return_value=["https://r/a"]), \
patch.object(pi, "_fetch_one", new=_fetcher(fetched)):
page = await (await client.get("/settings/plugins/catalog/")).get_data(as_text=True)
assert fetched == [] and "Not fetched yet: https://r/a" in page
page = await (await client.post("/settings/plugins/catalog/refresh",
headers={"X-CSRF-Token": "t"})).get_data(as_text=True)
assert fetched == ["https://r/a"] and "p-a" in page and "Not fetched yet" not in page
# the next page load shows the stored copy without fetching again
page = await (await client.get("/settings/plugins/catalog/")).get_data(as_text=True)
assert fetched == ["https://r/a"] and "p-a" in page
+27
View File
@@ -0,0 +1,27 @@
"""Steward never downloads plugins (Plugin System ruling, 2026-10-08, #5374).
First-party plugins ship in the image; a third-party plugin is a folder the
operator mounts in /data/plugins. So there is no catalog to fetch, no
repository to configure and no install route: each guard below names one of
those and fails if it comes back.
"""
from steward.core.settings import DEFAULTS
def test_no_route_fetches_lists_or_installs_plugins(app):
rules = [r.rule for r in app.url_map.iter_rules()]
assert any(r.startswith("/settings/plugins/") for r in rules) # the page itself is still there
for gone in ("/settings/plugins/catalog", "/settings/plugins/install", "/settings/plugins/repos",
"/settings/plugins/reload"):
assert not [r for r in rules if r.startswith(gone)], gone
def test_no_default_names_a_catalog():
assert not [k for k in DEFAULTS if k.startswith("plugins.")]
def test_no_scheduled_task_fetches_a_catalog(app):
from steward.app import _register_core_tasks
_register_core_tasks(app)
names = [t.name for t in app._task_registry]
assert names and "plugin_catalog_refresh" not in names
-3
View File
@@ -67,8 +67,6 @@ def test_bootstrap_splits_bundled_and_external(tmp_path, monkeypatch):
boot = load_bootstrap(config_path=tmp_path / "absent.yaml")
assert boot["plugin_dirs"] == ["plugins", "/srv/external-plugins"]
# Installs target the external (writable) root, never the bundled one.
assert boot["plugin_install_dir"] == "/srv/external-plugins"
def test_bootstrap_external_defaults_to_data_plugins(tmp_path, monkeypatch):
@@ -81,4 +79,3 @@ def test_bootstrap_external_defaults_to_data_plugins(tmp_path, monkeypatch):
boot = load_bootstrap(config_path=tmp_path / "absent.yaml")
assert boot["plugin_dirs"] == ["plugins", "/data/plugins"]
assert boot["plugin_install_dir"] == "/data/plugins"