feat(host_agent): a host says what updates it has waiting and whether it needs a reboot (#5765)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 1m22s
CI / unit (push) Successful in 1m26s
CI / publish (push) Successful in 1m20s

Step 1 of milestone 514 "Updates and reboots on a cadence, carried out by
each host's agent…": #5765 "The agent reports pending updates and whether
a reboot is required".

- Agent 1.16.0: an hourly background check (apt-check, apt list, or dnf -C
  with updateinfo and needs-restarting), each command bounded at 120 s, no
  root and no network. Debian's reboot-required marker and its packages
  are read every sample.
- Ingest records updates_pending, updates_security and reboot_required for
  rules, and keeps the newest on the registration (host_agent_005_updates).
- The Agent card leads with "Reboot required since …", then the updates
  waiting or "Up to date", with when the check ran. list_agents carries
  them too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-14 18:23:44 -04:00
co-authored by Claude Opus 5.5
parent b1db1e8b0d
commit 210a35c956
12 changed files with 472 additions and 4 deletions
+71 -1
View File
@@ -111,6 +111,62 @@ def counts_as_hottest(label: str, celsius: float) -> bool:
return not (_BOARD_INPUT.match(label) and celsius >= BOARD_INPUT_CEILING_C)
def _ts_or(value, fallback: datetime) -> datetime:
"""An agent-sent ISO time, or `fallback` when it isn't one."""
try:
got = _parse_ts(value)
except (AttributeError, TypeError, ValueError):
return fallback
return got if got.tzinfo else got.replace(tzinfo=timezone.utc)
def reboot_required(sample: dict):
"""Whether the sample says its host needs a reboot: from the reboot-required
marker (apt hosts) or the hourly update check (dnf's needs-restarting);
None when it can't say."""
reboot = sample.get("reboot") if isinstance(sample.get("reboot"), dict) else {}
if isinstance(reboot.get("required"), bool):
return reboot["required"]
upd = sample.get("updates") if isinstance(sample.get("updates"), dict) else {}
return upd["reboot_required"] if isinstance(upd.get("reboot_required"), bool) else None
def apply_updates(reg, sample: dict, now: datetime) -> bool:
"""Keep the registration's view of the host's updates and reboot current
from its newest sample (#5765); True when anything changed."""
before = (reg.updates_manager, reg.updates_pending, reg.updates_security,
reg.updates_checked_at, reg.updates_error, reg.reboot_required,
reg.reboot_required_since, reg.reboot_packages)
upd = sample.get("updates") if isinstance(sample.get("updates"), dict) else None
if upd:
reg.updates_checked_at = _ts_or(upd.get("checked_at"), now)
if upd.get("manager") in ("apt", "dnf"):
reg.updates_manager = upd["manager"]
if upd.get("error"):
reg.updates_error = str(upd["error"])[:200]
elif isinstance(upd.get("pending"), int):
reg.updates_pending = upd["pending"]
reg.updates_security = upd["security"] if isinstance(upd.get("security"), int) else None
reg.updates_error = None
required = reboot_required(sample)
if required is not None:
reg.reboot_required = required
if not required:
reg.reboot_required_since, reg.reboot_packages = None, None
else:
reboot = sample.get("reboot") if isinstance(sample.get("reboot"), dict) else {}
if reboot.get("since") is not None:
reg.reboot_required_since = _ts_or(reboot["since"], now)
elif reg.reboot_required_since is None:
reg.reboot_required_since = now
packages = reboot.get("packages")
if isinstance(packages, list):
reg.reboot_packages = ", ".join(str(p) for p in packages[:20])[:2000] or None
return before != (reg.updates_manager, reg.updates_pending, reg.updates_security,
reg.updates_checked_at, reg.updates_error, reg.reboot_required,
reg.reboot_required_since, reg.reboot_packages)
def _expand_sample_to_metrics(
sample: dict, host_name: str, recorded_at: datetime
) -> list[PluginMetric]:
@@ -149,6 +205,17 @@ def _expand_sample_to_metrics(
if sample.get("uptime_secs") is not None:
row("uptime_secs", host_name, sample["uptime_secs"])
# What the host has waiting (#5765): the agent's hourly answer rides
# every sample, so a rule on it reads like any other host metric.
upd = sample.get("updates") if isinstance(sample.get("updates"), dict) else {}
if isinstance(upd.get("pending"), int):
row("updates_pending", host_name, upd["pending"])
if isinstance(upd.get("security"), int):
row("updates_security", host_name, upd["security"])
required = reboot_required(sample)
if required is not None:
row("reboot_required", host_name, 1 if required else 0)
# The agent's own footprint (#5764), so a runaway agent shows before it
# goes silent.
me = sample.get("agent") or {}
@@ -312,6 +379,7 @@ async def ingest():
latest_disk_ts: datetime | None = None
# Whether Docker answered, from the newest sample that says (agent ≥ 1.9.0).
docker_status: dict | None = None
newest_sample: dict | None = None
for sample in samples:
try:
recorded_at = _parse_ts(sample["ts"])
@@ -346,7 +414,7 @@ async def ingest():
latest_disk, latest_disk_ts = disk, recorded_at
accepted += 1
if latest_ts is None or recorded_at > latest_ts:
latest_ts = recorded_at
latest_ts, newest_sample = recorded_at, sample
if accepted == 0 and not events:
await session.rollback()
@@ -411,6 +479,8 @@ async def ingest():
if host_ip and reg.host_ip != host_ip:
reg.host_ip = host_ip
changed = True
if newest_sample is not None and apply_updates(reg, newest_sample, received_at):
changed = True
# Mirror the reported IP into Host.address only when it's blank.
new_address = pick_host_address(host.address, host_ip)
if new_address is not None: