feat: the host agent is always on; the MCP sees and starts host maintenance (#5834)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 1m32s
CI / unit (push) Successful in 1m39s
CI / publish (push) Successful in 1m14s

#5834 "The host agent is always on, and the MCP sees a host's
maintenance", step 1 of milestone 520 "The MCP sees what Steward has
grown".

The host agent is a core system (operator, 2026-10-15: "it's very core
to the functionality of Steward as a whole and shouldn't be considered
something that can be toggled off"). ALWAYS_ON_PLUGINS in core settings
makes to_plugins_cfg report it enabled whatever its stored row says, so
it always loads and its MCP tools are always listed. Settings -> Plugins
shows it as "Built in: always on" with no switch, and a save keeps it
enabled. Its code and URLs stay under plugins/host_agent: every
installed agent posts to /plugins/host_agent/ingest.

Maintenance through the MCP:
- list_maintenance_plans: each plan's group, weekly slot, reboot policy,
  next slot, and its latest run (hosts, how far it got, who it skipped
  and why, how it ended).
- get_maintenance_job(host | job): a host's jobs, newest and running
  first, or one job: state, packages updated, reboot, drained by, why it
  failed, when its current state times out.
- run_maintenance(host, reboot="if_required" | "never"): admin, as the
  host page's Update now; refuses a busy host or one with no helper;
  audited "<user> via MCP".
- The overview names a host whose latest job in the last week failed or
  timed out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-15 12:59:42 -04:00
co-authored by Claude Opus 5.5
parent e122860cdc
commit 3ad8eed83d
9 changed files with 428 additions and 14 deletions
+11 -1
View File
@@ -590,13 +590,23 @@ def grade_style(grade: str | None) -> str:
return _STATUS_STYLE.get(grade or "", "")
# Plugins Steward can't run without, so they load whatever their stored
# `enabled` says and Settings → Plugins offers no switch for them. The host
# agent is one (operator, 2026-10-15): maintenance, the Hosts views, drive,
# update and mount alerts and Docker collection all rely on it.
ALWAYS_ON_PLUGINS = frozenset({"host_agent"})
def to_plugins_cfg(settings: dict[str, Any]) -> dict:
"""Assemble {plugin_name: {...config}} from all plugin.* keys."""
"""Assemble {plugin_name: {...config}} from all plugin.* keys. An
always-on plugin is enabled whatever its row says, or with no row."""
result = {}
for key, value in settings.items():
if key.startswith("plugin."):
name = key[len("plugin."):]
result[name] = value
for name in ALWAYS_ON_PLUGINS:
result[name] = {**(result.get(name) or {}), "enabled": True}
return result
+3 -2
View File
@@ -15,7 +15,7 @@ from steward.core.settings import (
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, to_access_cfg, DEFAULTS, seal_source_tokens,
is_plugin_secret, NORM_FLOORS,
is_plugin_secret, NORM_FLOORS, ALWAYS_ON_PLUGINS,
)
settings_bp = Blueprint("settings", __name__, url_prefix="/settings")
@@ -58,6 +58,7 @@ def _merge_plugin_config(discovered: list[dict], plugins_cfg: dict) -> None:
name = plugin["_dir"]
stored = plugins_cfg.get(name, {})
plugin["_enabled"] = stored.get("enabled", False)
plugin["_always_on"] = name in ALWAYS_ON_PLUGINS
defaults = plugin.get("config", {})
plugin["_config"] = {**defaults, **{k: v for k, v in stored.items() if k != "enabled"}}
@@ -930,7 +931,7 @@ async def start_restore():
def _build_plugin_cfg_from_form(plugin: dict, form) -> dict:
"""Extract config values for one plugin from a form submission."""
name = plugin["_dir"]
enabled = f"plugin.{name}.enabled" in form
enabled = name in ALWAYS_ON_PLUGINS or f"plugin.{name}.enabled" in form
plugin_cfg: dict = {"enabled": enabled}
for cfg_key in plugin.get("config", {}).keys():
field_name = f"plugin.{name}.{cfg_key}"
+11 -1
View File
@@ -43,7 +43,16 @@
<form method="post" action="/settings/plugins/{{ plugin._dir }}/" style="max-width:720px;">{{ csrf_field() }}
{# ── Enable toggle ────────────────────────────────────────────────────────── #}
{# ── Enable toggle; a built-in plugin has none (always on) ─────────────────── #}
{% if plugin._always_on %}
<div class="card" style="padding:1rem 1.25rem;margin-bottom:1rem;">
<div style="font-weight:500;">Built in: always on</div>
<div style="font-size:0.8rem;color:var(--text-muted);margin-top:0.4rem;">
Steward relies on this one (hosts, maintenance, drive and update alerts, Docker collection), so it can't
be turned off. Its settings below still apply.
</div>
</div>
{% else %}
<div class="card" style="padding:1rem 1.25rem;margin-bottom:1rem;">
<div style="display:flex;align-items:center;gap:0.75rem;">
<input type="checkbox"
@@ -58,6 +67,7 @@
Enabling loads the plugin straight away. Disabling takes effect after a restart.
</div>
</div>
{% endif %}
{# ── Config fields ─────────────────────────────────────────────────────────── #}
{% if plugin.get('config') %}
+1 -1
View File
@@ -29,7 +29,7 @@
background:color-mix(in srgb,var(--red) 15%,var(--bg));color:var(--red);">Error</span>
{% elif plugin._enabled %}
<span style="font-size:0.72rem;padding:0.1em 0.45em;border-radius:3px;
background:color-mix(in srgb,var(--green) 12%,var(--bg));color:var(--green);">Enabled</span>
background:color-mix(in srgb,var(--green) 12%,var(--bg));color:var(--green);">{{ "Built in" if plugin._always_on else "Enabled" }}</span>
{% else %}
<span style="font-size:0.72rem;padding:0.1em 0.45em;border-radius:3px;
background:var(--bg-elevated);color:var(--text-muted);">Disabled</span>