"""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