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
+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:]}