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
@@ -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