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 -4
View File
@@ -130,7 +130,7 @@ One Docker answer must arrive within 15 s and fit in 32 MiB, and one log answer
### What the host has waiting (1.16.0, #5765)
Once an hour, and when the agent starts, a background thread asks the package manager what it would upgrade, so a slow apt never holds up a sample. On apt hosts it uses `/usr/lib/update-notifier/apt-check` (pending;security), or `apt list --upgradable` when that's missing. On dnf hosts it uses `dnf -C check-update`, `dnf -C updateinfo list --security`, and `needs-restarting -r` for whether to reboot. None of this needs root or the network: the commands read lists the system's own timers already fetched. Each command has 120 s. The last answer rides every sample as `updates`. Every sample also reads Debian's `/var/run/reboot-required` (and `.pkgs`) as `reboot`. Steward records `updates_pending`, `updates_security` and `reboot_required` (1/0) for rules, and keeps the newest on the registration. The host page's Agent card leads with "Reboot required since …", then "N updates waiting (M security)" or "Up to date", with when the check ran. `state["updates_due"] = True` asks for a check on the next sample; a maintenance job uses it once it has updated the host.
Once an hour, and when the agent starts, a background thread asks the package manager what it would upgrade, so a slow apt never holds up a sample. On apt hosts it uses `/usr/lib/update-notifier/apt-check` (pending;security), or `apt list --upgradable` when that's missing. On dnf hosts it uses `dnf -C check-update`, `dnf -C updateinfo list --security`, and `needs-restarting -r` for whether to reboot; yum hosts the same through `yum -C` (1.21.0, #5803). On zypper hosts it uses `zypper --no-refresh list-updates` (`--dup` on Tumbleweed, which upgrades by distribution), `list-patches --category security` for the needed security patches (Tumbleweed has none to list), and `zypper needs-rebooting`. On pacman hosts it uses `checkupdates` from pacman-contrib, which install-deps.sh installs; pacman says nothing of security, and a reboot is required once the running kernel's `/usr/lib/modules/<release>` is gone, i.e. the kernel was upgraded under it. None of this needs root, and apart from `checkupdates` none of it needs the network: the commands read lists the system's own timers already fetched. `checkupdates` syncs a copy of the package lists into its own temporary directory, so it reaches the host's mirrors once an hour but never touches pacman's real database. Each command has 120 s. The last answer rides every sample as `updates`. Every sample also reads Debian's `/var/run/reboot-required` (and `.pkgs`) as `reboot`. Steward records `updates_pending`, `updates_security` and `reboot_required` (1/0) for rules, and keeps the newest on the registration. The host page's Agent card leads with "Reboot required since …", then "N updates waiting (M security)" or "Up to date", with when the check ran. `state["updates_due"] = True` asks for a check on the next sample; a maintenance job uses it once it has updated the host.
### A root helper for updates and reboots (1.17.0, #5766)
@@ -155,9 +155,12 @@ systemd when the agent leaves it a request:
whose id already has a result is not run again.
- **update**: `apt-get update` then `dist-upgrade` keeping existing config
files, with `NEEDRESTART_MODE=l` so needrestart only lists services rather
than restarting the Docker daemon under its containers; or
`dnf -y upgrade --refresh`. The result says how many packages changed and
whether a reboot is now required.
than restarting the Docker daemon under its containers;
`dnf -y upgrade --refresh` (or yum's); `zypper refresh` then
`zypper update` (`zypper dup` on Tumbleweed), counting zypper's 102/103
"reboot / restart needed" exits as success; or `pacman -Syu`
(#5804). The result says how many packages changed and whether a reboot is
now required.
- **reboot**: writes `rebooting`, syncs, `systemctl reboot`. Any request after
it waits for the next boot.
- **drain / undrain**: `docker node update --availability drain|active`, on
+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:]}
@@ -90,3 +90,118 @@ def test_a_reboot_is_required_when_debian_says_so(tmp_path):
assert got["required"] is True
assert got["packages"] == ["libc6", "linux-image-6.8.0-146-generic"]
assert got["since"].startswith("2026-10-14")
# ── pacman, zypper and yum (#5803) ───────────────────────────────────────────
CHECKUPDATES = ("linux 6.11.1.arch1-1 -> 6.11.2.arch1-1\n"
"openssl 3.3.2-1 -> 3.4.0-1\n"
"python-requests 2.32.3-1 -> 2.32.3-2\n")
ZYPPER_UPDATES = (
"S | Repository | Name | Current Version | Available Version | Arch\n"
"--+-----------------+-------------+---------------------+---------------------+-------\n"
"v | repo-sle-update | libopenssl3 | 3.1.4-150600.5.7.1 | 3.1.4-150600.5.10.1 | x86_64\n"
"v | repo-oss | vim | 9.1.0330-1.1 | 9.1.0698-1.1 | x86_64\n")
ZYPPER_PATCHES = (
"Repository | Name | Category | Severity | Interactive | Status | Since | Summary\n"
"----------------+-----------------------------+----------+-----------+-------------+------------+-------+--------\n"
"repo-sle-update | openSUSE-SLE-15.6-2024-3601 | security | important | --- | needed | - | Security update for openssl-3\n"
"repo-sle-update | openSUSE-SLE-15.6-2024-3500 | security | moderate | --- | applied | - | Security update for curl\n"
"repo-sle-update | openSUSE-SLE-15.6-2024-3488 | security | low | --- | not needed | - | Security update for ruby\n")
YUM_CHECK_UPDATE = ("Loaded plugins: fastestmirror\n\n"
"kernel.x86_64 3.10.0-1160.119.1.el7 updates\n"
"openssl.x86_64 1:1.0.2k-26.el7_9 updates\n")
def test_each_managers_list_is_counted():
assert a.parse_checkupdates(CHECKUPDATES) == 3
assert a.parse_checkupdates("") == 0
assert a.parse_zypper_updates(ZYPPER_UPDATES) == 2
assert a.parse_zypper_updates("No updates found.\n") == 0
assert a.parse_zypper_patches(ZYPPER_PATCHES) == 1 # applied and not-needed aren't waiting
assert a.parse_dnf_check_update(YUM_CHECK_UPDATE) == 2
def _host(monkeypatch, answers: dict, binaries=()):
"""A host whose commands answer from `answers` (keyed by the whole
command, its first two words, or its first) and with `binaries` on the PATH."""
calls = []
def fake_run(cmd, deadline=a.UPDATES_DEADLINE_SECONDS):
calls.append(" ".join(cmd))
for key in (" ".join(cmd), " ".join(cmd[:2]), cmd[0]):
if key in answers:
return answers[key]
return None
monkeypatch.setattr(a, "_run", fake_run)
monkeypatch.setattr(a.shutil, "which", lambda name: f"/usr/bin/{name}" if name in binaries else None)
return calls
def test_a_pacman_host_is_checked_with_checkupdates(monkeypatch):
_host(monkeypatch, {"checkupdates": (0, CHECKUPDATES, "")}, binaries=("pacman",))
got = a.collect_updates()
assert (got["manager"], got["pending"], got["security"]) == ("pacman", 3, None)
_host(monkeypatch, {"checkupdates": (2, "", "")}, binaries=("pacman",)) # 2: up to date
assert a.collect_updates()["pending"] == 0
def test_a_pacman_host_without_checkupdates_says_what_is_missing(monkeypatch):
_host(monkeypatch, {}, binaries=("pacman",))
got = a.collect_updates()
assert got["manager"] == "pacman" and "pacman-contrib" in got["error"]
def test_a_zypper_host_reads_updates_security_patches_and_reboot(monkeypatch):
monkeypatch.setattr(a, "_os_release_id", lambda path="/etc/os-release": "opensuse-leap")
_host(monkeypatch, {
"zypper --no-refresh -q list-updates": (0, ZYPPER_UPDATES, ""),
"zypper --no-refresh -q list-patches --category security": (0, ZYPPER_PATCHES, ""),
"zypper needs-rebooting": (102, "", "")}) # 102: a reboot is needed
got = a.collect_updates()
assert (got["manager"], got["pending"], got["security"]) == ("zypper", 2, 1)
assert got["reboot_required"] is True
def test_tumbleweed_counts_what_dup_would_change_and_has_no_security_patches(monkeypatch):
monkeypatch.setattr(a, "_os_release_id", lambda path="/etc/os-release": "opensuse-tumbleweed")
calls = _host(monkeypatch, {"zypper --no-refresh": (0, ZYPPER_UPDATES, ""),
"zypper needs-rebooting": (0, "", "")})
got = a.collect_updates()
assert got["pending"] == 2 and got["security"] is None and got["reboot_required"] is False
assert "zypper --no-refresh -q list-updates --dup" in calls
assert not any("list-patches" in c for c in calls)
def test_a_yum_host_without_dnf_is_checked_like_dnf(monkeypatch):
_host(monkeypatch, {"yum -q": (100, YUM_CHECK_UPDATE, ""), "needs-restarting -r": (1, "", "")})
got = a.collect_updates()
assert (got["manager"], got["pending"]) == ("yum", 2)
assert got["reboot_required"] is True
def test_a_failed_check_names_its_manager(monkeypatch):
_host(monkeypatch, {"zypper --no-refresh": (7, "", "System management is locked by PackageKit")})
monkeypatch.setattr(a, "_os_release_id", lambda path="/etc/os-release": "sles")
got = a.collect_updates()
assert got["manager"] == "zypper" and "locked" in got["error"]
def test_a_pacman_host_needs_a_reboot_once_its_running_kernel_is_replaced(monkeypatch, tmp_path):
monkeypatch.setattr(a.shutil, "which", lambda name: "/usr/bin/pacman" if name == "pacman" else None)
modules = tmp_path / "modules"
(modules / os.uname().release).mkdir(parents=True)
no_marker = str(tmp_path / "reboot-required")
assert a.collect_reboot(no_marker, str(modules)) == {"required": False}
(modules / os.uname().release).rmdir() # the upgrade removed it
assert a.collect_reboot(no_marker, str(modules)) == {"required": True}
def test_a_host_with_no_reboot_marker_to_read_says_nothing(monkeypatch, tmp_path):
monkeypatch.setattr(a.shutil, "which", lambda name: None)
assert a.collect_reboot(str(tmp_path / "reboot-required")) is None
@@ -173,3 +173,85 @@ async def test_steward_serves_only_the_listed_files(app):
for name in ("../agent.py", "agent.py", "routes.py", "../../steward/config.py"):
_, status = await agent_file(name)
assert status == 404, name
# ── pacman, zypper and yum hosts (#5804) ─────────────────────────────────────
def test_the_helper_counts_what_zypper_and_pacman_changed(maint):
zypper = ("The following 15 packages are going to be upgraded:\n bash vim ...\n"
"15 packages to upgrade, 2 new.\nOverall download size: 48.2 MiB.\n")
assert maint.upgraded_count(zypper) == 17
pacman = ("resolving dependencies...\nlooking for conflicting packages...\n\n"
"Packages (12) linux-6.11.2.arch1-1 openssl-3.4.0-1 ...\n\n"
"Total Download Size: 152.31 MiB\n")
assert maint.upgraded_count(pacman) == 12
yum = "Transaction Summary\n====\nUpgrade 4 Packages\n"
assert maint.upgraded_count(yum) == 4
def _manager(monkeypatch, maint, binaries):
monkeypatch.setattr(maint.shutil, "which",
lambda name: f"/usr/bin/{name}" if name in binaries else None)
@pytest.mark.parametrize("binaries, manager", [
(("apt-get",), "apt"), (("dnf", "yum"), "dnf"), (("yum",), "yum"),
(("zypper",), "zypper"), (("pacman",), "pacman"), ((), None)])
def test_the_helper_picks_the_hosts_package_manager(maint, monkeypatch, binaries, manager):
_manager(monkeypatch, maint, binaries)
assert maint.host_manager() == manager
def test_each_manager_upgrades_without_asking(maint, monkeypatch):
commands = {m: [step[1] for step in maint.update_steps(m)]
for m in ("apt", "dnf", "yum", "pacman")}
assert commands["pacman"] == [["pacman", "-Syu", "--noconfirm"]]
assert commands["yum"] == [["yum", "-y", "update"]]
assert commands["dnf"] == [["dnf", "-y", "upgrade", "--refresh"]]
assert commands["apt"][-1][-1] == "dist-upgrade"
monkeypatch.setattr(maint, "_os_release_id", lambda path="/etc/os-release": "opensuse-leap")
leap = [step[1] for step in maint.update_steps("zypper")]
assert leap == [["zypper", "--non-interactive", "refresh"],
["zypper", "--non-interactive", "update", "--auto-agree-with-licenses"]]
monkeypatch.setattr(maint, "_os_release_id", lambda path="/etc/os-release": "opensuse-tumbleweed")
assert maint.update_steps("zypper")[-1][1][2] == "dup" # a rolling release upgrades with dup
def test_a_zypper_update_that_asks_for_a_reboot_still_worked(maint, monkeypatch):
"""zypper exits 102 when the update needs a reboot: done, not failed."""
_manager(monkeypatch, maint, ("zypper",))
monkeypatch.setattr(maint, "_os_release_id", lambda path="/etc/os-release": "sles")
ran = []
def fake_run(cmd, deadline=0, env=None, ok_codes=(0,)):
ran.append(cmd[2])
code = 102 if cmd[2] == "update" else 0
return code in ok_codes, "3 packages to upgrade.\n"
monkeypatch.setattr(maint, "run", fake_run)
monkeypatch.setattr(maint, "reboot_required", lambda: True)
got = maint.do_update()
assert got["ok"] and got["packages"] == 3 and got["reboot_required"] is True
assert ran == ["refresh", "update"]
def test_a_failed_step_says_which(maint, monkeypatch):
_manager(monkeypatch, maint, ("pacman",))
monkeypatch.setattr(maint, "run", lambda cmd, **kw: (False, "error: failed to synchronize all databases"))
got = maint.do_update()
assert not got["ok"] and got["detail"].startswith("pacman -Syu failed: error: failed to synchronize")
def test_a_host_with_no_package_manager_it_knows_says_so(maint, monkeypatch):
_manager(monkeypatch, maint, ())
assert "apt, dnf, yum, zypper, pacman" in maint.do_update()["detail"]
def test_a_pacman_host_needs_a_reboot_once_the_running_kernel_is_replaced(maint, monkeypatch, tmp_path):
_manager(monkeypatch, maint, ("pacman",))
monkeypatch.setattr(maint, "REBOOT_MARKER", str(tmp_path / "reboot-required")) # none
monkeypatch.setattr(maint, "KERNEL_MODULES", str(tmp_path))
(tmp_path / os.uname().release).mkdir()
assert maint.reboot_required() is False
(tmp_path / os.uname().release).rmdir()
assert maint.reboot_required() is True