From 3ad8eed83d1bd56c034c0f405d94419e67fc272f Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 15 Oct 2026 12:59:42 -0400 Subject: [PATCH] feat: the host agent is always on; the MCP sees and starts host maintenance (#5834) #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 " 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 --- docs/core/mcp.md | 7 +- plugins/host_agent/mcp_tools.py | 212 +++++++++++++++++- steward/core/settings.py | 12 +- steward/settings/routes.py | 5 +- steward/templates/settings/plugin_detail.html | 12 +- steward/templates/settings/plugins.html | 2 +- tests/core/test_settings_default_plugins.py | 11 + tests/integration/test_mcp_maintenance.py | 156 +++++++++++++ tests/plugins/host_agent/test_mcp_tools.py | 25 ++- 9 files changed, 428 insertions(+), 14 deletions(-) create mode 100644 tests/integration/test_mcp_maintenance.py diff --git a/docs/core/mcp.md b/docs/core/mcp.md index 6c5ff39..352dccf 100644 --- a/docs/core/mcp.md +++ b/docs/core/mcp.md @@ -52,7 +52,7 @@ authentication. The AI access page shows the recipes for both. | Bearer tokens only, no OAuth | `steward/core/api_tokens.py`. Only a hash is stored, and the token is shown once. | | A token never acts above its user | Effective role = min(token role, user's **current** role). Lowering a user lowers every token they hold; disabling the user stops them all. | | Default deny | A tool is reachable only if it was registered with a role (`register_tool(fn, role=..., read_only=...)`). `tools/list` shows a caller only the tools their role allows. A call above the role is refused, and the refusal names the role it needs. | -| Plugin tools follow the plugin | A disabled plugin's tools are hidden and refused. | +| Plugin tools follow the plugin | A disabled plugin's tools are hidden and refused. The host agent is built in and always on (it can't be disabled), so its tools are always there. | | Dry run first | `run_playbook`, `create_schedule` and `set_alert_action` default to Ansible check mode (`--check --diff`). Changes are applied only when the caller passes `check=false`. A playbook its author flagged `# steward:confirm: true` also needs `confirmed=true`. `prune_docker` previews unless `check=false`. | | Secrets stay put | Playbook variables Steward detects as secret reach the run but are never stored, audited or returned. Schedules and alert actions refuse them, since they would have to store them. Inventory variables are listed by name only, and HTTP monitor header values are redacted. | | Every change is audited | Each write records an audit event as ` via MCP`, with the token id in its detail. `list_audit_events(user="")` includes those events. | @@ -79,7 +79,7 @@ Each role includes the tools of the roles above it in this list. | Automation | `list_playbooks`, `get_playbook`, `list_inventory`, `list_ansible_runs`, `get_ansible_run`, `wait_for_run`, `list_schedules` | | Audit | `list_audit_events` | | Docker plugin | `list_containers`, `get_container`, `list_swarm_services`, `search_container_logs`, `get_docker_disk_usage` | -| Host agent plugin | `list_agents` | +| Host agent (built in, always on) | `list_agents`, `list_maintenance_plans` (each plan's group, weekly slot, next run and latest run: how far it got, who it skipped and why), `get_maintenance_job` (a host's update jobs or one job: state, packages updated, whether it rebooted, why it failed) | ### Operator: run playbooks, change monitoring @@ -93,7 +93,8 @@ Each role includes the tools of the roles above it in this list. ### Admin: destructive and remediation `set_alert_action` (the playbook a rule runs when it fires), `delete_monitor`, -`delete_alert_rule`, `prune_docker`. +`delete_alert_rule`, `prune_docker`, `run_maintenance` (update a host now, rebooting +only if the update needs it, as the host page's Update now does). `save_playbook` and `delete_playbook` write this instance's own playbooks into `steward-local`, the same as Steward's editor (#5748). That is automation for diff --git a/plugins/host_agent/mcp_tools.py b/plugins/host_agent/mcp_tools.py index 4346918..e8ae378 100644 --- a/plugins/host_agent/mcp_tools.py +++ b/plugins/host_agent/mcp_tools.py @@ -5,14 +5,19 @@ from datetime import timedelta from sqlalchemy import select +import json + from steward.hosts import service as host_svc -from steward.mcp._context import mcp_app -from steward.mcp.tools._shape import age_s, db_session, iso, now_utc +from steward.mcp._context import current_caller, mcp_app +from steward.mcp.tools._audit import audit_mcp +from steward.mcp.tools._shape import age_s, clamp_limit, db_session, iso, now_utc, pick from steward.mcp.tools.overview import finding from steward.models.hosts import Host from steward.models.users import UserRole -from .models import HostAgentRegistration +from . import cadence, maintenance +from .models import (HostAgentRegistration, HostMaintenanceJob, HostMaintenancePlan, + HostMaintenanceRun) # A registration this old that has never checked in is a failed install, not one in progress. PENDING_GRACE = timedelta(minutes=30) @@ -80,21 +85,218 @@ async def list_agents() -> dict: return {"stale_after_s": stale_after, "counts": counts, "agents": agents, "total": len(agents)} +# ── maintenance (#5834): plans, runs, jobs, and starting one ───────────────── + +JOBS_MAX = 50 +# A failed job stays on the overview this long, unless a later one succeeded. +FAILED_JOB_DAYS = 7 + + +async def _names(db, ids) -> dict[str, str]: + from steward.models.users import User + ids = {i for i in ids if i} + if not ids: + return {} + hosts = dict((await db.execute(select(Host.id, Host.name).where(Host.id.in_(ids)))).all()) + users = dict((await db.execute(select(User.id, User.username).where(User.id.in_(ids)))).all()) + return {**users, **hosts} + + +def _job_row(job, names: dict, now) -> dict: + deadline = maintenance.DEADLINES.get(job.state) + started = job.created_at if job.state == "queued" else job.state_at + return { + "job_id": job.id, + "host": names.get(job.host_id, job.host_id), + "state": job.state, + "label": maintenance.LABELS.get(job.state, job.state), + "summary": maintenance.summary(job), + "active": job.state in maintenance.ACTIVE, + "reboot_policy": job.reboot_policy, + "packages_updated": job.packages, + "reboot_needed": job.reboot_needed, + "rebooted_at": iso(job.back_at), + "swarm_node": job.node_id, + "drained_by": names.get(job.drain_host_id, job.drain_host_id), + "detail": job.detail, + "state_since": iso(job.state_at), "state_age_s": age_s(job.state_at, now), + "gives_up_at": iso(started + deadline) if deadline and started else None, + "started_by": names.get(job.created_by) if job.created_by else + ("maintenance plan" if job.run_id else None), + "run_id": job.run_id, + "created_at": iso(job.created_at), + "finished_at": iso(job.finished_at), + } + + +async def list_maintenance_plans() -> dict: + """Each maintenance plan: which inventory group it updates, when (weekly + slot, time zone, how long a run may keep starting hosts), its reboot + policy, whether it is enabled, its next slot, and its latest run. + + A run updates the group's hosts one at a time; `latest_run` says how far + it got (`hosts_done` of `hosts`), which hosts it passed over and why + (`skipped`), and how it ended. get_maintenance_job shows one host's job. + Plans are created and edited on the Maintenance page. + """ + from steward.models.ansible_inventory import AnsibleGroup + now = now_utc() + async with db_session() as db: + plans = list((await db.execute(select(HostMaintenancePlan) + .order_by(HostMaintenancePlan.name))).scalars()) + groups = dict((await db.execute(select(AnsibleGroup.id, AnsibleGroup.name))).all()) + latest = {} + for plan in plans: + latest[plan.id] = (await db.execute( + select(HostMaintenanceRun).where(HostMaintenanceRun.plan_id == plan.id) + .order_by(HostMaintenanceRun.started_at.desc()).limit(1))).scalar_one_or_none() + names = await _names(db, [h for r in latest.values() if r + for h in json.loads(r.host_ids or "[]") + + [x["host_id"] for x in json.loads(r.skipped or "[]")]]) + out = [] + for plan in plans: + run = latest[plan.id] + nxt = cadence.next_slot(plan, now) if plan.enabled else None + row = { + "plan_id": plan.id, "name": plan.name, + "group": groups.get(plan.group_id, plan.group_id), + "schedule": cadence.describe(plan), + "weekday": cadence.WEEKDAYS[plan.weekday], "at_time": plan.at_time, + "time_zone": plan.time_zone, "window_minutes": plan.window_minutes, + "reboot_policy": plan.reboot_policy, "enabled": plan.enabled, + "next_slot_at": iso(nxt), "last_slot_at": iso(plan.last_slot_at), + "latest_run": None, + } + if run: + hosts = json.loads(run.host_ids or "[]") + row["latest_run"] = { + "run_id": run.id, "state": run.state, "manual": run.manual, + "hosts": [names.get(h, h) for h in hosts], + "hosts_done": min(run.position, len(hosts)), + "skipped": [{"host": names.get(x["host_id"], x["host_id"]), "reason": x["reason"]} + for x in json.loads(run.skipped or "[]")], + "detail": run.detail, + "started_at": iso(run.started_at), "finished_at": iso(run.finished_at), + } + out.append(row) + return {"plans": out, "total": len(out)} + + +async def get_maintenance_job(host: str = "", job: str = "", limit: int = 10) -> dict: + """A host's maintenance jobs, newest first (its running one first), or one + job by id. + + host: a host's name or id (list_hosts); job: a job id (from here, or a + plan's run). One of the two. + limit: how many of the host's jobs (default 10, max 50). + + A job is one host's update and, only when the update needs it, a reboot, + with its swarm node drained first and made active again after. `state` + moves queued → updating → [draining →] rebooting → [undraining →] done, + or failed / timed_out; `detail` says why a job failed. `summary` is the + host page's line ("Done: 12 packages updated, rebooted"). `gives_up_at` + is when an active job's current state times out. + """ + if bool(host.strip()) == bool(job.strip()): + raise ValueError("pass either `host` (a name or id) or `job` (a job id).") + now = now_utc() + limit = clamp_limit(limit, 10, JOBS_MAX) + async with db_session() as db: + if job.strip(): + row = await db.get(HostMaintenanceJob, job.strip()) + if row is None: + raise ValueError(f"no maintenance job {job.strip()!r}; " + "get_maintenance_job(host=...) lists a host's jobs.") + jobs = [row] + else: + h = pick(host, (await db.execute(select(Host))).scalars(), + kind="host", list_tool="list_hosts") + jobs = list((await db.execute( + select(HostMaintenanceJob).where(HostMaintenanceJob.host_id == h.id) + .order_by(HostMaintenanceJob.state.in_(maintenance.ACTIVE).desc(), + HostMaintenanceJob.created_at.desc()) + .limit(limit + 1))).scalars()) + names = await _names(db, [j.host_id for j in jobs] + [j.drain_host_id for j in jobs] + + [j.created_by for j in jobs]) + rows = [_job_row(j, names, now) for j in jobs[:limit]] + return {"jobs": rows, "total": len(rows), "truncated": len(jobs) > limit} + + +async def run_maintenance(host: str, reboot: str = "if_required") -> dict: + """Update a host now, as the host page's Update now does: the job starts + with the agent's next post and get_maintenance_job follows it. + + host: the host's name or id. + reboot: "if_required" (default) reboots only when the update says the + host needs it, draining its swarm node first and making it active after; + "never" updates and leaves any reboot for later. + + Refused while the host already has a job running, or when its agent has + no maintenance helper yet (Update agent installs it). Admin only, as on + the page. Recorded in the audit log. + """ + if reboot not in ("if_required", "never"): + raise ValueError('reboot must be "if_required" or "never". Nothing was started.') + caller = current_caller() + async with db_session() as db, db.begin(): + h = pick(host, (await db.execute(select(Host))).scalars(), + kind="host", list_tool="list_hosts") + reg = (await db.execute(select(HostAgentRegistration).where( + HostAgentRegistration.host_id == h.id))).scalar_one_or_none() + try: + created = await maintenance.create_job(db, h, reg, caller.user_id, reboot) + except maintenance.CannotStart as exc: + raise ValueError(f"{h.name} can't be updated now: {exc}. Nothing was started.") from None + job_id, host_id, name = created.id, h.id, h.name + await audit_mcp("host_agent.maintenance", "host", host_id, + {"job_id": job_id, "reboot_policy": reboot}) + return {"job_id": job_id, "host": name, "state": "queued", "reboot_policy": reboot, + "next": f'get_maintenance_job(job="{job_id}") follows it; it starts with the ' + "agent's next post."} + + +async def _failed_jobs(db, now) -> list: + """Each host's latest finished job, when it failed or timed out within + FAILED_JOB_DAYS (a later success clears it).""" + rows = (await db.execute( + select(HostMaintenanceJob, Host.name).join(Host, Host.id == HostMaintenanceJob.host_id) + .where(HostMaintenanceJob.state.in_(maintenance.FINISHED), + HostMaintenanceJob.finished_at >= now - timedelta(days=FAILED_JOB_DAYS)) + .order_by(HostMaintenanceJob.finished_at.desc()))).all() + latest = {} + for j, name in rows: + latest.setdefault(j.host_id, (j, name)) + return [(j, name) for j, name in latest.values() if j.state != "done"] + + async def mcp_overview(db) -> list[dict]: - """Agents registered more than PENDING_GRACE ago that have never checked in. + """Agents registered more than PENDING_GRACE ago that have never checked + in, and hosts whose latest maintenance job failed or timed out. Agents that stopped reporting are already flagged by the core overview from their metrics, so they are not repeated here. """ now = now_utc() - return [ + out = [ finding("warning", "agent_never_reported", h.name, "host agent was registered but has never checked in; the install may have failed", since=reg.created_at, ids={"host_id": h.id}, next="list_agents()") for reg, h in await _agents(db) if reg.last_seen_at is None and now - reg.created_at > PENDING_GRACE ] + out += [ + finding("warning", "maintenance_failed", name, + f"maintenance {maintenance.LABELS[j.state].lower()}" + + (f": {j.detail}" if j.detail else ""), + since=j.finished_at, ids={"host_id": j.host_id, "job_id": j.id}, + next=f'get_maintenance_job(job="{j.id}")') + for j, name in await _failed_jobs(db, now) + ] + return out def register_tools(register) -> None: register(list_agents, role=UserRole.viewer, read_only=True) + register(list_maintenance_plans, role=UserRole.viewer, read_only=True) + register(get_maintenance_job, role=UserRole.viewer, read_only=True) + register(run_maintenance, role=UserRole.admin, read_only=False) diff --git a/steward/core/settings.py b/steward/core/settings.py index b010ee4..4b57745 100644 --- a/steward/core/settings.py +++ b/steward/core/settings.py @@ -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 diff --git a/steward/settings/routes.py b/steward/settings/routes.py index af51bfb..b216444 100644 --- a/steward/settings/routes.py +++ b/steward/settings/routes.py @@ -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}" diff --git a/steward/templates/settings/plugin_detail.html b/steward/templates/settings/plugin_detail.html index 5da3f37..52f3dce 100644 --- a/steward/templates/settings/plugin_detail.html +++ b/steward/templates/settings/plugin_detail.html @@ -43,7 +43,16 @@
{{ csrf_field() }} - {# ── Enable toggle ────────────────────────────────────────────────────────── #} + {# ── Enable toggle; a built-in plugin has none (always on) ─────────────────── #} + {% if plugin._always_on %} +
+
Built in: always on
+
+ 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. +
+
+ {% else %}
+ {% endif %} {# ── Config fields ─────────────────────────────────────────────────────────── #} {% if plugin.get('config') %} diff --git a/steward/templates/settings/plugins.html b/steward/templates/settings/plugins.html index 773b232..7ceabf5 100644 --- a/steward/templates/settings/plugins.html +++ b/steward/templates/settings/plugins.html @@ -29,7 +29,7 @@ background:color-mix(in srgb,var(--red) 15%,var(--bg));color:var(--red);">Error {% elif plugin._enabled %} Enabled + background:color-mix(in srgb,var(--green) 12%,var(--bg));color:var(--green);">{{ "Built in" if plugin._always_on else "Enabled" }} {% else %} Disabled diff --git a/tests/core/test_settings_default_plugins.py b/tests/core/test_settings_default_plugins.py index 122f253..0ea3a11 100644 --- a/tests/core/test_settings_default_plugins.py +++ b/tests/core/test_settings_default_plugins.py @@ -41,3 +41,14 @@ def test_defaults_use_plugin_dot_namespace(): # The keys must live under the plugin. namespace to_plugins_cfg reads. for name in DEFAULT_ON: assert f"plugin.{name}" in settings_module.DEFAULTS + + +def test_the_host_agent_is_always_on(): + """Steward relies on it, so a stored disable, or no row at all, still + loads it (operator, 2026-10-15); its other settings are kept.""" + stored_off = {**DEFAULTS, "plugin.host_agent": {"enabled": False, "stale_after_seconds": 300}} + assert to_plugins_cfg(stored_off)["host_agent"] == {"enabled": True, "stale_after_seconds": 300} + no_row = {k: v for k, v in DEFAULTS.items() if k != "plugin.host_agent"} + assert to_plugins_cfg(no_row)["host_agent"]["enabled"] is True + # Only the host agent: another plugin's stored disable still wins. + assert to_plugins_cfg({**DEFAULTS, "plugin.snmp": {"enabled": False}})["snmp"]["enabled"] is False diff --git a/tests/integration/test_mcp_maintenance.py b/tests/integration/test_mcp_maintenance.py new file mode 100644 index 0000000..d10516e --- /dev/null +++ b/tests/integration/test_mcp_maintenance.py @@ -0,0 +1,156 @@ +"""Integration (#5834): the MCP's maintenance tools against real Postgres. + +A plan on a group, a host whose last job failed and one with a job running, +read back through list_maintenance_plans and get_maintenance_job; run_maintenance +refuses a busy host, starts one on a free host and audits it; the overview +names the failed job. + +Rows are seeded with plain SQL so this file never imports the plugin's models +(a second import after the app loaded the plugin raises "Table already +defined"). The tool module comes from the loaded plugin. +""" +from __future__ import annotations + +import asyncio +import hashlib +import importlib +import os +import uuid + +import pytest + +pytestmark = pytest.mark.integration + + +@pytest.fixture +def app(): + if not os.environ.get("STEWARD_DATABASE_URL"): + pytest.skip("needs Postgres") + from steward.app import create_app + return create_app(testing=False) + + +def _tools(): + from steward.core.plugin_manager import get_loaded_plugin_modules + loaded = get_loaded_plugin_modules().get("host_agent") + base = loaded.__name__ if loaded is not None else "plugins.host_agent" + return importlib.import_module(f"{base}.mcp_tools") + + +def test_maintenance_plans_jobs_and_starting_one_through_mcp(app): + from sqlalchemy import select, text + from steward.core.api_tokens import Caller + from steward.mcp import _context + from steward.models.ansible_inventory import AnsibleGroup, AnsibleTarget + from steward.models.audit import AuditEvent + from steward.models.hosts import Host + from steward.models.users import User, UserRole + + t = _tools() + tag = uuid.uuid4().hex[:8] + ids = {} + + async def _seed(): + async with app.db_sessionmaker() as s: + async with s.begin(): + user = User(username=f"mm-{tag}", email=f"mm-{tag}@test", password_hash="x", + role=UserRole.admin) + group = AnsibleGroup(name=f"mm-{tag}") + s.add_all([user, group]) + await s.flush() + ids["user"], ids["group"] = user.id, group.id + for n in ("failed", "busy", "free"): + host = Host(name=f"mm-{n}-{tag}", address="10.9.9.5") + s.add(host) + await s.flush() + target = AnsibleTarget(name=f"mm-{n}-{tag}", address="10.9.9.5", host_id=host.id) + target.groups = [group] + s.add(target) + await s.execute(text( + "INSERT INTO host_agent_registrations (id, host_id, token_hash, " + " token_created_at, last_seen_at, maint_helper, created_at, updated_at) " + "VALUES (:id, :h, :hash, now(), now(), true, now(), now())"), + {"id": str(uuid.uuid4()), "h": host.id, + "hash": hashlib.sha256(uuid.uuid4().hex.encode()).hexdigest()}) + ids[n] = host.id + ids["plan"], ids["run"], ids["failed_job"] = (str(uuid.uuid4()) for _ in range(3)) + await s.execute(text( + "INSERT INTO host_maintenance_plans (id, name, group_id, weekday, at_time, " + " time_zone, window_minutes, reboot_policy, enabled, created_by, created_at) " + "VALUES (:id, :name, :g, 6, '03:00', 'UTC', 240, 'if_required', true, :u, now())"), + {"id": ids["plan"], "name": f"plan-{tag}", "g": group.id, "u": user.id}) + await s.execute(text( + "INSERT INTO host_maintenance_runs (id, plan_id, state, host_ids, position, " + " skipped, detail, manual, started_at, finished_at) " + "VALUES (:id, :p, 'failed', :hosts, 1, :skipped, 'stopped: a host failed', " + " false, now() - interval '2 hours', now() - interval '1 hour')"), + {"id": ids["run"], "p": ids["plan"], + "hosts": f'["{ids["failed"]}", "{ids["busy"]}"]', + "skipped": f'[{{"host_id": "{ids["free"]}", "reason": "no agent"}}]'}) + await s.execute(text( + "INSERT INTO host_maintenance_jobs (id, host_id, state, state_at, reboot_policy, " + " step_seen, packages, reboot_needed, detail, run_id, created_at, finished_at) " + "VALUES (:id, :h, 'failed', now() - interval '1 hour', 'if_required', true, 3, " + " true, 'update failed: apt-get dist-upgrade exited 100', :run, " + " now() - interval '2 hours', now() - interval '1 hour')"), + {"id": ids["failed_job"], "h": ids["failed"], "run": ids["run"]}) + await s.execute(text( + "INSERT INTO host_maintenance_jobs (id, host_id, state, state_at, reboot_policy, " + " step_seen, created_by, created_at) " + "VALUES (:id, :h, 'updating', now(), 'never', false, :u, now())"), + {"id": str(uuid.uuid4()), "h": ids["busy"], "u": user.id}) + + async def _go(): + await _seed() + _context._app_ctx.set(app) + _context._caller_ctx.set(Caller(user_id=ids["user"], username=f"mm-{tag}", + role=UserRole.admin, token_id=9)) + + plans = {p["plan_id"]: p for p in (await t.list_maintenance_plans())["plans"]} + plan = plans[ids["plan"]] + assert plan["group"] == f"mm-{tag}" and plan["schedule"].startswith("Sundays at 03:00") + assert plan["next_slot_at"] and plan["enabled"] is True + run = plan["latest_run"] + assert run["state"] == "failed" and run["hosts_done"] == 1 + assert run["hosts"] == [f"mm-failed-{tag}", f"mm-busy-{tag}"] + assert run["skipped"] == [{"host": f"mm-free-{tag}", "reason": "no agent"}] + + failed = (await t.get_maintenance_job(host=f"mm-failed-{tag}"))["jobs"] + assert [j["state"] for j in failed] == ["failed"] + assert failed[0]["detail"].startswith("update failed") and failed[0]["started_by"] == "maintenance plan" + by_id = (await t.get_maintenance_job(job=ids["failed_job"]))["jobs"][0] + assert by_id["host"] == f"mm-failed-{tag}" + with pytest.raises(ValueError, match="either `host`"): + await t.get_maintenance_job() + + with pytest.raises(ValueError, match="already running.*Nothing was started"): + await t.run_maintenance(f"mm-busy-{tag}") + started = await t.run_maintenance(f"mm-free-{tag}", reboot="never") + assert started["state"] == "queued" and started["reboot_policy"] == "never" + [queued] = (await t.get_maintenance_job(host=ids["free"]))["jobs"] + assert queued["job_id"] == started["job_id"] and queued["started_by"] == f"mm-{tag}" + + async with app.db_sessionmaker() as s: + rows = list((await s.execute(select(AuditEvent).where( + AuditEvent.user_id == ids["user"], + AuditEvent.action == "host_agent.maintenance"))).scalars()) + findings = await t.mcp_overview(s) + assert len(rows) == 1 and rows[0].username == f"mm-{tag} via MCP" + mine = [f for f in findings if f["kind"] == "maintenance_failed" + and f["ids"]["host_id"] == ids["failed"]] + assert len(mine) == 1 and "apt-get dist-upgrade" in mine[0]["summary"] + assert not [f for f in findings if f["kind"] == "maintenance_failed" + and f["ids"]["host_id"] in (ids["busy"], ids["free"])] + + asyncio.run(_go()) + + +def test_the_host_agent_tools_are_listed_whatever_its_stored_switch_says(app): + """The host agent is always on (operator, 2026-10-15): its tools stay + listed even with a stored disable.""" + from steward.core.settings import to_plugins_cfg + app.config["PLUGINS"] = to_plugins_cfg({"plugin.host_agent": {"enabled": False}}) + mcp = app.mcp_server + assert mcp.plugin_enabled("host_agent") + for name in ("list_agents", "list_maintenance_plans", "get_maintenance_job", "run_maintenance"): + assert mcp.tool_plugins.get(name) == "host_agent", name diff --git a/tests/plugins/host_agent/test_mcp_tools.py b/tests/plugins/host_agent/test_mcp_tools.py index ca16597..7eb6edf 100644 --- a/tests/plugins/host_agent/test_mcp_tools.py +++ b/tests/plugins/host_agent/test_mcp_tools.py @@ -18,5 +18,28 @@ def test_agent_state(): def test_plugin_exposes_the_mcp_hooks(): reg = MagicMock() agent_plugin.register_mcp_tools(reg) - assert [c.args[0].__name__ for c in reg.call_args_list] == ["list_agents"] + assert [c.args[0].__name__ for c in reg.call_args_list] == [ + "list_agents", "list_maintenance_plans", "get_maintenance_job", "run_maintenance"] + roles = {c.args[0].__name__: c.kwargs["role"] for c in reg.call_args_list} + assert roles["run_maintenance"].value == "admin" # as Update now on the page + assert roles["get_maintenance_job"].value == "viewer" assert callable(agent_plugin.mcp_overview) + + +def test_a_job_row_says_what_it_did_and_when_it_gives_up(): + from plugins.host_agent.mcp_tools import _job_row + job = NS(id="j1", host_id="h1", state="rebooting", state_at=NOW - timedelta(minutes=5), + reboot_policy="if_required", packages=12, reboot_needed=True, back_at=None, + node_id="n1", drain_host_id="h2", detail=None, created_by=None, run_id="r1", + created_at=NOW - timedelta(minutes=20), finished_at=None) + row = _job_row(job, {"h1": "web-1", "h2": "mgr-1"}, NOW) + assert (row["host"], row["drained_by"], row["label"], row["active"]) == ( + "web-1", "mgr-1", "Rebooting", True) + assert row["started_by"] == "maintenance plan" and row["state_age_s"] == 300 + assert row["gives_up_at"] == (NOW + timedelta(minutes=25)).isoformat().replace("+00:00", "Z") + + done = NS(**{**job.__dict__, "state": "done", "back_at": NOW, "finished_at": NOW, + "created_by": "u1", "run_id": None}) + row = _job_row(done, {"h1": "web-1", "u1": "alice"}, NOW) + assert row["summary"] == "Done: 12 packages updated, rebooted" + assert row["started_by"] == "alice" and row["gives_up_at"] is None and not row["active"]