feat(docker): overview findings say when the problem began
#5013 "Overview findings for containers and Swarm services carry since=null" - Container problems are dated from their own evidence: a crash or restart loop from its first death in the hour, an OOM kill or error exit from the death that ended it, a failing healthcheck from its last turn to unhealthy. list_containers and get_container carry problem_since / problem_age_s. - Swarm services carry trouble_since: a crash loop's first failure in the hour, a short service's oldest troubled task in each failing slot, a recovered service's first failure in 24h. list_swarm_services shows it. - get_overview findings from the docker plugin now have since and age_s, so they rank by how long they have stood. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+60
-11
@@ -53,6 +53,29 @@ def container_problems(c, failures_1h: int, failed_24h: bool,
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def problem_since(problems: list[str], deaths: list, hour_ago: datetime,
|
||||||
|
unhealthy_at: datetime | None = None) -> datetime | None:
|
||||||
|
"""When a container's worst problem began; None when the retained events
|
||||||
|
cannot say. `deaths` are its die/oom events of the last 24h, newest first.
|
||||||
|
|
||||||
|
A restart or crash loop dates from its first death in the last hour, an
|
||||||
|
OOM kill or error exit from the death that ended it, and a failing
|
||||||
|
healthcheck from when its health last turned unhealthy.
|
||||||
|
"""
|
||||||
|
if not problems:
|
||||||
|
return None
|
||||||
|
worst = problems[0]
|
||||||
|
if worst in ("restarting", "crash_looping"):
|
||||||
|
in_hour = [e.at for e in deaths if e.at >= hour_ago]
|
||||||
|
return min(in_hour) if in_hour else (deaths[0].at if deaths else None)
|
||||||
|
if worst == "unhealthy":
|
||||||
|
return unhealthy_at
|
||||||
|
if worst == "oom_killed":
|
||||||
|
ooms = [e.at for e in deaths if e.event == "oom"]
|
||||||
|
return ooms[0] if ooms else (deaths[0].at if deaths else None)
|
||||||
|
return deaths[0].at if deaths else None
|
||||||
|
|
||||||
|
|
||||||
def cut_middle(text: str, limit: int = LOG_LINE_CHARS) -> str:
|
def cut_middle(text: str, limit: int = LOG_LINE_CHARS) -> str:
|
||||||
"""Keep both ends of an over-long line and say how much of the middle went."""
|
"""Keep both ends of an over-long line and say how much of the middle went."""
|
||||||
if text is None or len(text) <= limit:
|
if text is None or len(text) <= limit:
|
||||||
@@ -62,7 +85,7 @@ def cut_middle(text: str, limit: int = LOG_LINE_CHARS) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def container_row(c, host_names: dict, problems: list[str], now: datetime,
|
def container_row(c, host_names: dict, problems: list[str], now: datetime,
|
||||||
replaced: bool = False) -> dict:
|
replaced: bool = False, since: datetime | None = None) -> dict:
|
||||||
running = (c.status or "").lower() == "running"
|
running = (c.status or "").lower() == "running"
|
||||||
return {
|
return {
|
||||||
"container_name": c.name,
|
"container_name": c.name,
|
||||||
@@ -72,6 +95,9 @@ def container_row(c, host_names: dict, problems: list[str], now: datetime,
|
|||||||
"status": c.status,
|
"status": c.status,
|
||||||
"health": c.health,
|
"health": c.health,
|
||||||
"problems": problems,
|
"problems": problems,
|
||||||
|
# When the worst problem began, from the container's retained events.
|
||||||
|
"problem_since": iso(since),
|
||||||
|
"problem_age_s": age_s(since, now),
|
||||||
"exit_code": c.exit_code,
|
"exit_code": c.exit_code,
|
||||||
"oom_killed": bool(c.oom_killed),
|
"oom_killed": bool(c.oom_killed),
|
||||||
"restart_count": c.restart_count,
|
"restart_count": c.restart_count,
|
||||||
@@ -97,6 +123,10 @@ _SAYS = {"restarting": "is restarting", "crash_looping": "keeps dying",
|
|||||||
"exited_nonzero": "exited with an error"}
|
"exited_nonzero": "exited with an error"}
|
||||||
|
|
||||||
|
|
||||||
|
def _from_iso(text: str | None) -> datetime | None:
|
||||||
|
return datetime.fromisoformat(text.replace("Z", "+00:00")) if text else None
|
||||||
|
|
||||||
|
|
||||||
def container_findings(rows: list[dict]) -> list[dict]:
|
def container_findings(rows: list[dict]) -> list[dict]:
|
||||||
out = []
|
out = []
|
||||||
for r in rows:
|
for r in rows:
|
||||||
@@ -108,6 +138,7 @@ def container_findings(rows: list[dict]) -> list[dict]:
|
|||||||
out.append(finding(
|
out.append(finding(
|
||||||
_SEVERITY[worst], f"container_{worst}", r["container_name"],
|
_SEVERITY[worst], f"container_{worst}", r["container_name"],
|
||||||
f"container {r['container_name']}{where} {' and '.join(_SAYS[p] for p in r['problems'])}{detail}",
|
f"container {r['container_name']}{where} {' and '.join(_SAYS[p] for p in r['problems'])}{detail}",
|
||||||
|
since=_from_iso(r["problem_since"]),
|
||||||
ids={"host_id": r["host_id"], "container_name": r["container_name"]},
|
ids={"host_id": r["host_id"], "container_name": r["container_name"]},
|
||||||
next=f'get_container(container="{r["container_name"]}", host="{r["host_id"]}")'))
|
next=f'get_container(container="{r["container_name"]}", host="{r["host_id"]}")'))
|
||||||
return out
|
return out
|
||||||
@@ -132,22 +163,25 @@ def service_findings(services: list[dict]) -> list[dict]:
|
|||||||
last = task_error_text(s.get("last_error"))
|
last = task_error_text(s.get("last_error"))
|
||||||
tail = f"; last task error: {last}" if last else ""
|
tail = f"; last task error: {last}" if last else ""
|
||||||
ids = {"service_name": s["name"]}
|
ids = {"service_name": s["name"]}
|
||||||
|
since = s.get("trouble_since")
|
||||||
if s.get("crash_looping"):
|
if s.get("crash_looping"):
|
||||||
out.append(finding(
|
out.append(finding(
|
||||||
"critical", "swarm_service_crash_looping", s["name"],
|
"critical", "swarm_service_crash_looping", s["name"],
|
||||||
f"swarm service {s['name']} keeps failing: {s['failures_1h']} tasks failed in the "
|
f"swarm service {s['name']} keeps failing: {s['failures_1h']} tasks failed in the "
|
||||||
f"last hour, {replicas}{tail}", ids=ids, next="list_swarm_services()"))
|
f"last hour, {replicas}{tail}", since=since, ids=ids,
|
||||||
|
next="list_swarm_services()"))
|
||||||
elif s["state"] != "healthy":
|
elif s["state"] != "healthy":
|
||||||
out.append(finding(
|
out.append(finding(
|
||||||
"critical" if s["state"] == "down" else "warning", f"swarm_service_{s['state']}",
|
"critical" if s["state"] == "down" else "warning", f"swarm_service_{s['state']}",
|
||||||
s["name"], f"swarm service {s['name']} has {replicas}{tail}",
|
s["name"], f"swarm service {s['name']} has {replicas}{tail}",
|
||||||
ids=ids, next="list_swarm_services()"))
|
since=since, ids=ids, next="list_swarm_services()"))
|
||||||
elif s.get("failures_24h"):
|
elif s.get("failures_24h"):
|
||||||
n = s["failures_24h"]
|
n = s["failures_24h"]
|
||||||
out.append(finding(
|
out.append(finding(
|
||||||
"warning", "swarm_task_failed", s["name"],
|
"warning", "swarm_task_failed", s["name"],
|
||||||
f"swarm service {s['name']} had {n} failed task{'s' if n != 1 else ''} in the last "
|
f"swarm service {s['name']} had {n} failed task{'s' if n != 1 else ''} in the last "
|
||||||
f"24h and is now {replicas}{tail}", ids=ids, next="list_swarm_services()"))
|
f"24h and is now {replicas}{tail}", since=since, ids=ids,
|
||||||
|
next="list_swarm_services()"))
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
@@ -175,13 +209,17 @@ async def _rows(db, now: datetime) -> list[dict]:
|
|||||||
hour_ago = now - timedelta(hours=1)
|
hour_ago = now - timedelta(hours=1)
|
||||||
host_names = await _host_names(db)
|
host_names = await _host_names(db)
|
||||||
deaths = _crash_loop_deaths()
|
deaths = _crash_loop_deaths()
|
||||||
|
sick = await docker_svc.unhealthy_since(
|
||||||
|
db, [(c.host_id, c.name) for c in containers if (c.health or "") == "unhealthy"])
|
||||||
rows = []
|
rows = []
|
||||||
for c in containers:
|
for c in containers:
|
||||||
gone = (c.host_id, c.name) in replaced
|
key = (c.host_id, c.name)
|
||||||
events = failures.get((c.host_id, c.name), [])
|
gone = key in replaced
|
||||||
|
events = failures.get(key, [])
|
||||||
recent = sum(1 for e in events if e.at >= hour_ago)
|
recent = sum(1 for e in events if e.at >= hour_ago)
|
||||||
problems = [] if gone else container_problems(c, recent, bool(events), deaths)
|
problems = [] if gone else container_problems(c, recent, bool(events), deaths)
|
||||||
rows.append(container_row(c, host_names, problems, now, replaced=gone))
|
since = problem_since(problems, events, hour_ago, sick.get(key))
|
||||||
|
rows.append(container_row(c, host_names, problems, now, replaced=gone, since=since))
|
||||||
return rows
|
return rows
|
||||||
|
|
||||||
|
|
||||||
@@ -222,8 +260,10 @@ async def list_containers(host: str = "", status: str = "", problem: str = "",
|
|||||||
limit: default 100, max 500.
|
limit: default 100, max 500.
|
||||||
|
|
||||||
Containers with problems come first. Each row's `problems` lists what is
|
Containers with problems come first. Each row's `problems` lists what is
|
||||||
wrong, most severe first. `reported_age_s` is how old the agent's last
|
wrong, most severe first, and `problem_since` / `problem_age_s` say when
|
||||||
report is, so a large value means the row may be stale.
|
the worst of them began (null when Steward's retained events cannot say).
|
||||||
|
`reported_age_s` is how old the agent's last report is, so a large value
|
||||||
|
means the row may be stale.
|
||||||
|
|
||||||
A swarm task's container that Swarm has replaced (a rolling update, or a
|
A swarm task's container that Swarm has replaced (a rolling update, or a
|
||||||
retry after a failure) has `swarm_replaced: true` and no problems: Swarm
|
retry after a failure) has `swarm_replaced: true` and no problems: Swarm
|
||||||
@@ -306,14 +346,17 @@ async def get_container(container: str, host: str = "", range: str = "24h", even
|
|||||||
cpu = await docker_svc.container_points(db, c.host_id, c.name, "cpu_pct", since)
|
cpu = await docker_svc.container_points(db, c.host_id, c.name, "cpu_pct", since)
|
||||||
mem = await docker_svc.container_points(db, c.host_id, c.name, "mem_pct", since)
|
mem = await docker_svc.container_points(db, c.host_id, c.name, "mem_pct", since)
|
||||||
evs = await docker_svc.container_events(db, c.host_id, c.name, events)
|
evs = await docker_svc.container_events(db, c.host_id, c.name, events)
|
||||||
recent = sum(1 for e in failures if e.at >= now - timedelta(hours=1))
|
sick = await docker_svc.unhealthy_since(db, [(c.host_id, c.name)])
|
||||||
|
hour_ago = now - timedelta(hours=1)
|
||||||
|
recent = sum(1 for e in failures if e.at >= hour_ago)
|
||||||
try:
|
try:
|
||||||
ports = json.loads(c.ports_json or "[]")
|
ports = json.loads(c.ports_json or "[]")
|
||||||
except ValueError:
|
except ValueError:
|
||||||
ports = []
|
ports = []
|
||||||
problems = [] if gone else container_problems(c, recent, bool(failures), _crash_loop_deaths())
|
problems = [] if gone else container_problems(c, recent, bool(failures), _crash_loop_deaths())
|
||||||
|
began = problem_since(problems, failures, hour_ago, sick.get((c.host_id, c.name)))
|
||||||
return {
|
return {
|
||||||
**container_row(c, host_names, problems, now, replaced=gone),
|
**container_row(c, host_names, problems, now, replaced=gone, since=began),
|
||||||
"container_id": c.container_id,
|
"container_id": c.container_id,
|
||||||
"ports": ports,
|
"ports": ports,
|
||||||
"net_rx_bytes": c.net_rx_bytes, "net_tx_bytes": c.net_tx_bytes,
|
"net_rx_bytes": c.net_rx_bytes, "net_tx_bytes": c.net_tx_bytes,
|
||||||
@@ -348,6 +391,10 @@ async def list_swarm_services(limit: int = 100) -> dict:
|
|||||||
- `last_error`: the newest task that failed, was rejected, or carries an
|
- `last_error`: the newest task that failed, was rejected, or carries an
|
||||||
error while Swarm retries it — `error` is Swarm's own text (e.g. "No
|
error while Swarm retries it — `error` is Swarm's own text (e.g. "No
|
||||||
such image", "task: non-zero exit (1)"), with `exit_code`, `node`, `at`.
|
such image", "task: non-zero exit (1)"), with `exit_code`, `node`, `at`.
|
||||||
|
- `trouble_since` / `trouble_age_s`: when the current trouble began — a
|
||||||
|
crash loop's first failure in the hour, a short service's first failed
|
||||||
|
task in its failing slots, a recovered service's first failure in 24h.
|
||||||
|
Swarm keeps few old tasks, so a long outage reads as a lower bound.
|
||||||
|
|
||||||
Services with a problem come first. `nodes` lists every swarm node once,
|
Services with a problem come first. `nodes` lists every swarm node once,
|
||||||
with its role (manager/worker), availability and status.
|
with its role (manager/worker), availability and status.
|
||||||
@@ -386,6 +433,8 @@ async def list_swarm_services(limit: int = 100) -> dict:
|
|||||||
"crash_looping": s["crash_looping"],
|
"crash_looping": s["crash_looping"],
|
||||||
"failures_1h": s["failures_1h"], "failures_24h": s["failures_24h"],
|
"failures_1h": s["failures_1h"], "failures_24h": s["failures_24h"],
|
||||||
"last_error": _error_row(s["last_error"]),
|
"last_error": _error_row(s["last_error"]),
|
||||||
|
"trouble_since": iso(s["trouble_since"]),
|
||||||
|
"trouble_age_s": age_s(s["trouble_since"], now),
|
||||||
"previous_tasks": s["previous_tasks"], "tasks_known": s["tasks_known"],
|
"previous_tasks": s["previous_tasks"], "tasks_known": s["tasks_known"],
|
||||||
"replicas": [
|
"replicas": [
|
||||||
{"ghost": True, "host": r["host"], "count": r["count"]} if r["ghost"] else
|
{"ghost": True, "host": r["host"], "count": r["count"]} if r["ghost"] else
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ Quart's pipeline) call the same code.
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select
|
||||||
|
|
||||||
from steward.core.time_range import bucket_seconds
|
from steward.core.time_range import bucket_seconds
|
||||||
@@ -216,6 +218,27 @@ async def recent_failures_by_container(db, since) -> dict[tuple[str, str], list]
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
async def unhealthy_since(db, keys) -> dict[tuple[str, str], datetime]:
|
||||||
|
"""{(host_id, container_name): when it last turned unhealthy} for the given
|
||||||
|
containers, from their health_change events; absent when none is retained."""
|
||||||
|
keys = set(keys)
|
||||||
|
if not keys:
|
||||||
|
return {}
|
||||||
|
rows = (await db.execute(
|
||||||
|
select(DockerEvent)
|
||||||
|
.where(DockerEvent.event == "health_change",
|
||||||
|
DockerEvent.detail.like("%→unhealthy"),
|
||||||
|
DockerEvent.container_name.in_({name for _, name in keys}))
|
||||||
|
.order_by(DockerEvent.at.desc())
|
||||||
|
)).scalars()
|
||||||
|
out: dict[tuple[str, str], datetime] = {}
|
||||||
|
for e in rows:
|
||||||
|
key = (e.host_id, e.container_name)
|
||||||
|
if key in keys and key not in out:
|
||||||
|
out[key] = e.at
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
PRUNE_TARGETS = ("containers", "images", "system")
|
PRUNE_TARGETS = ("containers", "images", "system")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -120,6 +120,46 @@ def task_summary(tasks: list[dict], now: datetime, crash_loop_deaths: int,
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _oldest_failure(tasks: list[dict], since: datetime) -> datetime | None:
|
||||||
|
times = [at for t in tasks if t.get("state") in FAILED_TASK_STATES
|
||||||
|
and (at := task_time(t)) is not None and at >= since]
|
||||||
|
return min(times, default=None)
|
||||||
|
|
||||||
|
|
||||||
|
def trouble_since(tasks: list[dict], state: str, crash_looping: bool,
|
||||||
|
now: datetime) -> datetime | None:
|
||||||
|
"""When a service's current trouble began, as far as its retained tasks
|
||||||
|
tell; None when it has none, or the tasks cannot say.
|
||||||
|
|
||||||
|
A crash loop dates from its first failure in the last hour, and a healthy
|
||||||
|
service that lost tasks from its first failure in the last 24h. A service
|
||||||
|
short of replicas dates from its troubled slots (replicated: the slot;
|
||||||
|
global: the node): in each slot whose newest task is not running, the
|
||||||
|
oldest of its newest troubled tasks (failed, rejected, or carrying an
|
||||||
|
error), walking back until a running task interrupts them. Swarm keeps few
|
||||||
|
old tasks, so a long outage reads as the oldest one retained: a lower
|
||||||
|
bound.
|
||||||
|
"""
|
||||||
|
if crash_looping:
|
||||||
|
return _oldest_failure(tasks, now - timedelta(hours=1))
|
||||||
|
if state == "healthy":
|
||||||
|
return _oldest_failure(tasks, now - timedelta(hours=24))
|
||||||
|
by_slot: dict = {}
|
||||||
|
for t in tasks: # newest first, so each slot's list is too
|
||||||
|
by_slot.setdefault(t.get("slot") or t.get("node_id"), []).append(t)
|
||||||
|
began = []
|
||||||
|
for slot_tasks in by_slot.values():
|
||||||
|
oldest = None
|
||||||
|
for t in slot_tasks:
|
||||||
|
if t.get("state") == "running":
|
||||||
|
break
|
||||||
|
if t.get("state") in FAILED_TASK_STATES or t.get("error"):
|
||||||
|
oldest = task_time(t) or oldest
|
||||||
|
if oldest is not None:
|
||||||
|
began.append(oldest)
|
||||||
|
return min(began, default=None)
|
||||||
|
|
||||||
|
|
||||||
def _slot_key(c) -> str:
|
def _slot_key(c) -> str:
|
||||||
"""A task container's slot within its service: "<service>.<slot>.<task>" for
|
"""A task container's slot within its service: "<service>.<slot>.<task>" for
|
||||||
replicated services, "<service>.<node id>.<task>" for global ones."""
|
replicated services, "<service>.<node id>.<task>" for global ones."""
|
||||||
@@ -204,6 +244,7 @@ def build_swarm_services(containers, services, node_hostname: dict, host_name: d
|
|||||||
without task data); crash_looping = failures_1h ≥ the
|
without task data); crash_looping = failures_1h ≥ the
|
||||||
Settings → Thresholds crash-loop count
|
Settings → Thresholds crash-loop count
|
||||||
last_error = last_task_error(), plus `node` (the node's hostname)
|
last_error = last_task_error(), plus `node` (the node's hostname)
|
||||||
|
trouble_since = trouble_since(): when the current trouble began, or None
|
||||||
standalone[i] = {host_id, host, containers:[...]} # non-swarm, by host
|
standalone[i] = {host_id, host, containers:[...]} # non-swarm, by host
|
||||||
superseded = the task containers Swarm replaced, so callers can skip them
|
superseded = the task containers Swarm replaced, so callers can skip them
|
||||||
"""
|
"""
|
||||||
@@ -282,15 +323,19 @@ def build_swarm_services(containers, services, node_hostname: dict, host_name: d
|
|||||||
if desired is None:
|
if desired is None:
|
||||||
desired = len(replicas)
|
desired = len(replicas)
|
||||||
|
|
||||||
|
tasks = tasks_by_svc.get(name, [])
|
||||||
|
state = _service_state(int(running or 0), int(desired or 0))
|
||||||
|
summary = task_summary(tasks, now, crash_loop_deaths, node_hostname)
|
||||||
out_services.append({
|
out_services.append({
|
||||||
"name": name,
|
"name": name,
|
||||||
"mode": getattr(svc, "mode", "replicated") if svc is not None else "replicated",
|
"mode": getattr(svc, "mode", "replicated") if svc is not None else "replicated",
|
||||||
"running": running,
|
"running": running,
|
||||||
"desired": desired,
|
"desired": desired,
|
||||||
"state": _service_state(int(running or 0), int(desired or 0)),
|
"state": state,
|
||||||
"replicas": replicas,
|
"replicas": replicas,
|
||||||
"previous_tasks": previous.get(name, 0),
|
"previous_tasks": previous.get(name, 0),
|
||||||
**task_summary(tasks_by_svc.get(name, []), now, crash_loop_deaths, node_hostname),
|
**summary,
|
||||||
|
"trouble_since": trouble_since(tasks, state, summary["crash_looping"], now),
|
||||||
})
|
})
|
||||||
|
|
||||||
# Non-swarm containers grouped by host, running-first order preserved.
|
# Non-swarm containers grouped by host, running-first order preserved.
|
||||||
|
|||||||
@@ -204,6 +204,8 @@ def test_docker_and_agent_tools_over_seeded_rows(app):
|
|||||||
assert (s["failures_1h"], s["failures_24h"], s["crash_looping"]) == (1, 1, False)
|
assert (s["failures_1h"], s["failures_24h"], s["crash_looping"]) == (1, 1, False)
|
||||||
assert s["last_error"]["error"] == "task: non-zero exit (1)"
|
assert s["last_error"]["error"] == "task: non-zero exit (1)"
|
||||||
assert s["last_error"]["node"] == host_name and s["last_error"]["age_s"] >= 600
|
assert s["last_error"]["node"] == host_name and s["last_error"]["age_s"] >= 600
|
||||||
|
# Short of replicas since slot 2's task failed, ten minutes ago.
|
||||||
|
assert 600 <= s["trouble_age_s"] < 900
|
||||||
old = (await dtools.list_containers(host=host_name, status="exited", limit=500))["containers"]
|
old = (await dtools.list_containers(host=host_name, status="exited", limit=500))["containers"]
|
||||||
old = next(c for c in old if c["container_name"] == replaced)
|
old = next(c for c in old if c["container_name"] == replaced)
|
||||||
assert old["swarm_replaced"] is True and old["problems"] == []
|
assert old["swarm_replaced"] is True and old["problems"] == []
|
||||||
@@ -213,11 +215,15 @@ def test_docker_and_agent_tools_over_seeded_rows(app):
|
|||||||
|
|
||||||
# ── overview contributions ─────────────────────────────────────────
|
# ── overview contributions ─────────────────────────────────────────
|
||||||
async with app.db_sessionmaker() as db:
|
async with app.db_sessionmaker() as db:
|
||||||
found = {(f["kind"], f["subject"]) for f in await dtools.mcp_overview(db)}
|
findings = await dtools.mcp_overview(db)
|
||||||
|
found = {(f["kind"], f["subject"]) for f in findings}
|
||||||
agent_found = {(f["kind"], f["subject"]) for f in await atools.mcp_overview(db)}
|
agent_found = {(f["kind"], f["subject"]) for f in await atools.mcp_overview(db)}
|
||||||
assert ("container_unhealthy", db_) in found
|
assert ("container_unhealthy", db_) in found
|
||||||
assert ("container_exited_nonzero", job) in found
|
assert ("container_exited_nonzero", job) in found
|
||||||
assert ("swarm_service_degraded", svc) in found
|
assert ("swarm_service_degraded", svc) in found
|
||||||
|
# Each says how long it has been wrong: the death, the failed task.
|
||||||
|
since = {f["subject"]: f["age_s"] for f in findings}
|
||||||
|
assert 600 <= since[job] < 900 and 600 <= since[svc] < 900
|
||||||
assert not any(subject == replaced for _, subject in found)
|
assert not any(subject == replaced for _, subject in found)
|
||||||
assert not any(subject == web for _, subject in found)
|
assert not any(subject == web for _, subject in found)
|
||||||
assert ("agent_never_reported", quiet_host) in agent_found
|
assert ("agent_never_reported", quiet_host) in agent_found
|
||||||
|
|||||||
@@ -137,3 +137,35 @@ async def test_prune_docker_refuses_an_unknown_target_before_reading_anything(mo
|
|||||||
with pytest.raises(ValueError, match="'volumes' is not one of containers, images, system"):
|
with pytest.raises(ValueError, match="'volumes' is not one of containers, images, system"):
|
||||||
await t.prune_docker("nas", "volumes")
|
await t.prune_docker("nas", "volumes")
|
||||||
assert inspect.signature(t.prune_docker).parameters["check"].default is True
|
assert inspect.signature(t.prune_docker).parameters["check"].default is True
|
||||||
|
|
||||||
|
|
||||||
|
def _death(event, minutes_ago):
|
||||||
|
return NS(event=event, at=NOW - timedelta(minutes=minutes_ago))
|
||||||
|
|
||||||
|
|
||||||
|
def test_problem_since_dates_each_problem_from_its_own_evidence():
|
||||||
|
hour_ago = NOW - timedelta(hours=1)
|
||||||
|
deaths = [_death("die", 5), _death("die", 20), _death("die", 50), _death("die", 300)]
|
||||||
|
# A crash loop began with its first death inside the hour, not the older one.
|
||||||
|
assert t.problem_since(["crash_looping"], deaths, hour_ago) == NOW - timedelta(minutes=50)
|
||||||
|
# An error exit dates from the death that ended it.
|
||||||
|
assert t.problem_since(["exited_nonzero"], deaths, hour_ago) == NOW - timedelta(minutes=5)
|
||||||
|
oom = [_death("die", 2), _death("oom", 3)]
|
||||||
|
assert t.problem_since(["oom_killed", "exited_nonzero"], oom, hour_ago) == NOW - timedelta(minutes=3)
|
||||||
|
turned = NOW - timedelta(hours=2)
|
||||||
|
assert t.problem_since(["unhealthy"], [], hour_ago, turned) == turned
|
||||||
|
# No retained evidence: unknown, not now.
|
||||||
|
assert t.problem_since(["unhealthy"], [], hour_ago) is None
|
||||||
|
assert t.problem_since([], deaths, hour_ago) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_findings_carry_when_the_problem_began():
|
||||||
|
began = NOW - timedelta(minutes=40)
|
||||||
|
row = t.container_row(_c(name="db", status="exited", exit_code=1), {}, ["exited_nonzero"],
|
||||||
|
NOW, since=began)
|
||||||
|
assert row["problem_since"] == "2026-10-06T11:20:00Z" and row["problem_age_s"] == 2400
|
||||||
|
(f,) = t.container_findings([row])
|
||||||
|
assert f["since"] == "2026-10-06T11:20:00Z" and f["age_s"] is not None
|
||||||
|
svc = {"name": "db", "state": "down", "running": 0, "desired": 1, "trouble_since": began}
|
||||||
|
(g,) = t.service_findings([svc])
|
||||||
|
assert g["since"] == "2026-10-06T11:20:00Z"
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ import json
|
|||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
|
|
||||||
from plugins.docker.swarm_view import build_swarm_services, last_task_error, task_failures
|
from plugins.docker.swarm_view import (
|
||||||
|
build_swarm_services, last_task_error, task_failures, trouble_since,
|
||||||
|
)
|
||||||
|
|
||||||
NODE_HOSTNAME = {"nA": "docker01", "nB": "docker02", "nC": "docker03"}
|
NODE_HOSTNAME = {"nA": "docker01", "nB": "docker02", "nC": "docker03"}
|
||||||
HOST_NAME = {"h1": "docker01", "h2": "docker02"} # agents run on docker01/02 only
|
HOST_NAME = {"h1": "docker01", "h2": "docker02"} # agents run on docker01/02 only
|
||||||
@@ -172,3 +174,40 @@ def test_the_newest_manager_report_wins():
|
|||||||
(svc,) = _view([], [old, new])["services"]
|
(svc,) = _view([], [old, new])["services"]
|
||||||
assert svc["tasks_known"] is True and svc["failures_1h"] == 1
|
assert svc["tasks_known"] is True and svc["failures_1h"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def _ago(minutes):
|
||||||
|
return NOW - timedelta(minutes=minutes)
|
||||||
|
|
||||||
|
|
||||||
|
def test_trouble_since_for_each_kind_of_trouble():
|
||||||
|
loop = [_task("t1", state="failed", ago_min=2), _task("t2", state="failed", ago_min=20),
|
||||||
|
_task("t3", state="failed", ago_min=45), _task("t4", state="failed", ago_min=90)]
|
||||||
|
# A crash loop: its first failure inside the hour.
|
||||||
|
assert trouble_since(loop, "down", True, NOW) == _ago(45)
|
||||||
|
# Down: the oldest of the newest troubled tasks, back to a running one.
|
||||||
|
down = [_task("t1", state="pending", ago_min=1, error="no suitable node"),
|
||||||
|
_task("t2", state="rejected", ago_min=30, error="No such image"),
|
||||||
|
_task("t3", state="running", desired="running", ago_min=600),
|
||||||
|
_task("t4", state="failed", ago_min=900)]
|
||||||
|
assert trouble_since(down, "degraded", False, NOW) == _ago(30)
|
||||||
|
# Another slot's newer, healthy task does not hide the failing one.
|
||||||
|
two = [_task("a2", state="running", desired="running", ago_min=5, slot=1),
|
||||||
|
_task("b1", state="failed", ago_min=8, slot=2),
|
||||||
|
_task("b0", state="failed", ago_min=40, slot=2),
|
||||||
|
_task("a1", state="failed", ago_min=300, slot=1)]
|
||||||
|
assert trouble_since(two, "degraded", False, NOW) == _ago(40)
|
||||||
|
# Healthy again, but a task failed today: dated from that failure.
|
||||||
|
recovered = [_task("t1", state="running", desired="running", ago_min=10),
|
||||||
|
_task("t2", state="failed", ago_min=60 * 5),
|
||||||
|
_task("t3", state="failed", ago_min=60 * 30)]
|
||||||
|
assert trouble_since(recovered, "healthy", False, NOW) == _ago(60 * 5)
|
||||||
|
# No task records: unknown.
|
||||||
|
assert trouble_since([], "down", False, NOW) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_view_dates_a_down_service():
|
||||||
|
tasks = [_task("t1", state="failed", ago_min=3, exit_code=1),
|
||||||
|
_task("t2", state="failed", ago_min=70, exit_code=1)]
|
||||||
|
(s,) = _view([], [_svc("db", 0, 1, [], tasks=tasks)])["services"]
|
||||||
|
assert s["state"] == "down" and s["trouble_since"] == _ago(70)
|
||||||
|
|||||||
Reference in New Issue
Block a user