From a81b578247a5bb86d8ac0ad30d3d187c0a4b3eca Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 9 Oct 2026 14:21:56 -0400 Subject: [PATCH] feat(mcp): search_container_logs takes a Swarm service's name and searches every task it has run (#5468) #5468 "Log search by service name: searching "steward_app" refuses because every deploy makes a new container name". search_container_logs(container="steward_app") was refused, because no container carries the service's own name and five task containers partly matched. Now a service's name reaches every task container Steward holds anything for, live and removed (docker_svc.service_containers, as the service page's logs already do), so one search spans deploys. Each match carries the slot and task_id that wrote it, and the reply names the service and how many tasks were searched. A container named exactly what was asked still takes precedence. A miss that partly matches task containers now names their services. Co-Authored-By: Claude Opus 5.5 --- plugins/docker/mcp_tools.py | 60 +++++++++++++++++----- plugins/docker/service.py | 9 +++- tests/integration/test_mcp_plugin_tools.py | 29 +++++++++++ 3 files changed, 84 insertions(+), 14 deletions(-) diff --git a/plugins/docker/mcp_tools.py b/plugins/docker/mcp_tools.py index 5ac7858..4b403e8 100644 --- a/plugins/docker/mcp_tools.py +++ b/plugins/docker/mcp_tools.py @@ -27,7 +27,7 @@ from steward.models.users import UserRole from . import service as docker_svc from .service import PROBLEM_SAYS, PROBLEM_SEVERITY, PROBLEMS, container_problems, problem_since from .dedup import dedup_by_container_id -from .models import DockerDiskUsage, DockerSwarmNode, DockerSwarmService +from .models import DockerContainer, DockerDiskUsage, DockerSwarmNode, DockerSwarmService from .swarm_view import build_swarm_services, replaced_tasks, task_time CRASH_LOOP_DEATHS = docker_svc.CRASH_LOOP_DEATHS @@ -290,6 +290,10 @@ async def _pick_container(db, container: str, host: str, *, removed: bool = Fals return RemovedContainer(*gone[0]) names = sorted(c.name for c in partial)[:10] + [f"{r[1]} (removed)" for r in gone[:10]] hint = f" Containers whose names contain it: {', '.join(names)}." if names else "" + services = sorted({c.service_name for c in partial if c.service_name}) + if services: + hint += (f" Swarm services among them: {', '.join(services[:10])}; search_container_logs " + "takes a service's name and searches every task it has run.") raise ValueError(f"no container named {want!r}{' on that host' if host else ''}.{hint} " f"{'' if names else _REMOVED_HINT + ' '}list_containers lists them.") @@ -464,10 +468,14 @@ async def search_container_logs(container: str = "", host: str = "", text: str = context: int = 0, limit: int = 50) -> dict: """Search the container log lines Steward has retained, newest first. - container: one container's name (as list_containers shows); empty searches - every container (narrow with `host`). A container Docker has removed - still works while its lines are retained: the reply then has - `container_removed: true` and a `note` saying when it was last seen. + container: one container's name (as list_containers shows), or a Swarm + service's name (as list_swarm_services shows) to search every task + container the service has run, live and removed, so a search reaches + back across deploys; each match then has the `slot` and `task_id` that + wrote it. Empty searches every container (narrow with `host`). A + container Docker has removed still works while its lines are retained: + the reply then has `container_removed: true` and a `note` saying when it + was last seen. host: a host's name or id; empty for all hosts. text: case-insensitive substring to match; empty matches every line. range: how far back — "1h" (default), "6h", "24h", "7d" or "30d". Logs are @@ -496,16 +504,19 @@ async def search_container_logs(container: str = "", host: str = "", text: str = except ValueError as exc: raise ValueError(f"before {before!r} is not an ISO-8601 time; pass a response's next_before.") from exc async with db_session() as db: - host_id = name = removed = None - if container: - c = await _pick_container(db, container, host, removed=True) - host_id, name = c.host_id, c.name - removed = c if isinstance(c, RemovedContainer) else None - elif host: + host_id = name = removed = service = tasks = None + if host: hosts = list((await db.execute(select(Host))).scalars()) host_id = pick(host, hosts, kind="host", list_tool="list_hosts").id - rows = await docker_svc.search_logs(db, since=since, host_id=host_id, name=name, text=text, - stream=stream, before=before_dt, limit=limit + 1) + if container: + service, tasks = await _as_service(db, container.strip(), host_id) + if service is None: + c = await _pick_container(db, container, host, removed=True) + host_id, name = c.host_id, c.name + removed = c if isinstance(c, RemovedContainer) else None + rows = await docker_svc.search_logs(db, since=since, host_id=host_id, name=name, + containers=tasks, text=text, stream=stream, + before=before_dt, limit=limit + 1) more = len(rows) > limit rows = rows[:limit] host_names = await _host_names(db) @@ -513,12 +524,17 @@ async def search_container_logs(container: str = "", host: str = "", text: str = for r in rows: m = {"at": iso(r.ts), "container_name": r.container_name, "host_id": r.host_id, "host_name": host_names.get(r.host_id), "stream": r.stream, "line": cut_middle(r.line)} + if service: + m["slot"], m["task_id"] = docker_svc.task_of(service, r.container_name) if context: pre, post = await docker_svc.log_context(db, r.host_id, r.container_name, r.ts, context) m["before"] = [cut_middle(x.line) for x in pre] m["after"] = [cut_middle(x.line) for x in post] matches.append(m) out = {"range": key, "matches": matches, "returned": len(matches), "truncated": more} + if service: + out["service"] = service + out["tasks_searched"] = len(tasks) if removed is not None: out["container_removed"] = True out["note"] = _removed_note(removed) @@ -532,6 +548,24 @@ async def search_container_logs(container: str = "", host: str = "", text: str = return out +async def _as_service(db, want: str, host_id: str | None): + """(service, its task containers) when `want` names a Swarm service rather + than a container (#5468): every task container Steward holds anything for, + live and removed, kept to `host_id` when given (none there reads as no + lines, not as a missing name). (None, None) when it is not a service, or + a container carries that exact name, which keeps precedence.""" + if not want: + return None, None + exact = (await db.execute(select(DockerContainer.name).where( + DockerContainer.name == want).limit(1))).first() + if exact is not None: + return None, None + tasks = await docker_svc.service_containers(db, want) + if not tasks: + return None, None + return want, [(h, n) for h, n in tasks if host_id is None or h == host_id] + + async def get_docker_disk_usage(host: str = "") -> dict: """Docker disk usage per host: images, containers, volumes and build cache, in bytes. diff --git a/plugins/docker/service.py b/plugins/docker/service.py index 1781f8b..59d0f18 100644 --- a/plugins/docker/service.py +++ b/plugins/docker/service.py @@ -356,9 +356,12 @@ def scaled_bars(values: list, floor: float) -> dict: async def search_logs(db, *, since, host_id: str | None = None, name: str | None = None, + containers: list[tuple[str, str]] | None = None, text: str = "", stream: str = "", before=None, limit: int = 50) -> list: """Retained log lines matching a filter, newest first (limit + 1 fetched by callers - that need to know whether more exist).""" + that need to know whether more exist). `containers` keeps the lines of those + (host_id, container_name) pairs: a swarm service's tasks (#5468).""" + from sqlalchemy import tuple_ stmt = (select(DockerLog.id, DockerLog.host_id, DockerLog.container_name, DockerLog.ts, DockerLog.stream, DockerLog.line) .where(DockerLog.ts >= since)) @@ -366,6 +369,10 @@ async def search_logs(db, *, since, host_id: str | None = None, name: str | None stmt = stmt.where(DockerLog.host_id == host_id) if name: stmt = stmt.where(DockerLog.container_name == name) + if containers is not None: + if not containers: + return [] + stmt = stmt.where(tuple_(DockerLog.host_id, DockerLog.container_name).in_(containers)) if stream in ("stdout", "stderr"): stmt = stmt.where(DockerLog.stream == stream) if text: diff --git a/tests/integration/test_mcp_plugin_tools.py b/tests/integration/test_mcp_plugin_tools.py index 723a26d..38f495a 100644 --- a/tests/integration/test_mcp_plugin_tools.py +++ b/tests/integration/test_mcp_plugin_tools.py @@ -60,6 +60,9 @@ def test_docker_and_agent_tools_over_seeded_rows(app): 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}" replaced = f"{svc}.1.old" + # A service across a deploy (#5468): its live task, and the task the deploy replaced. + api = f"api-{tag}" + api_live, api_gone = f"{api}.1.{tag}{'a' * 17}", f"{api}.1.{tag}{'b' * 17}" removed = f"{svc}.2.gone{tag}" # a dead task Docker removed: logs and events only def _ago(minutes): @@ -105,6 +108,21 @@ def test_docker_and_agent_tools_over_seeded_rows(app): "INSERT INTO docker_logs (id, host_id, container_name, ts, stream, line) " "VALUES (:id, :h, :n, :ts, 'stderr', 'FATAL: cannot open database')"), {"id": str(uuid.uuid4()), "h": hid, "n": removed, "ts": now - timedelta(minutes=13)}) + await s.execute(container_sql, { + "h": hid, "n": api_live, "cid": uuid.uuid4().hex, "st": "running", "rc": 0, + "started": now - timedelta(minutes=3), "now": now, "health": None, + "exit": None, "proj": None, "svc": api, "task": f"{tag}{'a' * 17}"}) + await s.execute(text( + "INSERT INTO docker_events (id, host_id, container_name, event, detail, at) " + "VALUES (:id, :h, :n, 'start', '', :at)"), + {"id": str(uuid.uuid4()), "h": hid, "n": api_gone, "at": now - timedelta(minutes=30)}) + for name, mins, line in ((api_gone, 20, "listening (old image)"), + (api_live, 2, "listening (new image)")): + await s.execute(text( + "INSERT INTO docker_logs (id, host_id, container_name, ts, stream, line) " + "VALUES (:id, :h, :n, :ts, 'stdout', :line)"), + {"id": str(uuid.uuid4()), "h": hid, "n": name, + "ts": now - timedelta(minutes=mins), "line": line}) for i, (stream, line) in enumerate(( ("stdout", "starting up"), ("stdout", "connected to db"), ("stderr", "ERROR: 100% of disk_used"), ("stdout", "retrying"), @@ -218,6 +236,17 @@ def test_docker_and_agent_tools_over_seeded_rows(app): with pytest.raises(ValueError, match="may have been removed"): await dtools.get_container(f"nothing-{tag}") + # ── a service's name searches every task it has run (#5468) ───── + both = await dtools.search_container_logs(container=api, text="listening") + assert [m["line"] for m in both["matches"]] == ["listening (new image)", "listening (old image)"] + assert [(m["slot"], m["task_id"]) for m in both["matches"]] == [ + ("1", f"{tag}{'a' * 17}"), ("1", f"{tag}{'b' * 17}")] + assert (both["service"], both["tasks_searched"]) == (api, 2) + assert (await dtools.search_container_logs(container=api, host=quiet_host))["matches"] == [] + # A container's own name still reads as that one container. + one = await dtools.search_container_logs(container=api_live) + assert [m["line"] for m in one["matches"]] == ["listening (new image)"] and "service" not in one + # ── swarm, disk ──────────────────────────────────────────────────── sw = await dtools.list_swarm_services(limit=500) s = next(x for x in sw["services"] if x["service_name"] == svc)