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:
+7 -3
View File
@@ -5,13 +5,14 @@
# and the host_agent playbooks: one copy of the list.
#
# smartmontools gives the helper smartctl, which reads each drive's health.
# On pacman hosts, pacman-contrib gives the agent checkupdates, the one safe
# way to see pending updates without root (#5803).
# A package that can't be installed is reported, never fatal: the agent runs
# without it and the host page says what is missing.
#
# Recommended / weak dependencies are skipped: on Debian, smartmontools
# recommends a mail server.
PACKAGES="smartmontools"
have() { command -v "$1" >/dev/null 2>&1; }
@@ -39,12 +40,15 @@ install_with() {
missing=""
have smartctl || missing="$missing smartmontools"
[ -n "$missing" ] || { echo "dependencies present: $PACKAGES"; exit 0; }
if have pacman && ! have checkupdates; then
missing="$missing pacman-contrib"
fi
[ -n "$missing" ] || { echo "dependencies present"; exit 0; }
# shellcheck disable=SC2086 # one word per package
if install_with $missing; then
echo "installed:$missing"
else
echo "WARNING: could not install$missing; drive health will read \"smartctl not installed\" until it is"
echo "WARNING: could not install$missing; until it is, drive health reads \"smartctl not installed\" (smartmontools) and the update check reads \"checkupdates isn't installed\" (pacman-contrib)"
fi
exit 0
+100 -31
View File
@@ -76,8 +76,10 @@ def valid(req) -> bool:
return node is None or (isinstance(node, str) and bool(_NODE.match(node)))
def run(cmd: list, deadline: int = COMMAND_DEADLINE_SECONDS, env: dict | None = None):
"""(ok, output tail) of a command, bounded by `deadline`."""
def run(cmd: list, deadline: int = COMMAND_DEADLINE_SECONDS, env: dict | None = None,
ok_codes: tuple = (0,)):
"""(ok, output tail) of a command, bounded by `deadline`. `ok_codes` are
the exit codes that mean it worked (zypper says more than 0)."""
try:
done = subprocess.run(cmd, capture_output=True, text=True, timeout=deadline,
env=dict(os.environ, LANG="C", LC_ALL="C", **(env or {})))
@@ -86,55 +88,122 @@ def run(cmd: list, deadline: int = COMMAND_DEADLINE_SECONDS, env: dict | None =
except OSError as exc:
return False, str(exc)
out = (done.stdout or "") + (done.stderr or "")
return done.returncode == 0, out[-OUTPUT_KEPT_CHARS:]
return done.returncode in ok_codes, out[-OUTPUT_KEPT_CHARS:]
REBOOT_MARKER = "/var/run/reboot-required" # Debian and Ubuntu
KERNEL_MODULES = "/usr/lib/modules"
# zypper's own exit codes for "done, and": 102 a reboot is needed, 103 zypper
# updated itself and would run again (its next run picks up the rest).
ZYPPER_OK = (0, 102, 103)
def reboot_required() -> bool | None:
if os.path.exists("/var/run/reboot-required"):
"""Whether the host needs a reboot after an update, asked the way its
package manager says it; None when it can't say."""
if os.path.exists(REBOOT_MARKER):
return True
if shutil.which("apt-get"):
return False
if shutil.which("needs-restarting"):
try:
try:
if shutil.which("needs-restarting"): # dnf, yum
return subprocess.run(["needs-restarting", "-r"], capture_output=True,
timeout=60).returncode == 1
except (OSError, subprocess.TimeoutExpired):
return None
if shutil.which("zypper"):
code = subprocess.run(["zypper", "needs-rebooting"], capture_output=True,
timeout=60).returncode
return {0: False, 102: True}.get(code)
except (OSError, subprocess.TimeoutExpired):
return None
if shutil.which("pacman"):
# An upgraded kernel removes the running one's modules.
return not os.path.isdir(os.path.join(KERNEL_MODULES, os.uname().release))
return None
def upgraded_count(output: str) -> int | None:
"""Packages an apt or dnf run changed, from its summary line."""
"""Packages an update changed, from the manager's summary line."""
m = re.search(r"(\d+) upgraded, (\d+) newly installed", output) # apt
if m:
return int(m.group(1)) + int(m.group(2))
counts = re.findall(r"^\s*(?:Upgrade|Install)\s+(\d+) Packages?", output, re.MULTILINE) # dnf
m = re.search(r"(\d+) packages? to upgrade(?:, (\d+) new)?", output) # zypper
if m:
return int(m.group(1)) + int(m.group(2) or 0)
m = re.search(r"^Packages \((\d+)\)", output, re.MULTILINE) # pacman
if m:
return int(m.group(1))
counts = re.findall(r"^\s*(?:Upgrade|Install)\s+(\d+) Packages?", output, re.MULTILINE) # dnf, yum
return sum(int(c) for c in counts) if counts else None
def do_update() -> dict:
"""Upgrade every package, keeping existing config files and leaving
service restarts to a reboot: needrestart only lists, so the Docker daemon
isn't restarted under its containers mid-upgrade."""
if shutil.which("apt-get"):
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 update_steps(manager: str) -> list:
"""The commands an update runs on a host with `manager`, in order: each
(what it is, command, deadline, env, ok exit codes). The last one is the upgrade
whose output is counted. Existing config files are kept, and service
restarts are left to a reboot: needrestart only lists, so the Docker
daemon isn't restarted under its containers mid-upgrade."""
long = UPDATE_DEADLINE_SECONDS
if manager == "apt":
env = {"DEBIAN_FRONTEND": "noninteractive", "NEEDRESTART_MODE": "l"}
ok, out = run(["apt-get", "update"], env=env)
return [("apt-get update", ["apt-get", "update"], COMMAND_DEADLINE_SECONDS, env, (0,)),
("apt-get dist-upgrade", ["apt-get", "-y", "-o", "Dpkg::Options::=--force-confdef",
"-o", "Dpkg::Options::=--force-confold", "dist-upgrade"],
long, env, (0,))]
if manager == "dnf":
return [("dnf upgrade", ["dnf", "-y", "upgrade", "--refresh"], long, None, (0,))]
if manager == "yum":
return [("yum update", ["yum", "-y", "update"], long, None, (0,))]
if manager == "zypper":
# Tumbleweed is a rolling release: it upgrades with dup, not update.
upgrade = "dup" if _os_release_id() == "opensuse-tumbleweed" else "update"
return [("zypper refresh", ["zypper", "--non-interactive", "refresh"],
COMMAND_DEADLINE_SECONDS, None, ZYPPER_OK),
("zypper " + upgrade, ["zypper", "--non-interactive", upgrade, "--auto-agree-with-licenses"],
long, None, ZYPPER_OK)]
if manager == "pacman":
# Packages from the AUR aren't updated: no AUR helper runs as root.
return [("pacman -Syu", ["pacman", "-Syu", "--noconfirm"], long, None, (0,))]
return []
# Which manager a host has; dnf before yum, since dnf hosts often keep a yum
# that is dnf underneath.
UPDATE_MANAGERS = (("apt", "apt-get"), ("dnf", "dnf"), ("yum", "yum"), ("zypper", "zypper"),
("pacman", "pacman"))
def host_manager() -> str | None:
for manager, binary in UPDATE_MANAGERS:
if shutil.which(binary):
return manager
return None
def do_update() -> dict:
"""Upgrade every package with whichever package manager the host has."""
manager = host_manager()
if manager is None:
return {"ok": False, "detail": "no package manager this helper knows "
"(apt, dnf, yum, zypper, pacman) on this host"}
out = ""
for label, cmd, deadline, env, ok_codes in update_steps(manager):
ok, out = run(cmd, deadline=deadline, env=env, ok_codes=ok_codes)
if not ok:
return {"ok": False, "detail": "apt-get update failed: " + out}
ok, out = run(["apt-get", "-y", "-o", "Dpkg::Options::=--force-confdef",
"-o", "Dpkg::Options::=--force-confold", "dist-upgrade"],
deadline=UPDATE_DEADLINE_SECONDS, env=env)
if not ok:
return {"ok": False, "detail": "apt-get dist-upgrade failed: " + out}
changed = upgraded_count(out)
run(["apt-get", "-y", "autoremove"], env=env)
elif shutil.which("dnf"):
ok, out = run(["dnf", "-y", "upgrade", "--refresh"], deadline=UPDATE_DEADLINE_SECONDS)
if not ok:
return {"ok": False, "detail": "dnf upgrade failed: " + out}
changed = upgraded_count(out)
else:
return {"ok": False, "detail": "no apt-get or dnf on this host"}
return {"ok": False, "detail": f"{label} failed: " + out}
changed = upgraded_count(out)
if manager == "apt":
run(["apt-get", "-y", "autoremove"], env={"DEBIAN_FRONTEND": "noninteractive"})
return {"ok": True, "packages": changed, "reboot_required": reboot_required(),
"detail": out[-500:]}