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
-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 %}