Files
FabledSteward/tests/plugins/host_agent/test_agent_events.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

58 lines
2.4 KiB
Python

"""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"]