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
+11 -1
View File
@@ -113,9 +113,19 @@ Python 3.8+ stdlib only. `urllib.request` for the POST, plus `json`, `os`, `shut
### Signals and lifecycle
- **SIGHUP** → re-read config file without restart (lets admins change interval/mounts cleanly).
- **SIGTERM** (from systemd) → finish current poll cycle → flush buffer one last time → exit.
- **SIGTERM** (from systemd) → finish current poll cycle → flush buffer one last time, with a `stopping` event saying whether the host is going down too (`systemctl is-system-running` says `stopping`) → exit.
- **Systemd restart policy** → `Restart=on-failure`, `RestartSec=10`.
### What happened to the host (1.14.0, #5756)
A post may carry `events`, said once each, with the next post that gets through:
- `started` — the agent starting, with `boot_at` (from `/proc/uptime`).
- `stopping` — on SIGTERM, with `host_shutdown`: true, false, or null when systemd can't say.
- `lost_contact` — `from` the first post that got no answer (or a proxy's 5xx) `to` the post carrying it.
Steward turns them into the host's downtime (`host_downtime`): **reboot** (a stop, then a boot after it), **agent_stop** (the host stayed up), **unclean** (a boot after the agent was last heard that nobody announced: power loss or a crash) and **lost_contact**. 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, as when it is offline. Past the grace, agent offline fires. The events live in memory, so a span the agent saw is lost if it restarts mid-outage; the boot time still reads the restart as unclean.
### Logging
To stderr only (systemd captures to journal). No file logging, no log rotation. Levels:
+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>
+14 -3
View File
@@ -4,7 +4,7 @@ import logging
import uuid
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from typing import TYPE_CHECKING, Callable
from typing import TYPE_CHECKING, Awaitable, Callable
from sqlalchemy import event as sa_event, select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -53,6 +53,7 @@ class Heartbeat:
_HEARTBEAT: Heartbeat | None = None
_AGENT_FED: dict[str, Callable[[str], str]] = {}
_AWAY: Callable[[AsyncSession], Awaitable[set[str]]] | None = None
def agent_heartbeat(source_module: str, metric_name: str,
@@ -69,6 +70,14 @@ def agent_fed(source_module: str, host_of: Callable[[str], str]) -> None:
_AGENT_FED[source_module] = host_of
def agents_away(hosts_away: Callable[[AsyncSession], Awaitable[set[str]]]) -> None:
"""`await hosts_away(session)` names the hosts whose agent said it was
stopping and is still inside its grace (#5756): their silence has a
reason, so their other rules go quiet with it as when it is offline."""
global _AWAY
_AWAY = hosts_away
def is_heartbeat(rule) -> bool:
"""Whether `rule` watches the agent heartbeat ("agent offline")."""
return _HEARTBEAT is not None and (rule.source_module, rule.metric_name) == (
@@ -85,14 +94,16 @@ def host_behind(rule) -> str | None:
async def offline_agents(session) -> set[str]:
"""Hosts whose agent's last heartbeat was past its offline window."""
"""Hosts whose agent's last heartbeat was past its offline window, and
those whose agent said it was stopping (agents_away)."""
if _HEARTBEAT is None:
return set()
return set((await session.execute(
offline = set((await session.execute(
select(PluginMetricLatest.resource_name).where(
PluginMetricLatest.source_module == _HEARTBEAT.source_module,
PluginMetricLatest.metric_name == _HEARTBEAT.metric_name,
PluginMetricLatest.value > _HEARTBEAT.offline_after()))).scalars())
return offline | (await _AWAY(session) if _AWAY is not None else set())
async def _went_quiet_with_its_agent(session, rule_id: str) -> bool:
+27
View File
@@ -208,3 +208,30 @@ def test_sort_hosts_puts_missing_values_last_either_way():
assert names("cpu", False) == ["alpha", "bravo", "charlie"] # no reading: last both ways
assert names("name", True) == ["charlie", "bravo", "alpha"]
assert names("status", False)[0] == "charlie" # down first
async def test_the_agent_card_says_what_happened_while_the_host_was_away(app):
"""#5756: a reboot reads as a reboot with its length, one still going says so."""
from datetime import datetime, timedelta, timezone
from tests._templates import render_plugin_template
now = datetime.now(timezone.utc)
host = SimpleNamespace(id="h1", name="box", address="10.0.0.5")
reg = SimpleNamespace(agent_version="1.14.0", last_seen_at=now)
downtime = [
{"kind": "reboot", "label": "Rebooted", "started_at": now - timedelta(minutes=5),
"ended_at": None, "ongoing": True, "seconds": 300.0},
{"kind": "lost_contact", "label": "Lost contact", "started_at": now - timedelta(hours=3),
"ended_at": now - timedelta(hours=2), "ongoing": False, "seconds": 3600.0},
]
async with app.test_request_context("/plugins/host_agent/panel/h1"):
session["user_role"] = "viewer"
page = await render_plugin_template(
app, "host_agent", "panel.html", host=host, reg=reg, reporting=True, stale=False,
target=None, ansible_available=False, managed_key_set=False, downtime=downtime)
quiet = await render_plugin_template(
app, "host_agent", "panel.html", host=host, reg=reg, reporting=True, stale=False,
target=None, ansible_available=False, managed_key_set=False, downtime=[])
assert "Downtime, last 7 days" in page
assert page.index("Rebooted") < page.index("Lost contact")
assert "going on, 5m" in page and "1h" in page
assert "Downtime, last 7 days" not in quiet
+146
View File
@@ -0,0 +1,146 @@
"""Integration (#5756): what a host's agent says happened while it was away
becomes the host's downtime, once, whatever the agent retries.
The registration is seeded and the rows read with plain SQL: importing the
plugin's models again after the app loaded the plugin raises "Table already
defined" (see test_host_agent_alerts)."""
from __future__ import annotations
import asyncio
import hashlib
import os
import uuid
from datetime import datetime, timedelta, timezone
import pytest
pytestmark = pytest.mark.integration
@pytest.fixture
def app():
if not os.environ.get("STEWARD_DATABASE_URL"):
pytest.skip("needs Postgres")
from steward.app import create_app
return create_app(testing=False)
async def _agent(app, last_seen: datetime):
from sqlalchemy import text
from steward.models.hosts import Host
tag = uuid.uuid4().hex[:8]
token = f"tok-{tag}-{uuid.uuid4().hex}"
async with app.db_sessionmaker() as s:
async with s.begin():
host = Host(name=f"down-{tag}", address="10.9.9.8")
s.add(host)
await s.flush()
await s.execute(text(
"INSERT INTO host_agent_registrations "
"(id, host_id, token_hash, token_created_at, last_seen_at, created_at, updated_at) "
"VALUES (:id, :host, :hash, now(), :seen, now(), now())"),
{"id": str(uuid.uuid4()), "host": host.id, "seen": last_seen,
"hash": hashlib.sha256(token.encode()).hexdigest()})
host_id = host.id
return host_id, token
async def _post(app, token, samples, events):
resp = await app.test_client().post(
"/plugins/host_agent/ingest", json={"samples": samples, "events": events},
headers={"Authorization": f"Bearer {token}"})
assert resp.status_code == 200, await resp.get_data(as_text=True)
async def _rows(app, host_id):
from sqlalchemy import text
async with app.db_sessionmaker() as s:
async with s.begin():
return (await s.execute(text(
"SELECT kind, started_at, ended_at FROM host_downtime "
"WHERE host_id = :h ORDER BY started_at"), {"h": host_id})).all()
def test_a_reboot_the_agent_announced_is_one_reboot_with_its_length(app):
async def _go():
now = datetime.now(timezone.utc)
stop, boot, back = now - timedelta(minutes=5), now - timedelta(minutes=3), now - timedelta(minutes=2)
host_id, token = await _agent(app, stop)
stopping = {"kind": "stopping", "at": stop.isoformat(), "host_shutdown": True}
# The stopping agent's last post carries no sample, and is retried.
await _post(app, token, [], [stopping])
await _post(app, token, [], [stopping])
assert [(k, e) for k, _, e in await _rows(app, host_id)] == [("reboot", None)]
started = {"kind": "started", "at": back.isoformat(), "boot_at": boot.isoformat()}
await _post(app, token, [{"ts": back.isoformat(), "cpu_pct": 3.0}], [started])
assert await _rows(app, host_id) == [("reboot", stop, back)]
asyncio.run(_go())
def test_an_agent_restart_on_a_host_that_stayed_up_is_the_agent(app):
async def _go():
now = datetime.now(timezone.utc)
stop, back = now - timedelta(seconds=40), now - timedelta(seconds=30)
host_id, token = await _agent(app, stop)
# systemd couldn't say; the boot time, days before the stop, settles it.
await _post(app, token, [], [{"kind": "stopping", "at": stop.isoformat(), "host_shutdown": None}])
await _post(app, token, [{"ts": back.isoformat(), "cpu_pct": 3.0}],
[{"kind": "started", "at": back.isoformat(),
"boot_at": (now - timedelta(days=3)).isoformat()}])
assert await _rows(app, host_id) == [("agent_stop", stop, back)]
asyncio.run(_go())
def test_a_boot_nobody_announced_is_a_restart_without_a_shutdown(app):
async def _go():
now = datetime.now(timezone.utc)
last, boot, back = now - timedelta(minutes=20), now - timedelta(minutes=4), now - timedelta(minutes=3)
host_id, token = await _agent(app, last)
started = {"kind": "started", "at": back.isoformat(), "boot_at": boot.isoformat()}
await _post(app, token, [{"ts": back.isoformat(), "cpu_pct": 3.0}], [started])
await _post(app, token, [{"ts": now.isoformat(), "cpu_pct": 3.0}], [started])
assert await _rows(app, host_id) == [("unclean", last, back)]
asyncio.run(_go())
def test_lost_contact_is_the_span_the_agent_reports(app):
async def _go():
now = datetime.now(timezone.utc)
since, until = now - timedelta(minutes=45), now
host_id, token = await _agent(app, since)
lost = {"kind": "lost_contact", "from": since.isoformat(), "to": until.isoformat()}
await _post(app, token, [{"ts": until.isoformat(), "cpu_pct": 3.0}], [lost])
await _post(app, token, [{"ts": until.isoformat(), "cpu_pct": 3.0}], [lost])
assert await _rows(app, host_id) == [("lost_contact", since, until)]
asyncio.run(_go())
def test_a_host_rebooting_inside_its_grace_is_silent_with_reason(app):
"""Its other rules' silence is its agent's, as when it is offline (#5721),
until the grace runs out; then agent offline speaks for it."""
from steward.core.alerts import offline_agents
from steward.models.hosts import Host
from sqlalchemy import select
async def _go():
now = datetime.now(timezone.utc)
host_id, token = await _agent(app, now - timedelta(minutes=1))
await _post(app, token, [], [{"kind": "stopping", "at": (now - timedelta(minutes=1)).isoformat(),
"host_shutdown": True}])
old_id, old_token = await _agent(app, now - timedelta(hours=2))
await _post(app, old_token, [], [{"kind": "stopping", "at": (now - timedelta(hours=1)).isoformat(),
"host_shutdown": True}])
async with app.db_sessionmaker() as s:
async with s.begin():
away = await offline_agents(s)
names = dict((await s.execute(select(Host.id, Host.name).where(
Host.id.in_([host_id, old_id])))).all())
assert names[host_id] in away
assert names[old_id] not in away # an hour on: past its 10-minute grace
asyncio.run(_go())
@@ -0,0 +1,57 @@
"""The agent says what happened to its host (#5756): when it started and the
host booted, when it stops and whether the host is going down with it, and how
long it couldn't reach Steward."""
import subprocess
from datetime import datetime
from types import SimpleNamespace
from unittest.mock import patch
from plugins.host_agent import agent as a
def test_events_ride_the_payload_only_when_there_are_some():
assert "events" not in a.build_payload([], "h", {})
assert a.build_payload([], "h", {}, events=[{"kind": "started"}])["events"] == [{"kind": "started"}]
def test_started_says_when_the_host_booted():
with patch.object(a, "_read_file", return_value="3600.5 100.0\n"), \
patch.object(a.time, "time", return_value=1_800_000_000.0):
event = a.started_event()
assert event["kind"] == "started"
assert datetime.fromisoformat(event["boot_at"]).timestamp() == 1_800_000_000.0 - 3600.5
def test_started_without_uptime_still_says_it_started():
with patch.object(a, "_read_file", side_effect=OSError):
event = a.started_event()
assert event["kind"] == "started" and "boot_at" not in event
def test_the_host_is_going_down_when_systemd_says_stopping():
def run(out):
return patch.object(a.subprocess, "run", return_value=SimpleNamespace(stdout=out))
with run("stopping\n"):
assert a.host_shutting_down() is True
with run("running\n"):
assert a.host_shutting_down() is False
with run("degraded\n"):
assert a.host_shutting_down() is False
with patch.object(a.subprocess, "run", side_effect=FileNotFoundError):
assert a.host_shutting_down() is None
with patch.object(a.subprocess, "run", side_effect=subprocess.TimeoutExpired("systemctl", 3)):
assert a.host_shutting_down() is None
def test_stopping_says_whether_the_host_goes_with_it():
with patch.object(a, "host_shutting_down", return_value=True):
event = a.stopping_event()
assert event["kind"] == "stopping" and event["host_shutdown"] is True and event["at"]
def test_lost_contact_is_no_answer_or_a_proxy_s_5xx_not_steward_refusing():
assert a.lost_contact(None) and a.lost_contact(502) and a.lost_contact(503)
assert not a.lost_contact(400) and not a.lost_contact(401)
event = a.lost_contact_event("2026-10-14T10:00:00+00:00")
assert event["kind"] == "lost_contact" and event["from"] == "2026-10-14T10:00:00+00:00"
assert event["to"] > event["from"]
+33
View File
@@ -0,0 +1,33 @@
"""How a host's downtime reads on its page (#5756)."""
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace
from plugins.host_agent.downtime import describe, restart_grace_seconds
NOW = datetime(2026, 10, 14, 12, 0, tzinfo=timezone.utc)
def _row(kind, minutes_ago, lasted=None):
start = NOW - timedelta(minutes=minutes_ago)
return SimpleNamespace(kind=kind, started_at=start,
ended_at=start + timedelta(minutes=lasted) if lasted is not None else None)
def test_each_kind_reads_as_what_it_was():
assert describe(_row("reboot", 30, 2), NOW)["label"] == "Rebooted"
assert describe(_row("unclean", 30, 9), NOW)["label"] == "Restarted without shutting down"
assert describe(_row("lost_contact", 30, 20), NOW)["label"] == "Lost contact"
assert describe(_row("agent_stop", 30, 1), NOW)["label"] == "Agent restarted"
assert describe(_row("agent_stop", 3), NOW)["label"] == "Agent stopped"
def test_one_still_going_counts_to_now():
got = describe(_row("reboot", 4), NOW)
assert got["ongoing"] and got["seconds"] == 240.0
done = describe(_row("reboot", 30, 2), NOW)
assert not done["ongoing"] and done["seconds"] == 120.0
def test_the_grace_comes_from_the_plugin_settings():
assert restart_grace_seconds({}) == 600
assert restart_grace_seconds({"PLUGINS": {"host_agent": {"reboot_grace_seconds": 120}}}) == 120
@@ -91,3 +91,13 @@ def test_an_offline_agent_that_reports_after_the_restart_is_back():
back = NOW - timedelta(minutes=1)
regs = [FakeReg("h1", NOW - timedelta(seconds=15), NOW - timedelta(days=9))]
assert seconds_since_seen(regs, NAMES, NOW, back, already_silent={"nas"}) == [("nas", 15.0)]
def test_a_reboot_the_agent_announced_reads_as_just_seen_until_its_grace_ends():
"""#5756: an agent that said its host was rebooting isn't offline while
the reboot's grace lasts; another host silent as long still is."""
regs = [FakeReg("h1", NOW - timedelta(minutes=5), NOW - timedelta(days=9)),
FakeReg("h2", NOW - timedelta(minutes=5), NOW - timedelta(days=9))]
explained = {"h1": NOW + timedelta(minutes=5)}
assert seconds_since_seen(regs, NAMES, NOW, explained_until=explained) == [
("nas", 0.0), ("web", 300.0)]