Milestone 465 "Swarm task awareness". Fixes three defects from the live MCP review #4973: - #5008 "Swarm task history read as live state" - #5009 "Crash-loop detection is blind to Swarm services" - #5011 "Swarm task errors are not captured" Agent and ingest (agent 1.8.0): - A manager's agent now reports each service's newest tasks: slot, node, desired and current state, Swarm's Err/Message, exit code and time. - They are stored in docker_swarm_services.tasks_json (migration docker_010_swarm_tasks). Swarm view (pure): - A task container is "replaced" when Swarm set its task's desired state off `running`. Without the task on record, a newer or running task in the same slot decides. - Replaced containers are no longer replicas or crashes. - Each service gets, from the task records: - failures_1h and failures_24h (failed or rejected tasks); - crash_looping (Settings → Thresholds count); - last_error, in Swarm's own words. MCP: - New findings: swarm_service_crash_looping (critical) and swarm_task_failed (warning, on a healthy service with a failure in 24h). - Down and degraded findings name the last task error. - list_swarm_services carries the failure fields. - Container rows mark swarm_replaced and give a replaced task no problems. Web: - The containers page swarm cards and the Swarm topology page show crash loops, recent failures and the last task error. - The dashboard widget flags a crash loop, and its "failed (24h)" count no longer counts a rolling update's killed task. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
230 lines
9.5 KiB
Python
230 lines
9.5 KiB
Python
"""Docker queries shared by the plugin's pages and its MCP tools.
|
|
|
|
Each function takes an open session and returns rows or plain data, with no
|
|
Quart request context, so the routes and the /mcp tools (which run outside
|
|
Quart's pipeline) call the same code.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
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, DockerMetricHourly
|
|
|
|
|
|
CRASH_LOOP_DEATHS = 3 # default; Settings → Thresholds sets it
|
|
|
|
|
|
def crash_loop_deaths(config) -> int:
|
|
"""Deaths within an hour that make a container or a swarm service a crash
|
|
loop, from Settings → Thresholds (app.config["THRESHOLDS"])."""
|
|
th = (config.get("THRESHOLDS") or {}).get("crash_loop") or {}
|
|
return int(th.get("warn") or CRASH_LOOP_DEATHS)
|
|
|
|
|
|
def failed_24h(dead_names, swarm_services) -> int:
|
|
"""Failures in the last 24h for the dashboard: distinct plain containers
|
|
that died or were OOM-killed, plus each swarm service's failed tasks.
|
|
|
|
A swarm task's container dies whenever Swarm replaces it (a rolling update
|
|
kills it with 137), so its die event is not a failure. Services with
|
|
Swarm's task records count their failed tasks instead; a service without
|
|
them (an agent older than 1.8.0) falls back to its tasks' die events.
|
|
"""
|
|
by_prefix = {s["name"] + ".": s for s in swarm_services}
|
|
plain, fallback = 0, set()
|
|
for name in dead_names:
|
|
svc = next((s for p, s in by_prefix.items() if name.startswith(p)), None)
|
|
if svc is None:
|
|
plain += 1
|
|
elif not svc["tasks_known"]:
|
|
fallback.add(name)
|
|
tasks = sum(s["failures_24h"] or 0 for s in swarm_services if s["tasks_known"])
|
|
return plain + len(fallback) + tasks
|
|
|
|
|
|
def bucket(values: list[float], target: int = 40) -> list[float]:
|
|
"""Bucket-average a series down to ~target points (DB-agnostic, in Python).
|
|
|
|
Replaces the old SQL strftime() bucketing, which was SQLite-only and broke
|
|
on Postgres. Agent cadence is dense, so a multi-hour window is hundreds of
|
|
rows — averaging into a readable point count keeps the sparkline's shape.
|
|
"""
|
|
n = len(values)
|
|
if n <= target:
|
|
return values
|
|
size = (n + target - 1) // target
|
|
out: list[float] = []
|
|
for i in range(0, n, size):
|
|
chunk = values[i:i + size]
|
|
out.append(sum(chunk) / len(chunk))
|
|
return out
|
|
|
|
|
|
async def container_history(db, host_id: str, name: str, since) -> tuple[list, list]:
|
|
"""Return (cpu_series, mem_series) sparkline-ready for one host's container."""
|
|
rows = (await db.execute(
|
|
select(DockerMetric.cpu_pct, DockerMetric.mem_pct)
|
|
.where(DockerMetric.host_id == host_id)
|
|
.where(DockerMetric.container_name == name)
|
|
.where(DockerMetric.scraped_at >= since)
|
|
.order_by(DockerMetric.scraped_at)
|
|
)).all()
|
|
cpu = bucket([r.cpu_pct or 0.0 for r in rows])
|
|
mem = bucket([r.mem_pct or 0.0 for r in rows])
|
|
return cpu, mem
|
|
|
|
|
|
async def host_map(db, host_ids: set[str]) -> dict[str, Host]:
|
|
if not host_ids:
|
|
return {}
|
|
return {
|
|
h.id: h for h in (await db.execute(
|
|
select(Host).where(Host.id.in_(host_ids)))).scalars().all()
|
|
}
|
|
|
|
|
|
def escape_like(text: str) -> str:
|
|
"""Make `text` match literally inside a LIKE/ILIKE pattern (escape char \\)."""
|
|
return text.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
|
|
|
|
|
async def list_containers(db, host_id: str | None = None) -> list[DockerContainer]:
|
|
"""Containers, running first then by name; one host's when host_id is given."""
|
|
stmt = select(DockerContainer)
|
|
if host_id is not None:
|
|
stmt = stmt.where(DockerContainer.host_id == host_id)
|
|
stmt = stmt.order_by((DockerContainer.status == "running").desc(), DockerContainer.name)
|
|
return list((await db.execute(stmt)).scalars())
|
|
|
|
|
|
async def container_events(db, host_id: str, name: str, limit: int = 50) -> list[DockerEvent]:
|
|
"""A container's lifecycle events (start/stop/die/oom/health), newest first."""
|
|
return list((await db.execute(
|
|
select(DockerEvent)
|
|
.where(DockerEvent.host_id == host_id)
|
|
.where(DockerEvent.container_name == name)
|
|
.order_by(DockerEvent.at.desc())
|
|
.limit(limit)
|
|
)).scalars())
|
|
|
|
|
|
async def query_logs(db, host_id: str, name: str, stream: str, query: str,
|
|
limit: int) -> list:
|
|
"""The recent retained lines for one container, newest-first, optionally
|
|
filtered by stream and a case-insensitive substring.
|
|
|
|
Newest-first is deliberate: the viewer replaces the list on each poll, which
|
|
resets scroll to the top — so the latest lines stay visible without any
|
|
scroll handling, and older lines are a scroll away.
|
|
"""
|
|
stmt = (select(DockerLog.ts, DockerLog.stream, DockerLog.line)
|
|
.where(DockerLog.host_id == host_id)
|
|
.where(DockerLog.container_name == name))
|
|
if stream in ("stdout", "stderr"):
|
|
stmt = stmt.where(DockerLog.stream == stream)
|
|
if query:
|
|
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
|
|
|
|
|
|
PRUNE_TARGETS = ("containers", "images", "system")
|
|
|
|
|
|
def prune_extra_vars(prune_target: str) -> dict:
|
|
"""Extra-vars for the bundled docker_prune playbook. "Prune unused images"
|
|
means ALL unused (docker image prune -a), not just dangling; the full system
|
|
prune stays conservative (-f, no -a) per the operator's choice (m78)."""
|
|
extra_vars: dict = {"prune_target": prune_target}
|
|
if prune_target == "images":
|
|
extra_vars["prune_all_images"] = True
|
|
return extra_vars
|