"""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