The admin banner counts every plugin load failure, but settings/plugins.html only rendered failure reasons inside the plugin card macro -- which iterates discovered plugins. A plugin that failed BECAUSE it has no directory was therefore counted in the banner and shown nowhere on the page the banner links to. That dead end is what the operator hit: "1 plugin failed to load" on the dashboard, nothing wrong on the details page, and no way to clean it up. Folds the reason into the "Configured but not installed" rows rather than adding a competing section, and drives that list from the UNION of stored plugin.* settings and recorded load failures instead of stored settings alone. A failure with no stored row -- a plugin enabled by a DEFAULTS entry, which is exactly what plugin.http was -- would otherwise still be counted and still be invisible. Rows carry `removable`: only a plugin with a real stored row can be cleaned up, so a default-declared one shows the reason and an explanation instead of a Remove button that would delete nothing while the default reasserts it. Rows with a reason read as an error (red, "Failed to load"); rows that merely have leftover config stay a warning. A discovered plugin that failed is excluded -- its own card already shows the reason. Replaces find_orphaned_plugin_names outright rather than adding a second overlapping helper; it had one caller. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
136 lines
5.4 KiB
Python
136 lines
5.4 KiB
Python
"""Guards against plugin settings outliving the plugin they configure.
|
|
|
|
The `http` plugin was folded into the unified Monitor entity, but its
|
|
`plugin.http` DEFAULTS entry survived — so the operator saw a plugin that no
|
|
longer exists reported as enabled, with no way to clear it (deleting the row
|
|
did nothing; the default reasserted it). These tests make that class of drift a
|
|
CI failure instead of a support question.
|
|
"""
|
|
from pathlib import Path
|
|
|
|
from steward.core.settings import DEFAULTS, find_orphaned_plugins
|
|
from steward.settings.routes import CAPABILITY_PLUGINS
|
|
|
|
BUNDLED_PLUGINS_DIR = Path(__file__).resolve().parents[2] / "plugins"
|
|
|
|
|
|
def _bundled_plugin_names() -> set[str]:
|
|
"""Plugin dirs shipped inside the image, identified by their plugin.yaml."""
|
|
return {
|
|
entry.name
|
|
for entry in BUNDLED_PLUGINS_DIR.iterdir()
|
|
if entry.is_dir() and (entry / "plugin.yaml").exists()
|
|
}
|
|
|
|
|
|
def _default_plugin_names() -> set[str]:
|
|
prefix = "plugin."
|
|
return {k[len(prefix):] for k in DEFAULTS if k.startswith(prefix)}
|
|
|
|
|
|
def test_bundled_plugins_dir_is_discoverable():
|
|
# Guards the test itself: a wrong path would make everything below vacuous.
|
|
assert _bundled_plugin_names(), f"no bundled plugins found under {BUNDLED_PLUGINS_DIR}"
|
|
|
|
|
|
def test_every_default_plugin_key_has_a_real_bundled_plugin():
|
|
"""A plugin.* default naming a non-existent plugin is always a bug.
|
|
|
|
Bundled plugins ship in the image and version atomically with core, so
|
|
unlike external plugins they cannot be transiently missing — there is no
|
|
benign reason for this to fail.
|
|
"""
|
|
missing = _default_plugin_names() - _bundled_plugin_names()
|
|
assert not missing, (
|
|
f"DEFAULTS declares plugin(s) with no bundled directory: {sorted(missing)}. "
|
|
f"If the plugin was removed, delete its plugin.<name> key from DEFAULTS "
|
|
f"and add a migration dropping the stored row."
|
|
)
|
|
|
|
|
|
def test_http_plugin_default_is_gone():
|
|
# Explicit regression: this exact key is what the operator hit.
|
|
assert "plugin.http" not in DEFAULTS
|
|
|
|
|
|
def test_capability_plugins_all_exist():
|
|
"""CAPABILITY_PLUGINS classifies discovered plugins; stale names are dead weight."""
|
|
missing = CAPABILITY_PLUGINS - _bundled_plugin_names()
|
|
assert not missing, f"CAPABILITY_PLUGINS names non-existent plugin(s): {sorted(missing)}"
|
|
|
|
|
|
# ── orphan detection ─────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_orphans_are_stored_names_with_no_installed_plugin():
|
|
rows = find_orphaned_plugins(["docker", "traefik", "ancient"], ["docker", "traefik"])
|
|
assert [r["name"] for r in rows] == ["ancient"]
|
|
assert rows[0]["removable"] is True
|
|
assert rows[0]["reason"] is None
|
|
|
|
|
|
def test_no_orphans_when_everything_is_installed():
|
|
assert find_orphaned_plugins(["docker", "snmp"], ["docker", "snmp"]) == []
|
|
|
|
|
|
def test_installed_plugin_without_stored_settings_is_not_an_orphan():
|
|
# Never configured is not the same as left behind.
|
|
assert find_orphaned_plugins([], ["docker"]) == []
|
|
|
|
|
|
def test_orphans_are_sorted_and_skip_empty_names():
|
|
rows = find_orphaned_plugins(["zeta", "", "alpha"], [])
|
|
assert [r["name"] for r in rows] == ["alpha", "zeta"]
|
|
|
|
|
|
def test_accepts_arbitrary_iterables():
|
|
# Callers pass a generator of discovered dirs, not a list.
|
|
rows = find_orphaned_plugins((n for n in ["gone"]), (n for n in ["here"]))
|
|
assert [r["name"] for r in rows] == ["gone"]
|
|
|
|
|
|
# ── load failures folded in (issue #2638) ────────────────────────────────────
|
|
|
|
|
|
def test_failure_reason_is_attached_to_its_row():
|
|
rows = find_orphaned_plugins(
|
|
["ancient"], [], {"ancient": "Plugin directory not found in: ['/app/plugins']"})
|
|
assert rows[0]["reason"] == "Plugin directory not found in: ['/app/plugins']"
|
|
|
|
|
|
def test_failure_with_no_stored_row_still_gets_a_row():
|
|
"""The banner counts it, so the page must show it — that was the dead end.
|
|
|
|
A plugin enabled by a DEFAULTS entry has no stored row, so a stored-only
|
|
view would leave the banner pointing at a page with nothing on it.
|
|
"""
|
|
rows = find_orphaned_plugins([], [], {"http": "Plugin directory not found"})
|
|
assert [r["name"] for r in rows] == ["http"]
|
|
assert rows[0]["reason"] == "Plugin directory not found"
|
|
|
|
|
|
def test_failure_with_no_stored_row_is_not_removable():
|
|
# Offering Remove would delete nothing while the default reasserts it.
|
|
rows = find_orphaned_plugins([], [], {"http": "Plugin directory not found"})
|
|
assert rows[0]["removable"] is False
|
|
|
|
|
|
def test_failure_of_an_installed_plugin_is_not_listed_here():
|
|
# A discovered plugin that failed already shows the reason on its own card.
|
|
assert find_orphaned_plugins(
|
|
["docker"], ["docker"], {"docker": "boom"}) == []
|
|
|
|
|
|
def test_stored_orphan_and_failure_are_not_duplicated():
|
|
rows = find_orphaned_plugins(["gone"], [], {"gone": "Plugin directory not found"})
|
|
assert len(rows) == 1
|
|
assert rows[0]["removable"] is True and rows[0]["reason"]
|
|
|
|
|
|
def test_every_undiscovered_failure_is_represented():
|
|
# The invariant that keeps the banner count and this list in agreement.
|
|
failures = {"a": "x", "b": "y", "docker": "z"}
|
|
rows = find_orphaned_plugins([], ["docker"], failures)
|
|
listed = {r["name"] for r in rows}
|
|
assert listed == {"a", "b"}
|