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
+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()),
)