refactor(plugins): no plugin catalog; plugins ship in the image or are mounted (#5374)
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:
+2
-200
@@ -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/")
|
||||
|
||||
Reference in New Issue
Block a user