feat(host_agent): a host says what updates it has waiting and whether it needs a reboot (#5765)
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:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user