diff --git a/docs/plugins/host-agent-design.md b/docs/plugins/host-agent-design.md index 6693995..dcacb64 100644 --- a/docs/plugins/host-agent-design.md +++ b/docs/plugins/host-agent-design.md @@ -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/` 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 diff --git a/plugins/host_agent/agent.py b/plugins/host_agent/agent.py index 0623bb0..ce36d80 100644 --- a/plugins/host_agent/agent.py +++ b/plugins/host_agent/agent.py @@ -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 ";" 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: diff --git a/plugins/host_agent/files/install-deps.sh b/plugins/host_agent/files/install-deps.sh index 3ad626a..6bbab5c 100644 --- a/plugins/host_agent/files/install-deps.sh +++ b/plugins/host_agent/files/install-deps.sh @@ -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 diff --git a/plugins/host_agent/files/maint.py b/plugins/host_agent/files/maint.py index 8a390f2..4a31b72 100644 --- a/plugins/host_agent/files/maint.py +++ b/plugins/host_agent/files/maint.py @@ -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:]} diff --git a/tests/plugins/host_agent/test_agent_updates.py b/tests/plugins/host_agent/test_agent_updates.py index 231da8d..3e7e41a 100644 --- a/tests/plugins/host_agent/test_agent_updates.py +++ b/tests/plugins/host_agent/test_agent_updates.py @@ -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 diff --git a/tests/plugins/host_agent/test_maint_helper.py b/tests/plugins/host_agent/test_maint_helper.py index 90b7ec2..1d275a4 100644 --- a/tests/plugins/host_agent/test_maint_helper.py +++ b/tests/plugins/host_agent/test_maint_helper.py @@ -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