diff --git a/steward/core/settings.py b/steward/core/settings.py index ad6a80e..3469b56 100644 --- a/steward/core/settings.py +++ b/steward/core/settings.py @@ -18,7 +18,7 @@ from __future__ import annotations import asyncio import json import logging -from collections.abc import Iterable +from collections.abc import Iterable, Mapping from datetime import datetime, timezone from typing import Any @@ -267,20 +267,42 @@ async def get_stored_plugin_names(session: AsyncSession) -> list[str]: ) -def find_orphaned_plugin_names( +def find_orphaned_plugins( stored_names: Iterable[str], installed_names: Iterable[str], -) -> list[str]: - """Stored plugin settings whose plugin is not currently installed. + failures: Mapping[str, str] | None = None, +) -> list[dict[str, Any]]: + """Plugins that are configured or failed to load, but are not installed. Pure so it can be tested without a DB or a filesystem. "Not installed" is intentionally not treated as "safe to delete" — an external plugin under /data/plugins can be missing merely because the volume isn't mounted or an install failed, so the caller surfaces these for an explicit operator decision instead of removing them automatically. + + Driven by the UNION of stored settings and load failures, not by stored + settings alone. `load_plugins` records a failure for any enabled plugin + whose directory it cannot find, and the admin banner counts those — so a + failure with no stored row (a plugin enabled by a DEFAULTS entry) would + otherwise be counted in the banner and shown nowhere, which is exactly the + dead end this section exists to close. + + Each row carries `removable`: only a plugin with a real stored row can + actually be cleaned up. Offering Remove for a default-declared plugin would + be a lie — deleting nothing, while the default reasserts it on next load. """ installed = set(installed_names) - return sorted(name for name in stored_names if name and name not in installed) + failures = dict(failures or {}) + stored = {name for name in stored_names if name} + candidates = (stored | set(failures)) - installed + return [ + { + "name": name, + "reason": failures.get(name), + "removable": name in stored, + } + for name in sorted(candidates) + ] async def get_all_settings(session: AsyncSession) -> dict[str, Any]: diff --git a/steward/settings/routes.py b/steward/settings/routes.py index 23c3e72..03b1c24 100644 --- a/steward/settings/routes.py +++ b/steward/settings/routes.py @@ -9,7 +9,7 @@ from steward.core.audit import log_audit from steward.models.users import UserRole from steward.core.settings import ( get_all_settings, set_setting, delete_setting, - get_stored_plugin_names, find_orphaned_plugin_names, + get_stored_plugin_names, find_orphaned_plugins, to_smtp_cfg, to_webhook_cfg, to_ansible_cfg, to_plugins_cfg, to_oidc_cfg, to_ldap_cfg, to_thresholds_cfg, ) @@ -607,8 +607,17 @@ async def plugins(): # 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]) + # + # 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 + # `discovered`, so without this it would be counted in the banner and shown + # nowhere on the page that banner links to. + from steward.core.plugin_manager import get_plugin_failures + orphans = find_orphaned_plugins( + stored_plugin_names, + [p["_dir"] for p in discovered], + get_plugin_failures(), + ) return await render_template( "settings/plugins.html", capabilities=[p for p in discovered if p["_kind"] == "capability"], diff --git a/steward/templates/settings/plugins.html b/steward/templates/settings/plugins.html index 15939ea..c0bc414 100644 --- a/steward/templates/settings/plugins.html +++ b/steward/templates/settings/plugins.html @@ -95,31 +95,50 @@
- Stored settings for plugins Steward can't find. This usually means the plugin was - removed — but it also happens when an external plugin directory isn't mounted or an + Plugins Steward has settings for, or tried to load, but cannot find on disk — including + anything counted by the "failed to load" banner. This usually means the plugin was + removed, but it also happens when an external plugin directory isn't mounted or an install failed part-way, so nothing is deleted automatically. Removing an entry discards that plugin's saved configuration, including any credentials.
plugin.{{ name }} has no matching plugin.
+ {% if orphan.removable %}
+ Settings key plugin.{{ orphan.name }} has no matching plugin.
+ {% else %}
+ Enabled by a built-in default with no stored settings to remove — this is a
+ packaging bug, not leftover configuration. Please report it.
+ {% endif %}