#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>
161 lines
6.8 KiB
Python
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]
|