Files
FabledSteward/plugins/host_agent/downtime.py
T
bvandeusenandClaude Opus 5.5 c7ff5bc556
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 1m24s
CI / unit (push) Failing after 1m29s
CI / publish (push) Skipped
feat(host_agent): a reboot reads as a reboot, and lost contact as its span (#5756)
#5756 "The agent says when its host rebooted and when it lost contact, so
downtime is recorded as what it was". Agent 1.14.0 posts events with the
next post that gets through: started (with the host's boot time), stopping
(on SIGTERM, with whether systemd says the host is going down) and
lost_contact (from its first unanswered post to the one carrying it).

Steward keeps them as the host's downtime (host_downtime): reboot,
agent_stop, unclean (a boot after the agent was last heard that nobody
announced) or lost_contact. Retried posts apply once.

An announced stop explains the silence for reboot_grace_seconds (Settings
-> Plugins -> host_agent, 10 minutes): seconds_since_seen reads 0 until
then, and the host's other rules go quiet with it (agents_away beside
#5721's registry). Past the grace, agent offline fires.

The host's Agent card lists the last 7 days of downtime. Rows are pruned
at data.retention_days.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:08:16 -04:00

161 lines
6.8 KiB
Python

# plugins/host_agent/downtime.py
"""What a host's agent says happened to it while it was away (#5756).
An agent ≥ 1.14.0 tells Steward when it stops (and whether its host is going
down with it), when it starts (and when its host booted), and how long it
couldn't reach Steward. Each becomes a HostDowntime row, so a reboot reads as
a reboot with its length, and "agent offline" is left for silence nobody
explained. A post can be retried, so every event is applied idempotently.
"""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from sqlalchemy import select
from .models import HostDowntime
# A stop the agent announced: the silence after it is explained, for a while.
ANNOUNCED = ("reboot", "agent_stop")
LABELS = {
"reboot": "Rebooted",
"agent_stop": "Agent stopped",
"unclean": "Restarted without shutting down",
"lost_contact": "Lost contact",
}
def restart_grace_seconds(config) -> int:
"""How long an announced stop explains the agent's silence (Settings →
Plugins → host_agent, default 10 minutes): past it, agent offline fires."""
return int(((config.get("PLUGINS") or {}).get("host_agent") or {})
.get("reboot_grace_seconds", 600))
def _at(value) -> datetime | None:
if not isinstance(value, str):
return None
try:
got = datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError:
return None
return got if got.tzinfo else got.replace(tzinfo=timezone.utc)
def _when(event: dict) -> datetime | None:
return _at(event.get("at") or event.get("from"))
async def record_events(session, host_id: str, events, last_seen_at: datetime | None) -> int:
"""Apply one post's events to the host's downtime, oldest first; returns
how many rows were added or changed. `last_seen_at` is when the agent was
last heard before this post: a boot after it that nobody announced is a
restart without a shutdown."""
if not isinstance(events, list):
return 0
timed = sorted(((t, e) for e in events if isinstance(e, dict)
for t in [_when(e)] if t is not None), key=lambda te: te[0])
changed = 0
for at, event in timed:
kind = event.get("kind")
if kind == "stopping":
changed += await _stopping(session, host_id, at, event.get("host_shutdown"))
elif kind == "started":
changed += await _started(session, host_id, at, _at(event.get("boot_at")), last_seen_at)
elif kind == "lost_contact":
changed += await _lost_contact(session, host_id, at, _at(event.get("to")))
return changed
async def _stopping(session, host_id: str, at: datetime, host_shutdown) -> int:
seen = (await session.execute(select(HostDowntime.id).where(
HostDowntime.host_id == host_id, HostDowntime.kind.in_(ANNOUNCED),
HostDowntime.started_at == at))).first()
if seen:
return 0
session.add(HostDowntime(host_id=host_id, kind="reboot" if host_shutdown else "agent_stop",
started_at=at))
return 1
async def _started(session, host_id: str, at: datetime, boot_at: datetime | None,
last_seen_at: datetime | None) -> int:
stop = (await session.execute(select(HostDowntime).where(
HostDowntime.host_id == host_id, HostDowntime.kind.in_(ANNOUNCED),
HostDowntime.ended_at.is_(None), HostDowntime.started_at <= at)
.order_by(HostDowntime.started_at.desc()).limit(1))).scalar_one_or_none()
if stop is not None:
stop.ended_at = at
# The boot time settles what the stop was: the host came back from a
# boot after it, or it stayed up while only the agent restarted.
if boot_at is not None:
stop.kind = "reboot" if boot_at > stop.started_at else "agent_stop"
return 1
if boot_at is None or last_seen_at is None or boot_at <= last_seen_at:
return 0
seen = (await session.execute(select(HostDowntime.id).where(
HostDowntime.host_id == host_id, HostDowntime.kind == "unclean",
HostDowntime.ended_at == at))).first()
if seen:
return 0
session.add(HostDowntime(host_id=host_id, kind="unclean", started_at=last_seen_at, ended_at=at))
return 1
async def _lost_contact(session, host_id: str, since: datetime, until: datetime | None) -> int:
if until is None or until <= since:
return 0
row = (await session.execute(select(HostDowntime).where(
HostDowntime.host_id == host_id, HostDowntime.kind == "lost_contact",
HostDowntime.started_at == since))).scalar_one_or_none()
if row is None:
session.add(HostDowntime(host_id=host_id, kind="lost_contact", started_at=since, ended_at=until))
return 1
if row.ended_at is None or until > row.ended_at:
row.ended_at = until
return 1
return 0
async def announced_stops(session, now: datetime, grace_seconds: int) -> dict[str, datetime]:
"""{host id: when the silence its agent announced stops being explained}
for every announced stop still going and inside its grace."""
rows = (await session.execute(select(HostDowntime.host_id, HostDowntime.started_at).where(
HostDowntime.kind.in_(ANNOUNCED), HostDowntime.ended_at.is_(None),
HostDowntime.started_at > now - timedelta(seconds=grace_seconds)))).all()
out: dict[str, datetime] = {}
for host_id, started in rows:
until = started + timedelta(seconds=grace_seconds)
out[host_id] = max(out.get(host_id, until), until)
return out
async def hosts_away(session, now: datetime, grace_seconds: int) -> set[str]:
"""Names of the hosts in announced_stops: their silence has a reason."""
from steward.models.hosts import Host
ids = list(await announced_stops(session, now, grace_seconds))
if not ids:
return set()
return set((await session.execute(select(Host.name).where(Host.id.in_(ids)))).scalars())
def describe(row: HostDowntime, now: datetime) -> dict:
"""One downtime row as the host page shows it."""
label = LABELS.get(row.kind, row.kind)
if row.kind == "agent_stop" and row.ended_at is not None:
label = "Agent restarted"
end = row.ended_at or now
return {"kind": row.kind, "label": label, "started_at": row.started_at,
"ended_at": row.ended_at, "ongoing": row.ended_at is None,
"seconds": max(0.0, (end - row.started_at).total_seconds())}
async def recent(session, host_id: str, now: datetime, days: int = 7, limit: int = 6) -> list[dict]:
"""The host's downtime in the last `days`, newest first."""
rows = (await session.execute(select(HostDowntime).where(
HostDowntime.host_id == host_id,
HostDowntime.started_at > now - timedelta(days=days))
.order_by(HostDowntime.started_at.desc()).limit(limit))).scalars().all()
return [describe(r, now) for r in rows]