feat(mcp): docker and host-agent tools; TLS and crash-loop cutoffs in Settings
MCP-5 of milestone 461 "MCP server: Steward as an LLM-inspectable, LLM-operable hub". Contributed through each plugin's register_mcp_tools hook, so they exist only while the plugin is enabled. docker (viewer, read-only): - list_containers: filter by host, status, compose project, or problem (restarting, crash_looping, unhealthy, oom_killed, exited_nonzero); problems sort first and each row names what is wrong. - get_container: state, CPU/memory history across the raw/hourly seam, lifecycle events. A unique part of a swarm task name is enough. - list_swarm_services: replicas per node (agent-less placements as ghosts) and the swarm's nodes. - search_container_logs: text (% and _ literal), stream, range, context lines, paging by a full-precision `before` cursor; over-long lines keep both ends and say how much of the middle was cut. - get_docker_disk_usage: bytes per host, reclaimable first. - mcp_overview: failing containers and under-replicated swarm services join get_overview. host_agent: list_agents (reporting / stale / pending, version, OS), and an overview finding for agents registered over 30 minutes ago that never checked in. Settings > Thresholds gains the TLS-expiry warn/crit days (were 14/3 in code) and the crash-loop deaths-per-hour count (3), which get_overview and the docker tools now read. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,211 @@
|
||||
"""Integration: the docker and host_agent plugins' MCP tools against real Postgres.
|
||||
|
||||
Rows are seeded with plain SQL so this file never imports a plugin's models
|
||||
(a second import of them after the app loaded the plugin raises "Table already
|
||||
defined"). The tool modules come from the loaded plugin when there is one,
|
||||
and from the source tree when the plugin is not loaded.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import importlib
|
||||
import os
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
_NEEDS_DB = pytest.mark.skipif(
|
||||
not os.environ.get("STEWARD_DATABASE_URL"),
|
||||
reason="integration test needs a live Postgres (STEWARD_DATABASE_URL)",
|
||||
)
|
||||
|
||||
|
||||
@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 _plugin(name):
|
||||
from steward.core.plugin_manager import get_loaded_plugin_modules
|
||||
loaded = get_loaded_plugin_modules().get(name)
|
||||
return loaded if loaded is not None else importlib.import_module(f"plugins.{name}")
|
||||
|
||||
|
||||
def _sub(plugin_module, sub):
|
||||
return importlib.import_module(f"{plugin_module.__name__}.{sub}")
|
||||
|
||||
|
||||
@_NEEDS_DB
|
||||
def test_docker_and_agent_tools_over_seeded_rows(app):
|
||||
from sqlalchemy import text
|
||||
from steward.core.api_tokens import Caller
|
||||
from steward.mcp import _context
|
||||
from steward.models.hosts import Host
|
||||
from steward.models.users import UserRole
|
||||
|
||||
docker, agent = _plugin("docker"), _plugin("host_agent")
|
||||
dtools, atools = _sub(docker, "mcp_tools"), _sub(agent, "mcp_tools")
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
tag = uuid.uuid4().hex[:8]
|
||||
hid, hid2 = str(uuid.uuid4()), str(uuid.uuid4())
|
||||
host_name, quiet_host = f"dock-{tag}", f"quiet-{tag}"
|
||||
web, db_, job, svc = f"web-{tag}", f"db-{tag}", f"job-{tag}", f"svc-{tag}"
|
||||
|
||||
container_sql = text("""
|
||||
INSERT INTO docker_containers
|
||||
(host_id, name, container_id, image, status, cpu_pct, mem_pct, mem_usage_bytes,
|
||||
mem_limit_bytes, restart_count, ports_json, started_at, scraped_at, health,
|
||||
exit_code, oom_killed, compose_project, service_name)
|
||||
VALUES (:h, :n, :cid, 'img:1', :st, 5.0, 10.0, 1000, 10000, :rc, '[]', :started, :now,
|
||||
:health, :exit, false, :proj, :svc)
|
||||
""")
|
||||
|
||||
async def _seed():
|
||||
async with app.db_sessionmaker() as s:
|
||||
async with s.begin():
|
||||
s.add(Host(id=hid, name=host_name, address="10.8.8.8"))
|
||||
s.add(Host(id=hid2, name=quiet_host, address="10.8.8.9"))
|
||||
async with s.begin():
|
||||
for name, st, health, exit_code, proj, service in (
|
||||
(web, "running", "healthy", None, f"stack-{tag}", None),
|
||||
(db_, "running", "unhealthy", None, f"stack-{tag}", None),
|
||||
(job, "exited", None, 1, None, None),
|
||||
(f"{svc}.1.abc", "running", None, None, None, svc),
|
||||
):
|
||||
await s.execute(container_sql, {
|
||||
"h": hid, "n": name, "cid": uuid.uuid4().hex, "st": st, "rc": 0,
|
||||
"started": now - timedelta(hours=2), "now": now, "health": health,
|
||||
"exit": exit_code, "proj": proj, "svc": service})
|
||||
await s.execute(text(
|
||||
"INSERT INTO docker_events (id, host_id, container_name, event, detail, at) "
|
||||
"VALUES (:id, :h, :n, 'die', 'exit 1', :at)"),
|
||||
{"id": str(uuid.uuid4()), "h": hid, "n": job, "at": now - timedelta(minutes=10)})
|
||||
for i, (stream, line) in enumerate((
|
||||
("stdout", "starting up"), ("stdout", "connected to db"),
|
||||
("stderr", "ERROR: 100% of disk_used"), ("stdout", "retrying"),
|
||||
("stdout", "x" * 5000))):
|
||||
await s.execute(text(
|
||||
"INSERT INTO docker_logs (id, host_id, container_name, ts, stream, line) "
|
||||
"VALUES (:id, :h, :n, :ts, :st, :line)"),
|
||||
{"id": str(uuid.uuid4()), "h": hid, "n": web,
|
||||
"ts": now - timedelta(minutes=5 - i), "st": stream, "line": line})
|
||||
for i, cpu in enumerate((10.0, 30.0, 50.0)):
|
||||
await s.execute(text(
|
||||
"INSERT INTO docker_metrics (id, host_id, container_name, scraped_at, "
|
||||
"cpu_pct, mem_pct, mem_usage_bytes) VALUES (:id, :h, :n, :t, :c, 20.0, 1000)"),
|
||||
{"id": str(uuid.uuid4()), "h": hid, "n": web,
|
||||
"t": now - timedelta(minutes=30 - i * 10), "c": cpu})
|
||||
await s.execute(text(
|
||||
"INSERT INTO docker_swarm_services (host_id, service_name, mode, desired, running, "
|
||||
"placement_json, updated_at) VALUES (:h, :n, 'replicated', 3, 1, '[]', :now)"),
|
||||
{"h": hid, "n": svc, "now": now})
|
||||
await s.execute(text(
|
||||
"INSERT INTO docker_swarm_nodes (host_id, node_id, hostname, role, availability, "
|
||||
"status, leader, updated_at) VALUES (:h, :nid, :hn, 'manager', 'active', 'ready', "
|
||||
"true, :now)"), {"h": hid, "nid": f"node-{tag}", "hn": host_name, "now": now})
|
||||
await s.execute(text(
|
||||
"INSERT INTO docker_disk_usage (host_id, layers_size, images_size, images_reclaimable, "
|
||||
"containers_size, volumes_size, build_cache_size, images_total, images_active, "
|
||||
"containers_count, volumes_count, updated_at) "
|
||||
"VALUES (:h, 1, 5000, 3000, 10, 20, 30, 4, 1, 4, 2, :now)"), {"h": hid, "now": now})
|
||||
await s.execute(text(
|
||||
"INSERT INTO host_agent_registrations (id, host_id, token_hash, token_created_at, "
|
||||
"agent_version, last_seen_at, created_at, updated_at) "
|
||||
"VALUES (:id, :h, :th, :t, '1.5.0', :seen, :t, :t)"),
|
||||
{"id": str(uuid.uuid4()), "h": hid, "th": uuid.uuid4().hex + uuid.uuid4().hex,
|
||||
"t": now - timedelta(days=1), "seen": now - timedelta(seconds=20)})
|
||||
await s.execute(text(
|
||||
"INSERT INTO host_agent_registrations (id, host_id, token_hash, token_created_at, "
|
||||
"created_at, updated_at) VALUES (:id, :h, :th, :t, :t, :t)"),
|
||||
{"id": str(uuid.uuid4()), "h": hid2, "th": uuid.uuid4().hex + uuid.uuid4().hex,
|
||||
"t": now - timedelta(hours=2)})
|
||||
|
||||
async def _go():
|
||||
await _seed()
|
||||
_context._app_ctx.set(app)
|
||||
_context._caller_ctx.set(Caller(user_id="u", username="viewer", role=UserRole.viewer, token_id=0))
|
||||
|
||||
# ── list_containers: problems first, filters ───────────────────────
|
||||
mine = [c for c in (await dtools.list_containers(host=host_name, limit=500))["containers"]]
|
||||
names = [c["container_name"] for c in mine]
|
||||
assert set(names) >= {web, db_, job}
|
||||
problems = {c["container_name"]: c["problems"] for c in mine}
|
||||
assert problems[db_] == ["unhealthy"] and problems[job] == ["exited_nonzero"]
|
||||
assert problems[web] == []
|
||||
assert names.index(db_) < names.index(web) and names.index(job) < names.index(web)
|
||||
only = (await dtools.list_containers(host=hid, problem="unhealthy"))["containers"]
|
||||
assert [c["container_name"] for c in only] == [db_]
|
||||
proj = (await dtools.list_containers(compose_project=f"stack-{tag}"))["containers"]
|
||||
assert {c["container_name"] for c in proj} == {web, db_}
|
||||
with pytest.raises(ValueError, match="is not 'any' or one of"):
|
||||
await dtools.list_containers(problem="sad")
|
||||
|
||||
# ── get_container ──────────────────────────────────────────────────
|
||||
c = await dtools.get_container(web, range="1h")
|
||||
assert c["host_name"] == host_name and c["status"] == "running"
|
||||
assert c["cpu_pct_history"]["max"] == 50.0 and c["cpu_pct_history"]["points"] == 3
|
||||
crashed = await dtools.get_container(job, host=host_name)
|
||||
assert [e["event"] for e in crashed["events"]] == ["die"]
|
||||
# A unique part of a long swarm task name is enough.
|
||||
assert (await dtools.get_container(f"{svc}.1"))["swarm_service"] == svc
|
||||
with pytest.raises(ValueError, match="list_containers"):
|
||||
await dtools.get_container(f"nope-{tag}")
|
||||
|
||||
# ── search_container_logs ──────────────────────────────────────────
|
||||
hit = await dtools.search_container_logs(container=web, text="100%", context=1)
|
||||
assert [m["line"] for m in hit["matches"]] == ["ERROR: 100% of disk_used"]
|
||||
assert hit["matches"][0]["before"] == ["connected to db"]
|
||||
assert hit["matches"][0]["after"] == ["retrying"]
|
||||
# % and _ match literally, not as wildcards.
|
||||
assert (await dtools.search_container_logs(container=web, text="o_n"))["matches"] == []
|
||||
page1 = await dtools.search_container_logs(container=web, limit=2)
|
||||
assert page1["truncated"] and len(page1["matches"]) == 2
|
||||
assert "characters omitted" in page1["matches"][0]["line"]
|
||||
assert len(page1["matches"][0]["line"]) < 2100
|
||||
page2 = await dtools.search_container_logs(container=web, limit=2, before=page1["next_before"])
|
||||
assert [m["line"] for m in page2["matches"]] == ["ERROR: 100% of disk_used", "connected to db"]
|
||||
errs = await dtools.search_container_logs(host=host_name, stream="stderr")
|
||||
assert [m["container_name"] for m in errs["matches"]] == [web]
|
||||
with pytest.raises(ValueError, match="context needs `container`"):
|
||||
await dtools.search_container_logs(text="x", context=2)
|
||||
|
||||
# ── swarm, disk ────────────────────────────────────────────────────
|
||||
sw = await dtools.list_swarm_services(limit=500)
|
||||
s = next(x for x in sw["services"] if x["service_name"] == svc)
|
||||
assert (s["state"], s["running"], s["desired"]) == ("degraded", 1, 3)
|
||||
assert any(n["node_id"] == f"node-{tag}" and n["leader"] for n in sw["nodes"])
|
||||
disk = (await dtools.get_docker_disk_usage(host=host_name))["hosts"]
|
||||
assert disk[0]["images_reclaimable_bytes"] == 3000
|
||||
|
||||
# ── overview contributions ─────────────────────────────────────────
|
||||
async with app.db_sessionmaker() as db:
|
||||
found = {(f["kind"], f["subject"]) for f in await dtools.mcp_overview(db)}
|
||||
agent_found = {(f["kind"], f["subject"]) for f in await atools.mcp_overview(db)}
|
||||
assert ("container_unhealthy", db_) in found
|
||||
assert ("container_exited_nonzero", job) in found
|
||||
assert ("swarm_service_degraded", svc) in found
|
||||
assert not any(subject == web for _, subject in found)
|
||||
assert ("agent_never_reported", quiet_host) in agent_found
|
||||
assert not any(subject == host_name for _, subject in agent_found)
|
||||
|
||||
# ── host agents ────────────────────────────────────────────────────
|
||||
agents = {a["host_name"]: a for a in (await atools.list_agents())["agents"]}
|
||||
assert agents[host_name]["state"] == "reporting" and agents[host_name]["agent_version"] == "1.5.0"
|
||||
assert agents[quiet_host]["state"] == "pending"
|
||||
|
||||
# ── registration through the plugin hook, gated on enablement ─────
|
||||
mcp = app.mcp_server
|
||||
mcp.register_plugin("docker", docker)
|
||||
mcp.register_plugin("host_agent", agent)
|
||||
assert {"list_containers", "search_container_logs", "list_agents"} <= set(mcp.tool_roles)
|
||||
assert mcp.tool_plugins["list_containers"] == "docker"
|
||||
assert set(mcp.overview_sources) >= {"docker", "host_agent"}
|
||||
|
||||
asyncio.run(_go())
|
||||
@@ -134,6 +134,10 @@ def test_monitor_findings():
|
||||
assert down["since"] == "2026-10-06T10:00:00Z"
|
||||
assert down["next"] == 'get_monitor(monitor="a")'
|
||||
assert (out["mon-b"]["severity"], out["mon-c"]["severity"]) == ("critical", "warning")
|
||||
# The TLS window comes from Settings → Thresholds.
|
||||
narrow = {f["subject"] for f in overview.monitor_findings(
|
||||
mons, states, {}, NOW, {"warn": 5, "crit": 1})}
|
||||
assert narrow == {"mon-a", "mon-b"}
|
||||
|
||||
|
||||
def test_alert_findings_severity_by_state():
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
"""The docker plugin's MCP tools: what counts as a problem, and how it is reported."""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from types import SimpleNamespace as NS
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import plugins.docker as docker_plugin
|
||||
from plugins.docker import mcp_tools as t
|
||||
|
||||
NOW = datetime(2026, 10, 6, 12, 0, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def _c(**kw):
|
||||
base = dict(name="web", host_id="h1", image="nginx", status="running", health=None,
|
||||
exit_code=None, oom_killed=False, restart_count=0, cpu_pct=1.0, mem_pct=2.0,
|
||||
mem_usage_bytes=1, mem_limit_bytes=2, started_at=NOW - timedelta(hours=1),
|
||||
compose_project=None, service_name=None, scraped_at=NOW - timedelta(seconds=30))
|
||||
base.update(kw)
|
||||
return NS(**base)
|
||||
|
||||
|
||||
def test_container_problems_most_severe_first():
|
||||
assert t.container_problems(_c(), 0, False) == []
|
||||
assert t.container_problems(_c(status="restarting"), 4, True) == ["restarting", "crash_looping"]
|
||||
# The crash-loop count comes from Settings → Thresholds.
|
||||
assert t.container_problems(_c(), 2, True) == []
|
||||
assert t.container_problems(_c(), 2, True, crash_loop_deaths=2) == ["crash_looping"]
|
||||
assert t.container_problems(_c(health="unhealthy", oom_killed=True), 0, False) == ["unhealthy", "oom_killed"]
|
||||
# An unhealthy flag on a stopped container is stale; it isn't failing a healthcheck now.
|
||||
assert t.container_problems(_c(status="exited", health="unhealthy", exit_code=0), 0, False) == []
|
||||
|
||||
|
||||
def test_exited_nonzero_needs_a_recent_death():
|
||||
crashed = _c(status="exited", exit_code=137)
|
||||
assert t.container_problems(crashed, 1, True) == ["exited_nonzero"]
|
||||
# A container that died weeks ago and was left stopped is not news.
|
||||
assert t.container_problems(crashed, 0, False) == []
|
||||
|
||||
|
||||
def test_cut_middle_keeps_both_ends_and_sizes_the_gap():
|
||||
assert t.cut_middle("short") == "short"
|
||||
line = "A" * 10 + "B" * 100 + "C" * 10
|
||||
cut = t.cut_middle(line, limit=20)
|
||||
assert cut.startswith("A" * 10) and cut.endswith("C" * 10)
|
||||
assert "[… 100 characters omitted …]" in cut
|
||||
|
||||
|
||||
def test_container_row_uptime_only_while_running():
|
||||
row = t.container_row(_c(), {"h1": "nas"}, [], NOW)
|
||||
assert row["host_name"] == "nas" and row["uptime_s"] == 3600 and row["reported_age_s"] == 30
|
||||
assert t.container_row(_c(status="exited"), {}, [], NOW)["uptime_s"] is None
|
||||
|
||||
|
||||
def test_container_findings_one_per_container_with_every_problem_named():
|
||||
rows = [t.container_row(_c(name="db", status="restarting", exit_code=1), {"h1": "nas"},
|
||||
["restarting", "crash_looping"], NOW),
|
||||
t.container_row(_c(name="ok"), {}, [], NOW)]
|
||||
(f,) = t.container_findings(rows)
|
||||
assert f["severity"] == "critical" and f["kind"] == "container_restarting"
|
||||
assert "is restarting and keeps dying" in f["summary"] and "on nas" in f["summary"]
|
||||
assert "(exit 1)" in f["summary"]
|
||||
assert f["next"] == 'get_container(container="db", host="h1")'
|
||||
|
||||
|
||||
def test_service_findings_by_state():
|
||||
svcs = [{"name": "a", "state": "healthy", "running": 2, "desired": 2},
|
||||
{"name": "b", "state": "degraded", "running": 1, "desired": 3},
|
||||
{"name": "c", "state": "down", "running": 0, "desired": 1}]
|
||||
got = {f["subject"]: f["severity"] for f in t.service_findings(svcs)}
|
||||
assert got == {"b": "warning", "c": "critical"}
|
||||
|
||||
|
||||
def test_plugin_exposes_the_mcp_hooks():
|
||||
reg = MagicMock()
|
||||
docker_plugin.register_mcp_tools(reg)
|
||||
names = {call.args[0].__name__ for call in reg.call_args_list}
|
||||
assert names == {"list_containers", "get_container", "list_swarm_services",
|
||||
"search_container_logs", "get_docker_disk_usage"}
|
||||
for call in reg.call_args_list:
|
||||
assert call.kwargs["read_only"] is True
|
||||
assert callable(docker_plugin.mcp_overview)
|
||||
@@ -0,0 +1,22 @@
|
||||
"""The host_agent plugin's MCP tools."""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from types import SimpleNamespace as NS
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import plugins.host_agent as agent_plugin
|
||||
from plugins.host_agent.mcp_tools import agent_state
|
||||
|
||||
NOW = datetime(2026, 10, 6, 12, 0, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def test_agent_state():
|
||||
assert agent_state(NS(last_seen_at=None), NOW, 180) == "pending"
|
||||
assert agent_state(NS(last_seen_at=NOW - timedelta(seconds=60)), NOW, 180) == "reporting"
|
||||
assert agent_state(NS(last_seen_at=NOW - timedelta(minutes=10)), NOW, 180) == "stale"
|
||||
|
||||
|
||||
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 callable(agent_plugin.mcp_overview)
|
||||
Reference in New Issue
Block a user