feat(mcp): search_container_logs takes a Swarm service's name and searches every task it has run (#5468)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 1m9s
CI / unit (push) Successful in 1m29s
CI / publish (push) Successful in 15s

#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 <noreply@anthropic.com>
This commit is contained in:
2026-10-09 14:21:56 -04:00
co-authored by Claude Opus 5.5
parent e98dd10556
commit a81b578247
3 changed files with 84 additions and 14 deletions
+47 -13
View File
@@ -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.
+8 -1
View File
@@ -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: