feat(host_agent): a reboot reads as a reboot, and lost contact as its span (#5756)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 1m24s
CI / unit (push) Failing after 1m29s
CI / publish (push) Skipped

#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>
This commit is contained in:
2026-10-14 17:08:16 -04:00
co-authored by Claude Opus 5.5
parent 5dd9956f35
commit c7ff5bc556
16 changed files with 632 additions and 15 deletions
+9
View File
@@ -25,6 +25,15 @@ def setup(app: "Quart") -> None:
from steward.hosts.service import agent_stale_after_seconds
agent_heartbeat("host_agent", "seconds_since_seen", lambda: agent_stale_after_seconds(app.config))
agent_fed("host_agent", lambda resource: resource.split(":", 1)[0])
# A reboot the agent announced is silence with a reason, for a while (#5756).
from datetime import datetime, timezone
from steward.core.alerts import agents_away
from .downtime import hosts_away, restart_grace_seconds
agents_away(lambda session: hosts_away(session, datetime.now(timezone.utc),
restart_grace_seconds(app.config)))
from steward.core.cleanup import prune_by_age
from .models import HostDowntime
prune_by_age(HostDowntime.started_at)
def get_scheduled_tasks() -> list:
+73 -7
View File
@@ -12,6 +12,7 @@ import re
import shutil
import signal
import socket
import subprocess
import sys
import time
import urllib.error
@@ -20,7 +21,7 @@ from collections import deque
from concurrent.futures import ThreadPoolExecutor, wait as wait_futures
from datetime import datetime, timezone
AGENT_VERSION = "1.13.0"
AGENT_VERSION = "1.14.0"
# Default path to the local Docker Engine socket. Overridable via the
# `docker_socket` config key; collection is silently skipped if it's absent or
@@ -1330,16 +1331,70 @@ def build_sample(mounts: list[str], state: dict,
return sample
def build_payload(samples: list[dict], hostname: str, metadata: dict) -> dict:
def build_payload(samples: list[dict], hostname: str, metadata: dict,
events: list | None = None) -> dict:
# sent_at is this host's clock at the moment of sending: Steward compares
# it with its own on arrival to say how far apart the two clocks are.
return {
payload = {
"agent_version": AGENT_VERSION,
"hostname": hostname,
"metadata": metadata,
"sent_at": datetime.now(timezone.utc).isoformat(),
"samples": samples,
}
if events:
payload["events"] = events
return payload
# ─── what happened to the host (#5756) ───────────────────────────────────────
# Events ride the next post that gets through, so Steward records a reboot as a
# reboot and a lost connection as the span it lasted, not as the agent gone.
def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def started_event() -> dict:
"""The agent starting, with when the host booted: Steward tells a host
that rebooted from an agent that only restarted by it."""
event = {"kind": "started", "at": _now_iso()}
try:
booted = time.time() - float(_read_file(UPTIME_PATH).split()[0])
event["boot_at"] = datetime.fromtimestamp(booted, timezone.utc).isoformat()
except Exception:
pass
return event
def host_shutting_down():
"""Whether the host itself is going down, not only this agent: systemd says
"stopping" while it shuts down or reboots. None when it can't tell."""
try:
out = subprocess.run(["systemctl", "is-system-running"], stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL, universal_newlines=True,
timeout=3).stdout.strip()
except (OSError, subprocess.SubprocessError):
return None
return out == "stopping" if out else None
def stopping_event() -> dict:
return {"kind": "stopping", "at": _now_iso(), "host_shutdown": host_shutting_down()}
def lost_contact_event(since: str) -> dict:
"""The span this agent could not reach Steward: from its first failed post
to now, the post that is carrying it."""
return {"kind": "lost_contact", "from": since, "to": _now_iso()}
def lost_contact(status) -> bool:
"""Whether a failed post means Steward couldn't be reached: no answer, or
a proxy's 5xx in front of a Steward that is down. A 400 or 401 is Steward
answering."""
return status is None or status >= 500
# ─── POST + backoff ──────────────────────────────────────────────────────────
@@ -1441,6 +1496,9 @@ def main_loop(conf_path: str) -> int:
docker_log_exclude = cfg.get("docker_log_exclude") or []
buffer = RingBuffer(maxlen=20)
backoff = 0
# Said once each, with the next post that gets through (#5756).
events: list = [started_event()]
lost_since = None
# Carries previous net/disk counters + monotonic ts for rate computation.
rate_state: dict = {}
@@ -1470,10 +1528,14 @@ def main_loop(conf_path: str) -> int:
samples=send + [sample],
hostname=hostname,
metadata=metadata,
events=events + ([lost_contact_event(lost_since)] if lost_since else []),
)
ok, status = post_payload(cfg["url"], cfg["token"], payload)
if ok:
if lost_since:
_log("INFO", f"reached Steward again (unreachable since {lost_since})")
events, lost_since = [], None
for s in held:
buffer.push(s)
if send:
@@ -1482,6 +1544,8 @@ def main_loop(conf_path: str) -> int:
backoff = 0
sleep_for = cfg["interval_seconds"]
else:
if lost_since is None and lost_contact(status):
lost_since = sample["ts"]
if status == 400:
_log("ERROR", "server rejected payload (400) — dropping sample")
elif status == 401:
@@ -1501,10 +1565,12 @@ def main_loop(conf_path: str) -> int:
slept += 1.0
_log("INFO", "SIGTERM — flushing and exiting")
final = buffer.drain()
if final:
post_payload(cfg["url"], cfg["token"],
build_payload(final, hostname, metadata))
# Say it is stopping, and whether the host is going down with it, so Steward
# reads the silence that follows as a reboot rather than the agent lost.
final_events = events + ([lost_contact_event(lost_since)] if lost_since else [])
final_events.append(stopping_event())
post_payload(cfg["url"], cfg["token"],
build_payload(buffer.drain(), hostname, metadata, events=final_events))
return 0
+160
View File
@@ -0,0 +1,160 @@
# 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]
@@ -0,0 +1,32 @@
# plugins/host_agent/migrations/versions/host_agent_004_downtime.py
"""host_agent: each host's downtime and what it was (#5756)
Revision ID: host_agent_004_downtime
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "host_agent_004_downtime"
down_revision: Union[str, None] = "host_agent_003_clock_offset"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"host_downtime",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("host_id", sa.String(36), sa.ForeignKey("hosts.id", ondelete="CASCADE"),
nullable=False),
sa.Column("kind", sa.String(16), nullable=False),
sa.Column("started_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("ended_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
)
op.create_index("ix_host_downtime_host_id", "host_downtime", ["host_id"])
def downgrade() -> None:
op.drop_index("ix_host_downtime_host_id", table_name="host_downtime")
op.drop_table("host_downtime")
+19
View File
@@ -37,3 +37,22 @@ class HostAgentRegistration(Base):
created_at = Column(DateTime(timezone=True), nullable=False, default=_utcnow)
updated_at = Column(DateTime(timezone=True), nullable=False,
default=_utcnow, onupdate=_utcnow)
class HostDowntime(Base):
"""A span a host's agent was away, and what the agent said it was (#5756).
kind is "reboot" (the host went down and came back), "agent_stop" (the
agent stopped; the host stayed up), "unclean" (the host came back from a
boot nobody announced: power loss or a crash) or "lost_contact" (the agent
ran but couldn't reach Steward). ended_at is None while it is going on.
"""
__tablename__ = "host_downtime"
id = Column(String(36), primary_key=True, default=_uuid)
host_id = Column(String(36), ForeignKey("hosts.id", ondelete="CASCADE"),
nullable=False, index=True)
kind = Column(String(16), nullable=False)
started_at = Column(DateTime(timezone=True), nullable=False)
ended_at = Column(DateTime(timezone=True), nullable=True)
created_at = Column(DateTime(timezone=True), nullable=False, default=_utcnow)
+3
View File
@@ -16,4 +16,7 @@ tags:
config:
stale_after_seconds: 180
# How long a reboot or restart the agent announced explains its silence
# before "agent offline" fires (agent >= 1.14.0, #5756).
reboot_grace_seconds: 600
default_interval_seconds: 30
+13 -3
View File
@@ -24,6 +24,7 @@ from steward.core.time_range import parse_range, RANGE_OPTIONS
from steward.hosts import service as host_svc
from steward.models.hosts import Host
from steward.models.metrics import PluginMetric
from .downtime import recent as recent_downtime, record_events
from .models import HostAgentRegistration
from .scheduler import arrivals
# Query helpers live in a model-free module so integration tests can import them
@@ -232,7 +233,10 @@ async def ingest():
return _error(400, "malformed_payload", "missing 'samples'")
samples = payload.get("samples") or []
if not isinstance(samples, list) or not samples:
# What happened to the host (agent ≥ 1.14.0, #5756). A stopping agent's last
# post may carry its events alone, with no sample left to send.
events = payload.get("events") if isinstance(payload.get("events"), list) else []
if not isinstance(samples, list) or not (samples or events):
return _error(400, "malformed_payload", "'samples' must be a non-empty list")
token_hash = _hash_token(raw)
@@ -305,7 +309,7 @@ async def ingest():
if latest_ts is None or recorded_at > latest_ts:
latest_ts = recorded_at
if accepted == 0:
if accepted == 0 and not events:
await session.rollback()
return _error(400, "malformed_payload", "no valid samples")
# How far this host's clock is from Steward's, either way, so an
@@ -346,6 +350,11 @@ async def ingest():
host.name)
phases.mark("docker")
# Before last_seen_at moves: a boot after it that nobody announced
# was a restart without a shutdown.
if events:
await record_events(session, host.id, events, reg.last_seen_at)
md = payload.get("metadata") or {}
changed = False
if latest_ts and (reg.last_seen_at is None or latest_ts > reg.last_seen_at):
@@ -818,6 +827,7 @@ async def host_panel(host_id: str):
HostAgentRegistration.host_id == host_id))).scalar_one_or_none()
target = (await db.execute(select(AnsibleTarget).where(
AnsibleTarget.host_id == host_id))).scalar_one_or_none()
downtime = await recent_downtime(db, host_id, datetime.now(timezone.utc)) if reg else []
ls = reg.last_seen_at if reg else None
# "Deployed" is gated on the agent actually checking in (last_seen_at), NOT on
@@ -829,7 +839,7 @@ async def host_panel(host_id: str):
ansible_cfg = current_app.config.get("ANSIBLE", {})
return await render_template(
"panel.html",
host=host, reg=reg, reporting=reporting, stale=stale, target=target,
host=host, reg=reg, reporting=reporting, stale=stale, target=target, downtime=downtime,
ansible_available=has_capability("ansible.run_playbook"),
managed_key_set=bool((ansible_cfg.get("ssh_public_key") or "").strip()),
)
+11 -1
View File
@@ -12,6 +12,7 @@ from steward.core.watch import watch
from steward.hosts.service import agent_stale_after_seconds
from steward.models.hosts import Host
from steward.models.metrics import PluginMetricLatest
from .downtime import announced_stops, restart_grace_seconds
from .models import HostAgentRegistration
logger = logging.getLogger(__name__)
@@ -36,6 +37,7 @@ def seconds_since_seen(regs: Iterable, host_names: dict[str, str],
now: datetime, watching_since: datetime | None = None,
arrived: dict[str, datetime] | None = None,
already_silent: set[str] | frozenset = frozenset(),
explained_until: dict[str, datetime] | None = None,
) -> list[tuple[str, float]]:
"""(host name, seconds since its agent last reported) per registration.
@@ -52,6 +54,11 @@ def seconds_since_seen(regs: Iterable, host_names: dict[str, str],
whole silence counts. Counting them from `watching_since` read them as
just seen, which resolved their alert on every restart and fired it again
three minutes later (#5719).
`explained_until` ({host id: time}) is a stop the agent announced, a reboot
or its own restart, plus its grace (#5756): until then the agent reads as
just seen. A host still silent past it reads its whole silence, so agent
offline fires as the grace runs out, for silence nobody explained.
"""
out = []
for r in regs:
@@ -62,6 +69,8 @@ def seconds_since_seen(regs: Iterable, host_names: dict[str, str],
continue
if watching_since is not None and name not in already_silent:
seen = max(seen, watching_since)
if r.host_id in (explained_until or {}):
seen = max(seen, explained_until[r.host_id])
out.append((name, max(0.0, (now - seen).total_seconds())))
return out
@@ -88,9 +97,10 @@ async def record_seconds_since_seen(app) -> None:
PluginMetricLatest.metric_name == "seconds_since_seen",
PluginMetricLatest.resource_name.in_(list(host_names.values())),
PluginMetricLatest.value > stale_after))).scalars())
explained = await announced_stops(session, now, restart_grace_seconds(app.config))
batch = MetricBatch()
for name, seconds in seconds_since_seen(regs, host_names, now, watching_since,
arrivals(app), already_silent):
arrivals(app), already_silent, explained):
batch.add(source_module="host_agent", resource_name=name,
metric_name="seconds_since_seen", value=seconds, recorded_at=now)
await batch.record(session)
+14
View File
@@ -19,6 +19,20 @@
"Manage agent" (#5278). #}
<a href="/plugins/host_agent/{{ host.id }}/" class="btn btn-sm btn-ghost">Full metrics →</a>
{# What the agent said happened while it was away, last 7 days (#5756). #}
{% if downtime %}
<div style="margin-top:0.8rem;">
<div style="font-size:0.78rem;color:var(--text-muted);margin-bottom:0.3rem;">Downtime, last 7 days</div>
{% for d in downtime %}
<div style="display:flex;gap:0.6rem;align-items:baseline;font-size:0.82rem;padding:0.15rem 0;">
<span class="{{ 'tone-warn' if d.ongoing or d.kind == 'unclean' else '' }}" style="min-width:12rem;">{{ d.label }}</span>
<span style="color:var(--text-muted);">{{ d.started_at|when }}</span>
<span style="color:var(--text-muted);margin-left:auto;">{{ 'going on, ' if d.ongoing }}{{ d.seconds|duration }}</span>
</div>
{% endfor %}
</div>
{% endif %}
{% if session.user_role == 'admin' %}
<details style="margin-top:0.7rem;">
<summary style="cursor:pointer;font-size:0.82rem;color:var(--text-muted);">Manage agent</summary>