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 <noreply@anthropic.com>
This commit is contained in:
+47
-13
@@ -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.
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user