fix(plugins): remove the phantom http plugin; surface orphaned plugin settings
CI / lint (push) Successful in 3s
CI / unit (push) Successful in 46s
CI / integration (push) Successful in 2m22s
CI / publish (push) Successful in 1m16s

Operator saw the `http` plugin -- deleted when ping/dns/http were unified into
the Monitor entity -- still reported as enabled, with no way to clear it.

It was never an orphaned row. `plugin.http` was hardcoded in core DEFAULTS, so
deleting the app_settings row did nothing: the default reasserted it on the
next settings load. That is precisely why no cleanup UI could have fixed it.
Rule 22 says the removed subsystem should have taken its setting with it.

Purged the surviving references -- the DEFAULTS key, "http" in
CAPABILITY_PLUGINS, the stale plugin_manager docstring example, and the
capabilities blurb still advertising HTTP/uptime as a bundled capability --
plus a migration dropping any stored plugin.http row. Untouched: `http` as a
MONITOR TYPE (icmp/tcp/dns/http) everywhere it appears, and http_001_initial,
which is kept deliberately so existing DBs resolve the revision graph.

For the general case, cleanup splits by provenance rather than being uniformly
automatic or uniformly manual:

  Bundled plugins ship in the image and version atomically with core, so they
  cannot be transiently missing -- a plugin.* default with no bundled directory
  is unambiguously a bug. Guarded by a unit test that fails CI, which is the
  only part safe to automate.

  External plugins live in operator-mounted /data/plugins, where absence is
  ambiguous: unmounted volume, failed install, mid-upgrade. Auto-deleting their
  config would silently destroy unrecoverable credentials on a transient
  condition, so Settings now lists them under "Configured but not installed"
  with an explicit, confirmed, audit-logged Remove. The section hides entirely
  when empty, and the remove route refuses if the plugin is actually installed.

Orphan detection reads STORED rows, never the DEFAULTS-merged view -- offering
to remove a default-only key would be a lie -- and returns names only, since
plugin config can hold credentials and listing orphans never needs their values.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-12 23:41:06 -04:00
co-authored by Claude Opus 5
parent 6c9b89390a
commit 59fece855d
7 changed files with 265 additions and 7 deletions
+37 -2
View File
@@ -8,7 +8,8 @@ from steward.auth.middleware import require_role
from steward.core.audit import log_audit
from steward.models.users import UserRole
from steward.core.settings import (
get_all_settings, set_setting,
get_all_settings, set_setting, delete_setting,
get_stored_plugin_names, find_orphaned_plugin_names,
to_smtp_cfg, to_webhook_cfg, to_ansible_cfg, to_plugins_cfg,
to_oidc_cfg, to_ldap_cfg, to_thresholds_cfg,
)
@@ -587,7 +588,7 @@ def _build_plugin_cfg_from_form(plugin: dict, form) -> dict:
# of a host), not discrete vendor integrations. Presentation-only split — they
# still load through the normal plugin mechanism. A plugin.yaml may set
# kind: capability|integration to override.
CAPABILITY_PLUGINS = {"host_agent", "http", "snmp", "docker"}
CAPABILITY_PLUGINS = {"host_agent", "snmp", "docker"}
@settings_bp.get("/plugins/")
@@ -595,22 +596,56 @@ CAPABILITY_PLUGINS = {"host_agent", "http", "snmp", "docker"}
async def plugins():
async with current_app.db_sessionmaker() as db:
settings = await get_all_settings(db)
stored_plugin_names = await get_stored_plugin_names(db)
discovered = _discover_plugins()
_merge_plugin_config(discovered, to_plugins_cfg(settings))
for p in discovered:
p["_kind"] = p.get("kind") or (
"capability" if p["_dir"] in CAPABILITY_PLUGINS else "integration")
repos = _get_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.
orphans = find_orphaned_plugin_names(
stored_plugin_names, [p["_dir"] for p in discovered])
return await render_template(
"settings/plugins.html",
capabilities=[p for p in discovered if p["_kind"] == "capability"],
integrations=[p for p in discovered if p["_kind"] != "capability"],
discovered_plugins=discovered,
orphaned_plugins=orphans,
repos=repos,
settings=settings,
)
@settings_bp.post("/plugins/orphans/<name>/remove/")
@require_role(UserRole.admin)
async def plugin_orphan_remove(name: str):
"""Delete the stored settings row for a plugin that is no longer installed.
Refuses if the plugin IS installed — that would silently wipe a live
plugin's config, and disabling it is what the operator wants there instead.
"""
installed = {p["_dir"] for p in _discover_plugins()}
if name in installed:
return redirect(url_for("settings.plugins"))
async with current_app.db_sessionmaker() as db:
async with db.begin():
removed = await delete_setting(db, f"plugin.{name}")
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)
return redirect(url_for("settings.plugins"))
# ── Per-plugin detail (settings) ──────────────────────────────────────────────
@settings_bp.get("/plugins/<name>/")