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:
@@ -0,0 +1,92 @@
|
||||
# tests/plugins/host_agent/test_agent_updates.py
|
||||
"""#5765: the agent says what the package manager has waiting and whether the
|
||||
host needs a reboot, without root, without the network, and without a slow
|
||||
apt ever holding up a sample."""
|
||||
import os
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
|
||||
from plugins.host_agent import agent as a
|
||||
|
||||
|
||||
def test_apt_check_counts_are_read_from_its_stderr():
|
||||
assert a.parse_apt_check("12;3") == (12, 3)
|
||||
assert a.parse_apt_check("") is None
|
||||
|
||||
|
||||
def test_apt_list_counts_security_updates_by_their_suite():
|
||||
out = ("Listing...\n"
|
||||
"libc6/noble-updates,noble-security 2.39-0ubuntu8.4 amd64 [upgradable from: 2.39-0ubuntu8.3]\n"
|
||||
"curl/noble-updates 8.5.0-2ubuntu10.5 amd64 [upgradable from: 8.5.0-2ubuntu10.4]\n")
|
||||
assert a.parse_apt_list(out) == (2, 1)
|
||||
|
||||
|
||||
def test_dnf_check_update_counts_packages_and_stops_at_obsoletes():
|
||||
out = ("\nkernel.x86_64 5.14.0-503 baseos\nopenssl.x86_64 3.2.2-6 baseos\n"
|
||||
"Obsoleting Packages\ngrub2.x86_64 2.06 baseos\n")
|
||||
assert a.parse_dnf_check_update(out) == 2
|
||||
|
||||
|
||||
def test_apt_hosts_are_checked_with_apt_check(monkeypatch):
|
||||
calls = []
|
||||
|
||||
def fake_run(cmd, deadline=a.UPDATES_DEADLINE_SECONDS):
|
||||
calls.append(cmd[0])
|
||||
return (0, "", "7;2") if cmd[0].endswith("apt-check") else None
|
||||
|
||||
monkeypatch.setattr(a, "_run", fake_run)
|
||||
got = a.collect_updates()
|
||||
assert (got["manager"], got["pending"], got["security"]) == ("apt", 7, 2)
|
||||
assert calls == ["/usr/lib/update-notifier/apt-check"]
|
||||
|
||||
|
||||
def test_a_package_manager_that_hangs_is_an_error_not_a_stall(monkeypatch):
|
||||
def hang(cmd, deadline=a.UPDATES_DEADLINE_SECONDS):
|
||||
raise subprocess.TimeoutExpired(cmd, deadline)
|
||||
|
||||
monkeypatch.setattr(a, "_run", hang)
|
||||
assert "didn't answer" in a.collect_updates()["error"]
|
||||
|
||||
|
||||
def test_the_check_runs_in_the_background_once_an_hour(monkeypatch):
|
||||
started, release = threading.Event(), threading.Event()
|
||||
|
||||
def slow():
|
||||
started.set()
|
||||
release.wait(5)
|
||||
return {"manager": "apt", "pending": 1, "security": 0, "checked_at": "x"}
|
||||
|
||||
monkeypatch.setattr(a, "collect_updates", slow)
|
||||
state: dict = {}
|
||||
a.refresh_updates(state, now_mono=1000.0)
|
||||
assert started.wait(2)
|
||||
a.refresh_updates(state, now_mono=1001.0) # still running: not started twice
|
||||
release.set()
|
||||
for _ in range(50):
|
||||
if not state.get("updates_running"):
|
||||
break
|
||||
time.sleep(0.02)
|
||||
assert state["updates"]["pending"] == 1
|
||||
|
||||
monkeypatch.setattr(a, "collect_updates", lambda: {"pending": 2})
|
||||
a.refresh_updates(state, now_mono=1000.0 + 600) # inside the hour: no new check
|
||||
assert state["updates"]["pending"] == 1
|
||||
state["updates_due"] = True # asked for now: checked now
|
||||
a.refresh_updates(state, now_mono=1000.0 + 601)
|
||||
for _ in range(50):
|
||||
if state["updates"].get("pending") == 2:
|
||||
break
|
||||
time.sleep(0.02)
|
||||
assert state["updates"]["pending"] == 2
|
||||
|
||||
|
||||
def test_a_reboot_is_required_when_debian_says_so(tmp_path):
|
||||
marker = tmp_path / "reboot-required"
|
||||
marker.write_text("*** System restart required ***\n")
|
||||
(tmp_path / "reboot-required.pkgs").write_text("linux-image-6.8.0-146-generic\nlibc6\nlibc6\n")
|
||||
os.utime(marker, (1792000000, 1792000000))
|
||||
got = a.collect_reboot(str(marker))
|
||||
assert got["required"] is True
|
||||
assert got["packages"] == ["libc6", "linux-image-6.8.0-146-generic"]
|
||||
assert got["since"].startswith("2026-10-14")
|
||||
@@ -122,3 +122,37 @@ def test_the_agent_s_own_footprint_is_recorded():
|
||||
idx = _index(_expand_sample_to_metrics(sample, "d3", datetime.now(timezone.utc)))
|
||||
assert idx[("agent_rss_bytes", "d3")] == 18_000_000
|
||||
assert idx[("agent_cpu_pct", "d3")] == 1.5
|
||||
|
||||
|
||||
def test_pending_updates_and_a_needed_reboot_are_metrics():
|
||||
"""#5765: rules can watch what a host has waiting."""
|
||||
sample = {"ts": "2026-10-14T22:00:00+00:00",
|
||||
"updates": {"manager": "apt", "pending": 12, "security": 3, "checked_at": "2026-10-14T21:30:00+00:00"},
|
||||
"reboot": {"required": True, "since": "2026-10-14T06:00:00+00:00", "packages": ["libc6"]}}
|
||||
idx = _index(_expand_sample_to_metrics(sample, "d3", datetime.now(timezone.utc)))
|
||||
assert idx[("updates_pending", "d3")] == 12
|
||||
assert idx[("updates_security", "d3")] == 3
|
||||
assert idx[("reboot_required", "d3")] == 1
|
||||
|
||||
|
||||
def test_the_registration_keeps_the_host_s_updates_and_reboot():
|
||||
from types import SimpleNamespace
|
||||
from plugins.host_agent.routes import apply_updates
|
||||
now = datetime(2026, 10, 14, 22, tzinfo=timezone.utc)
|
||||
reg = SimpleNamespace(updates_manager=None, updates_pending=None, updates_security=None,
|
||||
updates_checked_at=None, updates_error=None, reboot_required=None,
|
||||
reboot_required_since=None, reboot_packages=None)
|
||||
sample = {"updates": {"manager": "apt", "pending": 4, "security": 1,
|
||||
"checked_at": "2026-10-14T21:30:00+00:00"},
|
||||
"reboot": {"required": True, "since": "2026-10-14T06:00:00Z",
|
||||
"packages": ["libc6", "linux-image-6.8.0-146-generic"]}}
|
||||
assert apply_updates(reg, sample, now) is True
|
||||
assert (reg.updates_pending, reg.updates_security, reg.reboot_required) == (4, 1, True)
|
||||
assert reg.reboot_required_since.hour == 6
|
||||
assert reg.reboot_packages == "libc6, linux-image-6.8.0-146-generic"
|
||||
assert apply_updates(reg, sample, now) is False # nothing new
|
||||
# A failed check keeps the last counts and says why; the reboot clears.
|
||||
apply_updates(reg, {"updates": {"error": "apt is locked", "checked_at": "2026-10-14T22:30:00Z"},
|
||||
"reboot": {"required": False}}, now)
|
||||
assert reg.updates_pending == 4 and reg.updates_error == "apt is locked"
|
||||
assert reg.reboot_required is False and reg.reboot_required_since is None
|
||||
|
||||
Reference in New Issue
Block a user