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:
@@ -6,11 +6,12 @@ Quart's pipeline) call the same code.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from steward.core.time_range import bucket_seconds
|
||||
from steward.models.hosts import Host
|
||||
|
||||
from .models import DockerContainer, DockerEvent, DockerLog, DockerMetric
|
||||
from .models import DockerContainer, DockerEvent, DockerLog, DockerMetric, DockerMetricHourly
|
||||
|
||||
|
||||
def bucket(values: list[float], target: int = 40) -> list[float]:
|
||||
@@ -97,3 +98,88 @@ async def query_logs(db, host_id: str, name: str, stream: str, query: str,
|
||||
stmt = stmt.where(DockerLog.line.ilike(f"%{escape_like(query)}%", escape="\\"))
|
||||
stmt = stmt.order_by(DockerLog.ts.desc(), DockerLog.id.desc()).limit(limit)
|
||||
return (await db.execute(stmt)).all()
|
||||
|
||||
|
||||
CONTAINER_SERIES = ("cpu_pct", "mem_pct", "mem_usage_bytes")
|
||||
|
||||
|
||||
async def container_points(db, host_id: str, name: str, metric: str, since,
|
||||
target: int = 120) -> list[tuple]:
|
||||
"""(time, average) points of one container metric since `since`, oldest first.
|
||||
|
||||
Raw samples are bucket-averaged in SQL; the part of the range older than
|
||||
the oldest raw sample (already rolled up and pruned by retention) comes
|
||||
from the hourly table.
|
||||
"""
|
||||
if metric not in CONTAINER_SERIES:
|
||||
raise ValueError(f"metric must be one of {', '.join(CONTAINER_SERIES)}")
|
||||
raw_col = getattr(DockerMetric, metric)
|
||||
width_s = bucket_seconds(since, target)
|
||||
rbucket = func.date_bin(
|
||||
func.make_interval(0, 0, 0, 0, 0, 0, width_s), DockerMetric.scraped_at, func.to_timestamp(0),
|
||||
).label("bucket")
|
||||
raw = (await db.execute(
|
||||
select(rbucket, func.avg(raw_col))
|
||||
.where(DockerMetric.host_id == host_id, DockerMetric.container_name == name,
|
||||
DockerMetric.scraped_at >= since)
|
||||
.group_by(rbucket).order_by(rbucket)
|
||||
)).all()
|
||||
oldest_raw = raw[0][0] if raw else None
|
||||
hourly_stmt = (
|
||||
select(DockerMetricHourly.bucket, getattr(DockerMetricHourly, metric))
|
||||
.where(DockerMetricHourly.host_id == host_id,
|
||||
DockerMetricHourly.container_name == name,
|
||||
DockerMetricHourly.bucket >= since)
|
||||
.order_by(DockerMetricHourly.bucket)
|
||||
)
|
||||
if oldest_raw is not None:
|
||||
hourly_stmt = hourly_stmt.where(DockerMetricHourly.bucket < oldest_raw)
|
||||
hourly = (await db.execute(hourly_stmt)).all()
|
||||
return [(t, float(v)) for t, v in hourly] + [(t, float(v)) for t, v in raw]
|
||||
|
||||
|
||||
async def search_logs(db, *, since, host_id: str | None = None, name: 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)."""
|
||||
stmt = (select(DockerLog.id, DockerLog.host_id, DockerLog.container_name,
|
||||
DockerLog.ts, DockerLog.stream, DockerLog.line)
|
||||
.where(DockerLog.ts >= since))
|
||||
if host_id:
|
||||
stmt = stmt.where(DockerLog.host_id == host_id)
|
||||
if name:
|
||||
stmt = stmt.where(DockerLog.container_name == name)
|
||||
if stream in ("stdout", "stderr"):
|
||||
stmt = stmt.where(DockerLog.stream == stream)
|
||||
if text:
|
||||
stmt = stmt.where(DockerLog.line.ilike(f"%{escape_like(text)}%", escape="\\"))
|
||||
if before is not None:
|
||||
stmt = stmt.where(DockerLog.ts < before)
|
||||
stmt = stmt.order_by(DockerLog.ts.desc(), DockerLog.id.desc()).limit(limit)
|
||||
return list((await db.execute(stmt)).all())
|
||||
|
||||
|
||||
async def log_context(db, host_id: str, name: str, ts, n: int) -> tuple[list, list]:
|
||||
"""Up to n lines either side of `ts` in one container's log, each oldest first."""
|
||||
cols = (DockerLog.id, DockerLog.ts, DockerLog.stream, DockerLog.line)
|
||||
base = select(*cols).where(DockerLog.host_id == host_id, DockerLog.container_name == name)
|
||||
before = (await db.execute(
|
||||
base.where(DockerLog.ts < ts).order_by(DockerLog.ts.desc(), DockerLog.id.desc()).limit(n)
|
||||
)).all()
|
||||
after = (await db.execute(
|
||||
base.where(DockerLog.ts > ts).order_by(DockerLog.ts, DockerLog.id).limit(n)
|
||||
)).all()
|
||||
return list(reversed(before)), list(after)
|
||||
|
||||
|
||||
async def recent_failures_by_container(db, since) -> dict[tuple[str, str], list]:
|
||||
"""{(host_id, container_name): [die/oom events since `since`, newest first]}."""
|
||||
rows = (await db.execute(
|
||||
select(DockerEvent)
|
||||
.where(DockerEvent.event.in_(("die", "oom")), DockerEvent.at >= since)
|
||||
.order_by(DockerEvent.at.desc())
|
||||
)).scalars()
|
||||
out: dict[tuple[str, str], list] = {}
|
||||
for e in rows:
|
||||
out.setdefault((e.host_id, e.container_name), []).append(e)
|
||||
return out
|
||||
|
||||
Reference in New Issue
Block a user