Files
FabledSteward/steward/timeline/service.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

229 lines
9.8 KiB
Python

"""Reading the timeline, and keeping it: the filtered list the page, the host
card and get_timeline share, the first fill from records that predate it,
its retention, and a host rename carried onto its entries."""
from __future__ import annotations
import logging
from datetime import datetime, timedelta, timezone
from sqlalchemy import delete, func, select, text, update
from sqlalchemy.dialects.postgresql import insert
from . import catalog
from .config import Config, kind_types
from steward.models.timeline import TimelineEntry
logger = logging.getLogger(__name__)
# How far back the first fill reaches: the default retention of the records it reads.
BACKFILL_DAYS = 90
def query(cfg: Config, *, host: str = "", area: str = "", kind: str = "", type_: str = "",
since: datetime | None = None, include_hidden: bool = False):
"""The entries matching the filters, unordered (core.paging orders them).
Hidden types are left out unless `include_hidden`, or a `type_` asks for one."""
stmt = select(TimelineEntry)
if host:
stmt = stmt.where(TimelineEntry.hosts.contains([host]))
if area:
stmt = stmt.where(cfg.area_filter(area))
if kind:
stmt = stmt.where(TimelineEntry.type.in_(kind_types(kind)))
if type_:
stmt = stmt.where(TimelineEntry.type == type_)
elif not include_hidden:
hidden = cfg.hidden_types()
if hidden:
stmt = stmt.where(TimelineEntry.type.notin_(hidden))
if since is not None:
stmt = stmt.where(TimelineEntry.at >= since)
return stmt
def _ref_models() -> dict:
"""The full records an entry can point to: core's, and the ones loaded
plugins registered (catalog.register_ref)."""
from steward.models.alerts import AlertEvent
from steward.models.ansible import AnsibleRun
from steward.models.audit import AuditEvent
from steward.models.norms import MetricAnomaly
return {"alert_event": AlertEvent, "ansible_run": AnsibleRun, "audit_event": AuditEvent,
"metric_anomaly": MetricAnomaly, **catalog.REFS}
async def kept_refs(db, entries) -> set[tuple[str, str]]:
"""The (ref_type, ref_id) of `entries` whose full record still exists."""
wanted: dict[str, set[str]] = {}
for e in entries:
if e.ref_type and e.ref_id:
wanted.setdefault(e.ref_type, set()).add(e.ref_id)
kept = set()
models = _ref_models()
for ref_type, ids in wanted.items():
model = models.get(ref_type)
if model is None: # a plugin no longer loaded: assume it is kept
kept |= {(ref_type, i) for i in ids}
continue
found = (await db.execute(select(model.id).where(model.id.in_(ids)))).scalars()
kept |= {(ref_type, str(i)) for i in found}
return kept
def view(entry: TimelineEntry, cfg: Config, kept: set) -> dict:
"""One entry as the page and the MCP tool read it."""
known = catalog.TYPES.get(entry.type)
area = cfg.area_of(entry)
detail = entry.detail or {}
url = detail.get("url")
gone = bool(entry.ref_type) and (entry.ref_type, entry.ref_id) not in kept
return {
"entry": entry,
"type_label": known.label if known else entry.type,
"kind": known.kind if known else None,
"kind_label": catalog.KINDS.get(known.kind) if known else None,
"area": area,
"area_label": cfg.area_label(area),
"note": detail.get("note"),
"url": None if gone else url,
"gone": gone,
}
async def views(db, cfg: Config, entries) -> list[dict]:
kept = await kept_refs(db, entries)
return [view(e, cfg, kept) for e in entries]
async def recent_for_host(db, cfg: Config, host: str, limit: int = 8) -> list[dict]:
"""A host's newest shown entries, for its page."""
rows = (await db.execute(
query(cfg, host=host).order_by(TimelineEntry.at.desc(), TimelineEntry.id.desc())
.limit(limit))).scalars().all()
return await views(db, cfg, rows)
# ── Keeping it ──────────────────────────────────────────────────────────────
async def prune(session, now: datetime) -> tuple[int, int]:
"""Delete entries past timeline.retention_days; (rows, days), with 0 days
keeping everything. Call inside a transaction."""
from steward.core.settings import get_setting
days = int(await get_setting(session, "timeline.retention_days") or 0)
if days <= 0:
return 0, 0
result = await session.execute(
delete(TimelineEntry).where(TimelineEntry.at < now - timedelta(days=days)))
return result.rowcount or 0, days
async def carry_host_rename(session, old: str, new: str) -> int:
"""Point a renamed host's entries at its new name, so its page still
shows its history."""
if not old or old == new:
return 0
result = await session.execute(
update(TimelineEntry).where(TimelineEntry.hosts.contains([old]))
.values(hosts=func.array_replace(TimelineEntry.hosts, old, new))
.execution_options(synchronize_session=False))
return result.rowcount or 0
async def _core_entries(session, since: datetime) -> list:
"""Entries for the alert events, finished Ansible runs and audit rows
since `since`, built by the live writers' own builders. One record that
can't be read is skipped, not the whole fill."""
from steward.models.alerts import AlertEvent, AlertRule
from steward.models.ansible import AnsibleRun
from steward.models.audit import AuditEvent
from .record import from_alert, from_audit, from_run
built: list = []
async def build(what: str, coro) -> None:
try:
built.append(await coro)
except Exception:
logger.exception("Timeline fill: %s skipped", what)
for event, rule in (await session.execute(
select(AlertEvent, AlertRule).join(AlertRule, AlertEvent.rule_id == AlertRule.id)
.where(AlertEvent.transitioned_at >= since))).all():
await build(f"alert event {event.id}", from_alert(session, rule, event))
for run in (await session.execute(
select(AnsibleRun).where(AnsibleRun.started_at >= since,
AnsibleRun.finished_at.is_not(None)))).scalars().all():
await build(f"Ansible run {run.id}", from_run(session, run))
for event in (await session.execute(
select(AuditEvent).where(AuditEvent.timestamp >= since))).scalars().all():
await build(f"audit row {event.id}", from_audit(session, event, _audit_detail(event)))
return built
def fill_sources() -> dict:
"""{source: its `(session, since) -> [entries]`}: core's, and each loaded
plugin's `timeline_backfill`."""
from steward.core.plugin_manager import get_loaded_plugin_modules
sources = {"steward": _core_entries}
for name, module in sorted(get_loaded_plugin_modules().items()):
hook = getattr(module, "timeline_backfill", None)
if callable(hook):
sources[name] = hook
return sources
async def backfill(app, now: datetime | None = None, sources: dict | None = None) -> int:
"""Fill the timeline from the records that predate it, BACKFILL_DAYS
back, once per source: core's, then each loaded plugin's. Which sources
are done, and when, is kept in timeline.filled, so a plugin loaded later
fills on the next turn. An entry for a record already covered is
skipped, so a source filled again adds nothing twice. Returns how many
entries were added."""
from steward.core.settings import get_setting, set_setting
now = now or datetime.now(timezone.utc)
since = now - timedelta(days=BACKFILL_DAYS)
sources = fill_sources() if sources is None else sources
added = 0
async with app.db_sessionmaker() as session:
async with session.begin():
filled = dict(await get_setting(session, "timeline.filled") or {})
# The builders add each entry to the session; no flush may write
# them before the insert below, which skips any already covered.
session.sync_session.autoflush = False
for name, source in sources.items():
if name in filled:
continue
try:
async with session.begin():
rows = [e for e in await source(session, since) if e is not None]
for entry in rows:
session.expunge(entry)
for chunk in range(0, len(rows), 500):
result = await session.execute(
insert(TimelineEntry).values([_row(e) for e in rows[chunk:chunk + 500]])
.on_conflict_do_nothing(index_elements=["ref_type", "ref_id"],
index_where=text("ref_id IS NOT NULL")))
added += result.rowcount or 0
filled[name] = now.isoformat()
await set_setting(session, "timeline.filled", filled)
logger.info("Timeline: filled from %s, the last %d days", name, BACKFILL_DAYS)
except Exception:
logger.exception("Timeline: the fill from %s failed; it is tried again tomorrow", name)
return added
def _audit_detail(event) -> dict:
import json
try:
return json.loads(event.detail_json) if event.detail_json else {}
except (TypeError, ValueError):
return {}
def _row(entry: TimelineEntry) -> dict:
from steward.core.ids import time_ordered_id
return {"id": entry.id or time_ordered_id(), "at": entry.at, "until": entry.until,
"type": entry.type, "area": entry.area, "hosts": entry.hosts or [],
"subject": entry.subject, "summary": entry.summary, "detail": entry.detail,
"actor": entry.actor or "system", "ref_type": entry.ref_type, "ref_id": entry.ref_id}