Files
FabledSteward/plugins/docker/timeline.py
T
bvandeusenandClaude Opus 5.5 2b97c36fd4
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 1m36s
CI / integration (push) Successful in 1m52s
CI / publish (push) Successful in 1m12s
feat(timeline): maintenance, reboots, deploys, crash-loops and reviewed anomalies on the timeline (#6349)
The host agent writes each maintenance job once it ends, and each stretch a
host was away (a reboot, a crash, lost contact, the agent restarting), kept up
to date until it is back. Docker judges each snapshot: a unit on a new image,
or a container recreated, is a deploy; deaths past the Thresholds crash-loop
count are one crash-loop entry that later deaths extend; single restarts are
recorded but hidden. An anomaly has one entry whose type follows its review:
only those judged real are shown by default.

A source whose record changes keeps one entry for it (record_or_update).
Plugins register the records their entries point to, so an aged-out job or
downtime reads "Details no longer kept". The first fill runs once per source
(timeline.filled), the host agent's jobs and downtime included.

get_anomaly's "what else was going on" reads the timeline for the host or
resource instead of querying alerts, runs, maintenance, downtime and container
deaths itself; Docker's own source keeps only starts, stops and health
changes. A new kind, Observed, is for what Steward saw happen with no rule or
person behind it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 17:21:44 -04:00

161 lines
7.4 KiB
Python

# plugins/docker/timeline.py
"""What Docker puts on the timeline, judged from each snapshot as it is
ingested: a deploy (a container or swarm service running a new image, or a
container recreated), and a crash-loop as one entry for the whole episode.
A single restart or death is recorded but hidden. As on the Docker pages, a
crash loop is Settings → Thresholds' number of deaths within CRASH_WINDOW:
the one that reaches it opens a crash-loop entry, which each later one
extends until the container has been quiet for CRASH_WINDOW.
A container belongs to its swarm service when it has one, so a service's
tasks, replaced on every deploy and every crash, read as one subject.
"""
from __future__ import annotations
import logging
from datetime import timedelta
from sqlalchemy import func, select
from steward.models.timeline import TimelineEntry
from steward.timeline import catalog
from steward.timeline.record import record
logger = logging.getLogger(__name__)
TYPES = (
("docker.deployed", "Deployed", "change", "service", True,
"A container or swarm service started on a new image, or a container was recreated."),
("docker.crash_loop", "Crash-looping", "observed", "service", True,
"A container kept restarting or dying: one entry for the whole episode."),
("docker.restarted", "Container restarted or died", "observed", "service", False,
"A single restart, death or out-of-memory kill."),
)
CRASH_WINDOW = timedelta(hours=1)
_TROUBLE = ("restart", "die", "oom")
def register() -> None:
for t in TYPES:
catalog.register_type(*t)
def unit(c: dict) -> str:
"""What a container is part of: its swarm service, else itself."""
return c.get("service_name") or c.get("name")
def short_image(image: str | None) -> str:
"""An image as a person reads it: the name and tag, and a few characters
of the digest when there is one."""
if not image:
return "an unknown image"
name, _, digest = image.partition("@")
name = name.rsplit("/", 1)[-1]
if digest:
return f"{name} ({digest.split(':', 1)[-1][:12]})"
return name
def deploys(old_rows: list, latest: list[dict]) -> list[dict]:
"""Each unit whose containers now run an image they didn't, or a
container recreated under the same name: {unit, name, image, was,
recreated, swarm}. A swarm task landing on a host that ran none of its
service before is the scheduler moving it, not a deploy."""
images: dict[str, set] = {}
ids = {}
for r in old_rows:
images.setdefault(r.service_name or r.name, set()).add(r.image)
ids[r.name] = (r.container_id, r.image)
out, seen = [], set()
for c in latest:
if (c.get("status") or "").lower() != "running" or not c.get("name"):
continue
key, image = unit(c), c.get("image") or ""
if key in seen:
continue
before = images.get(key)
old_id, old_image = ids.get(c["name"], (None, None))
if before and image not in before:
was = old_image if old_image and old_image != image else sorted(before)[0]
out.append({"unit": key, "name": c["name"], "image": image, "was": was,
"recreated": False, "swarm": bool(c.get("service_name"))})
elif old_id and c.get("container_id") and old_id != c["container_id"] and not c.get("service_name"):
out.append({"unit": key, "name": c["name"], "image": image, "was": old_image,
"recreated": True, "swarm": False})
elif before is None and not c.get("service_name"):
out.append({"unit": key, "name": c["name"], "image": image, "was": None,
"recreated": False, "swarm": False})
else:
continue
seen.add(key)
return out
def _deploy_summary(d: dict, host_name: str) -> str:
what = f"{d['unit']} on {host_name}"
if d["recreated"]:
return f"{what}: recreated on {short_image(d['image'])}"
if d["was"] is None:
return f"{what}: started on {short_image(d['image'])}"
new, old = short_image(d["image"]), short_image(d["was"])
if d["image"].split("@")[0] == (d["was"] or "").split("@")[0]:
return f"{what}: new build of {new}"
return f"{what}: {old} → {new}"
async def _trouble(session, host, key: str, name: str, at, said: str, deaths: int) -> None:
"""One restart, death or OOM kill of `key`: extends its crash-loop, opens
one when it is the `deaths`-th within CRASH_WINDOW, else a hidden entry."""
subject = f"{host.name}/{key}"
loop = (await session.execute(select(TimelineEntry).where(
TimelineEntry.type == "docker.crash_loop", TimelineEntry.subject == subject,
TimelineEntry.until >= at - CRASH_WINDOW).order_by(TimelineEntry.at.desc()).limit(1))
).scalar_one_or_none()
if loop is not None:
count = (loop.detail or {}).get("count", 0) + 1
loop.until = max(loop.until, at)
loop.detail = {**(loop.detail or {}), "count": count, "last": said}
loop.summary = f"{key} on {host.name} crash-looping: {count} restarts or deaths"
return
recent = (await session.execute(select(func.count(), func.min(TimelineEntry.at)).where(
TimelineEntry.type == "docker.restarted", TimelineEntry.subject == subject,
TimelineEntry.at >= at - CRASH_WINDOW))).one()
url = f"/plugins/docker/container/{host.id}/{name}"
if recent[0] + 1 >= deaths:
count = recent[0] + 1
record(session, type="docker.crash_loop", at=recent[1] or at, until=at,
summary=f"{key} on {host.name} crash-looping: {count} restarts or deaths",
hosts=[host.name], subject=subject, actor="Docker",
detail={"count": count, "last": said, "url": url})
return
record(session, type="docker.restarted", at=at, summary=f"{key} on {host.name}: {said}",
hosts=[host.name], subject=subject, actor="Docker", detail={"url": url})
_SAID = {"restart": "restarted", "die": "died", "oom": "killed, out of memory"}
async def from_snapshot(session, host, old_rows: list, latest: list[dict], events: list, at,
deaths: int) -> None:
"""The entries one snapshot makes: its deploys, and its restarts and
deaths (`events`: (name, event, detail, when)), in a savepoint so a
failure here never loses the snapshot. `deaths` makes a crash loop."""
try:
async with session.begin_nested():
for d in deploys(old_rows, latest):
url = "/plugins/docker/swarm" if d["swarm"] else f"/plugins/docker/container/{host.id}/{d['name']}"
record(session, type="docker.deployed", at=at, summary=_deploy_summary(d, host.name),
hosts=[host.name], subject=f"{host.name}/{d['unit']}", actor="Docker",
detail={"image": d["image"], "was": d["was"], "url": url})
units = {c.get("name"): unit(c) for c in latest if c.get("name")}
units.update({r.name: r.service_name or r.name for r in old_rows if r.name not in units})
for name, event, detail, when in events:
if event in _TROUBLE:
said = _SAID[event] + (f" ({detail})" if detail else "")
await _trouble(session, host, units.get(name, name), name, when or at, said, deaths)
await session.flush()
except Exception:
logger.exception("Docker on %s: timeline entries could not be written", host.name)