feat(host-agent): updates on pacman, zypper and yum hosts (#5803, #5804)
CI / lint (push) Successful in 3s
CI / integration (push) Successful in 1m26s
CI / unit (push) Successful in 1m35s
CI / publish (push) Successful in 20s

Agent 1.21.0. The hourly update check now covers the package managers
beyond apt and dnf (#5803 "The agent's update check reads pacman,
zypper and yum"):
- yum: `yum -C check-update`, `updateinfo list security`,
  `needs-restarting -r`, as dnf.
- zypper: `--no-refresh list-updates` (`--dup` on Tumbleweed),
  needed security patches from `list-patches --category security`,
  and `zypper needs-rebooting`.
- pacman: `checkupdates` from pacman-contrib, which install-deps.sh now
  installs. pacman has no security notion; a reboot is required once
  the running kernel's /usr/lib/modules/<release> is gone.
The checks sit in one UPDATE_CHECKS table, and a failure names its
manager.

The root helper's update runs on the same hosts (#5804 "The scheduled
update runs on pacman, zypper and yum hosts"): yum upgrade, zypper
refresh + update (dup on Tumbleweed, with 102/103 counted as success),
pacman -Syu. Each step carries a label so a failure says which one,
and the reboot check reads zypper and the pacman kernel too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-15 11:47:13 -04:00
co-authored by Claude Opus 5.5
parent 1878f91293
commit 4054bf6bbb
6 changed files with 468 additions and 75 deletions
+157 -37
View File
@@ -22,7 +22,7 @@ from collections import deque
from concurrent.futures import ThreadPoolExecutor, wait as wait_futures
from datetime import datetime, timezone
AGENT_VERSION = "1.20.1"
AGENT_VERSION = "1.21.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
@@ -1308,18 +1308,21 @@ class RingBuffer:
return len(self._dq)
# ─── pending updates and reboots (#5765) ─────────────────────────────────────
# ─── pending updates and reboots (#5765, #5803) ──────────────────────────────
#
# What the package manager has waiting, read once an hour in a background
# thread so a slow apt never holds up a sample, and whether the host needs a
# reboot, read every sample (a file test). Nothing here needs root or the
# network: apt-check and `apt list` read the lists apt-daily already fetched,
# and dnf runs from its cache (-C).
# reboot, read every sample (a file test). Nothing here needs root. apt-check
# and `apt list` read the lists apt-daily already fetched; dnf, yum and zypper
# run from their caches (-C, --no-refresh). pacman has no cache to ask without
# root, so checkupdates (pacman-contrib) syncs its own copy of the package
# lists, in the agent's private /tmp, and does reach the mirrors.
UPDATES_INTERVAL_SECONDS = 3600.0
UPDATES_DEADLINE_SECONDS = 120.0
REBOOT_REQUIRED = "/var/run/reboot-required"
REBOOT_PACKAGES_MAX = 20
KERNEL_MODULES = "/usr/lib/modules"
def _run(cmd: list, deadline: float = UPDATES_DEADLINE_SECONDS):
@@ -1332,6 +1335,11 @@ def _run(cmd: list, deadline: float = UPDATES_DEADLINE_SECONDS):
return done.returncode, done.stdout, done.stderr
def _failed(manager: str, got, what: str, at: str) -> dict:
return {"manager": manager, "error": ((got[2] or got[1]) or what + " failed").strip()[:200],
"checked_at": at}
def parse_apt_check(stderr: str):
"""apt-check writes "<pending>;<security>" to stderr; (pending, security) or None."""
m = re.search(r"(\d+);(\d+)", stderr or "")
@@ -1354,47 +1362,153 @@ def parse_apt_list(stdout: str) -> tuple:
def parse_dnf_check_update(stdout: str) -> int:
"""Packages `dnf check-update` lists, up to its "Obsoleting Packages" part."""
"""Packages `dnf check-update` (or yum's) lists, up to its "Obsoleting
Packages" part. yum also prints "Loaded plugins:" lines first."""
count = 0
for line in (stdout or "").splitlines():
if line.startswith("Obsoleting"):
break
if line.strip() and not line.startswith((" ", "Last metadata")):
if line.strip() and not line.startswith((" ", "Last metadata", "Loaded plugins",
"Security:")):
count += 1
return count
def parse_checkupdates(stdout: str) -> int:
"""Packages pacman-contrib's checkupdates lists: "name old -> new"."""
return sum(1 for line in (stdout or "").splitlines() if " -> " in line)
def _zypper_rows(stdout: str) -> list:
"""The data rows of a zypper table: lines with "|" after the "---+---" rule."""
rows, seen_rule = [], False
for line in (stdout or "").splitlines():
if re.match(r"^-+\+[-+]+$", line.strip()):
seen_rule = True
elif seen_rule and "|" in line:
rows.append([c.strip() for c in line.split("|")])
return rows
def parse_zypper_updates(stdout: str) -> int:
"""Packages `zypper list-updates` lists (one table row each)."""
return len(_zypper_rows(stdout))
def parse_zypper_patches(stdout: str) -> int:
"""Security patches `zypper list-patches --category security` says are
still needed (its Status column), not those already applied."""
return sum(1 for row in _zypper_rows(stdout) if "needed" in row)
def _os_release_id(path: str = "/etc/os-release") -> str:
try:
with open(path) as fh:
for line in fh:
if line.startswith("ID="):
return line.split("=", 1)[1].strip().strip('"')
except OSError:
pass
return ""
def _check_apt(at: str):
got = _run(["/usr/lib/update-notifier/apt-check"])
if got is not None:
counts = parse_apt_check(got[2])
if counts:
return {"manager": "apt", "pending": counts[0], "security": counts[1], "checked_at": at}
got = _run(["apt", "list", "--upgradable"])
if got is not None and got[0] == 0:
pending, security = parse_apt_list(got[1])
return {"manager": "apt", "pending": pending, "security": security, "checked_at": at}
return None
def _needs_restarting(out: dict) -> dict:
"""dnf and yum hosts: needs-restarting -r exits 1 when a reboot is needed."""
restart = _run(["needs-restarting", "-r"])
if restart is not None and restart[0] in (0, 1):
out["reboot_required"] = restart[0] == 1
return out
def _check_dnf_like(manager: str, at: str):
"""dnf, and yum where there is no dnf: the same output and exit codes."""
got = _run([manager, "-q", "-C", "check-update"])
if got is None:
return None
if got[0] not in (0, 100): # 100: updates are waiting
return _failed(manager, got, manager + " check-update", at)
out = {"manager": manager, "pending": parse_dnf_check_update(got[1]),
"security": None, "checked_at": at}
sec = _run([manager, "-q", "-C", "updateinfo", "list", "--security"] if manager == "dnf"
else [manager, "-q", "-C", "updateinfo", "list", "security"])
if sec is not None and sec[0] == 0:
out["security"] = len({ln.split()[-1] for ln in sec[1].splitlines()
if ln.strip() and not ln.startswith("Loaded plugins")})
return _needs_restarting(out)
def _check_zypper(at: str):
"""openSUSE / SLES. Tumbleweed upgrades with dup, so its pending list is
dup's, and it has no patches to call security."""
tumbleweed = _os_release_id() == "opensuse-tumbleweed"
got = _run(["zypper", "--no-refresh", "-q", "list-updates"] + (["--dup"] if tumbleweed else []))
if got is None:
return None
if got[0] != 0:
return _failed("zypper", got, "zypper list-updates", at)
out = {"manager": "zypper", "pending": parse_zypper_updates(got[1]), "security": None,
"checked_at": at}
if not tumbleweed:
sec = _run(["zypper", "--no-refresh", "-q", "list-patches", "--category", "security"])
if sec is not None and sec[0] in (0, 100, 101):
out["security"] = parse_zypper_patches(sec[1])
restart = _run(["zypper", "needs-rebooting"])
if restart is not None and restart[0] in (0, 102): # 102: a reboot is needed
out["reboot_required"] = restart[0] == 102
return out
def _check_pacman(at: str):
"""Arch and its family (CachyOS, Manjaro, EndeavourOS). pacman keeps no
security list, so security is None. Without pacman-contrib there is no
safe check: `pacman -Sy` needs root and leaves a partial upgrade."""
if shutil.which("pacman") is None:
return None
got = _run(["checkupdates"])
if got is None:
return {"manager": "pacman", "error": "checkupdates isn't installed (pacman-contrib)",
"checked_at": at}
if got[0] not in (0, 2): # 2: nothing to update
return _failed("pacman", got, "checkupdates", at)
return {"manager": "pacman", "pending": parse_checkupdates(got[1]), "security": None,
"checked_at": at}
# Asked in this order; the first that answers is the host's. dnf comes before
# yum because dnf hosts often keep a yum alias that is dnf underneath.
UPDATE_CHECKS = (
("apt", _check_apt),
("dnf", lambda at: _check_dnf_like("dnf", at)),
("yum", lambda at: _check_dnf_like("yum", at)),
("zypper", _check_zypper),
("pacman", _check_pacman),
)
def collect_updates() -> dict:
"""{"manager", "pending", "security", "checked_at"} (security None when the
manager can't say), {"error"} when the check failed, or {} on a host with
no package manager we know. dnf hosts also say whether a reboot is
required, from needs-restarting."""
no package manager we know. dnf, yum and zypper hosts also say whether a
reboot is required."""
at = _now_iso()
try:
got = _run(["/usr/lib/update-notifier/apt-check"])
if got is not None:
counts = parse_apt_check(got[2])
if counts:
return {"manager": "apt", "pending": counts[0], "security": counts[1],
"checked_at": at}
got = _run(["apt", "list", "--upgradable"])
if got is not None and got[0] == 0:
pending, security = parse_apt_list(got[1])
return {"manager": "apt", "pending": pending, "security": security, "checked_at": at}
got = _run(["dnf", "-q", "-C", "check-update"])
if got is not None:
if got[0] not in (0, 100): # 100: updates are waiting
return {"manager": "dnf", "error": (got[2] or "dnf check-update failed").strip()[:200],
"checked_at": at}
out = {"manager": "dnf", "pending": parse_dnf_check_update(got[1]),
"security": None, "checked_at": at}
sec = _run(["dnf", "-q", "-C", "updateinfo", "list", "--security"])
if sec is not None and sec[0] == 0:
out["security"] = len({ln.split()[-1] for ln in sec[1].splitlines() if ln.strip()})
restart = _run(["needs-restarting", "-r"])
if restart is not None and restart[0] in (0, 1):
out["reboot_required"] = restart[0] == 1
return out
for _name, check in UPDATE_CHECKS:
got = check(at)
if got is not None:
return got
except subprocess.TimeoutExpired:
return {"error": "the package manager didn't answer in %.0f s" % UPDATES_DEADLINE_SECONDS,
"checked_at": at}
@@ -1428,13 +1542,19 @@ def refresh_updates(state: dict, now_mono: float | None = None) -> None:
threading.Thread(target=work, name="updates", daemon=True).start()
def collect_reboot(path: str = REBOOT_REQUIRED):
"""{"required", "since", "packages"} from Debian's reboot-required marker,
or None where there is no such marker system (no apt) to ask."""
def collect_reboot(path: str = REBOOT_REQUIRED, modules: str = KERNEL_MODULES):
"""{"required", "since", "packages"} from Debian's reboot-required marker;
on pacman hosts, whether the running kernel's modules are gone (an upgrade
replaced the kernel); None where neither can be asked (dnf, yum and zypper
say it with the hourly update check)."""
try:
st = os.stat(path)
except FileNotFoundError:
return {"required": False} if shutil.which("apt-get") else None
if shutil.which("apt-get"):
return {"required": False}
if shutil.which("pacman"):
return {"required": not os.path.isdir(os.path.join(modules, os.uname().release))}
return None
packages: list = []
try:
with open(path + ".pkgs") as fh: