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
+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/")