Files
FabledSteward/tests/core/test_plugin_settings_hygiene.py
T
bvandeusenandClaude Opus 5 59fece855d
CI / lint (push) Successful in 3s
CI / unit (push) Successful in 46s
CI / integration (push) Successful in 2m22s
CI / publish (push) Successful in 1m16s
fix(plugins): remove the phantom http plugin; surface orphaned plugin settings
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>
2026-08-12 23:41:06 -04:00

89 lines
3.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_plugin_names
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():
assert find_orphaned_plugin_names(
["docker", "traefik", "ancient"], ["docker", "traefik"]
) == ["ancient"]
def test_no_orphans_when_everything_is_installed():
assert find_orphaned_plugin_names(["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_plugin_names([], ["docker"]) == []
def test_orphans_are_sorted_and_deduped_of_empties():
assert find_orphaned_plugin_names(["zeta", "", "alpha"], []) == ["alpha", "zeta"]
def test_accepts_arbitrary_iterables():
# Callers pass a generator of discovered dirs, not a list.
assert find_orphaned_plugin_names(
(n for n in ["gone"]), (n for n in ["here"])
) == ["gone"]