Files
FabledSteward/tests/plugins/host_agent/test_maint_helper.py
T
bvandeusenandClaude Opus 5.5 4054bf6bbb
CI / lint (push) Successful in 3s
CI / integration (push) Successful in 1m26s
CI / unit (push) Successful in 1m35s
CI / publish (push) Successful in 20s
feat(host-agent): updates on pacman, zypper and yum hosts (#5803, #5804)
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>
2026-10-15 11:47:13 -04:00

258 lines
12 KiB
Python

# tests/plugins/host_agent/test_maint_helper.py
"""#5766: the root helper the agent can start, and the agent's side of it.
The helper runs as root and reads files an unprivileged agent wrote, so what it
refuses matters as much as what it does: a link, a big file, an action not on
its list. The agent leaves requests and reports results; Steward serves the
helper and the units from one copy.
"""
import importlib.util
import json
import os
from pathlib import Path
import pytest
from plugins.host_agent import agent as a
FILES = Path(__file__).resolve().parents[3] / "plugins" / "host_agent" / "files"
@pytest.fixture
def maint():
spec = importlib.util.spec_from_file_location("steward_maint", FILES / "maint.py")
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
def _write(path, req):
path.write_text(json.dumps(req))
return str(path)
def test_the_helper_reads_a_request_naming_one_of_its_actions(maint, tmp_path):
for action in ("update", "reboot", "drain", "undrain"):
assert maint.read_request(_write(tmp_path / "r.json", {"id": "job-1", "action": action}))
node = "abcdefghijklmnopqrstuvwxy"
assert maint.read_request(_write(tmp_path / "n.json",
{"id": "job-2", "action": "drain", "node": node}))["node"] == node
@pytest.mark.parametrize("req", [
{"id": "job-1", "action": "shell"}, # not on the list
{"id": "job-1", "action": "update; rm -rf /"},
{"id": "../../etc/x", "action": "update"}, # an id that walks paths
{"id": "", "action": "update"},
{"id": "job-1", "action": "drain", "node": "--force"}, # a flag, not a node id
["update"],
])
def test_the_helper_refuses_anything_else(maint, tmp_path, req):
assert maint.read_request(_write(tmp_path / "r.json", req)) is None
def test_the_helper_never_follows_a_link_the_agent_planted(maint, tmp_path):
target = tmp_path / "secret.json"
_write(target, {"id": "job-1", "action": "update"})
link = tmp_path / "r.json"
link.symlink_to(target)
assert maint.read_request(str(link)) is None
def test_the_helper_refuses_a_big_file_and_a_non_file(maint, tmp_path):
big = tmp_path / "big.json"
big.write_text(json.dumps({"id": "job-1", "action": "update", "pad": "x" * 5000}))
assert maint.read_request(str(big)) is None
(tmp_path / "dir.json").mkdir()
assert maint.read_request(str(tmp_path / "dir.json")) is None
assert maint.read_request(str(tmp_path / "missing.json")) is None
def test_the_helper_counts_what_an_upgrade_changed(maint):
assert maint.upgraded_count("12 upgraded, 2 newly installed, 0 to remove and 0 not upgraded.") == 14
dnf = "Transaction Summary\n====\nInstall 1 Package\nUpgrade 23 Packages\n"
assert maint.upgraded_count(dnf) == 24
assert maint.upgraded_count("nothing to do") is None
def test_a_request_is_deleted_whatever_it_held_and_a_repeat_is_not_run_twice(maint, tmp_path, monkeypatch):
requests, results = tmp_path / "req", tmp_path / "res"
requests.mkdir()
monkeypatch.setattr(maint, "REQUESTS", str(requests))
monkeypatch.setattr(maint, "RESULTS", str(results))
ran = []
monkeypatch.setattr(maint, "do_node", lambda avail, node: ran.append(avail) or {"ok": True})
_write(requests / "bad.json", {"id": "x", "action": "shell"})
_write(requests / "job-1.json", {"id": "job-1", "action": "drain"})
maint.main()
assert list(requests.iterdir()) == []
assert json.loads((results / "job-1.json").read_text())["state"] == "done"
_write(requests / "job-1.json", {"id": "job-1", "action": "drain"})
maint.main()
assert ran == ["drain"]
def test_a_reboot_says_so_before_it_goes(maint, tmp_path, monkeypatch):
monkeypatch.setattr(maint, "RESULTS", str(tmp_path))
called = []
monkeypatch.setattr(maint, "run", lambda cmd, deadline=0, env=None: called.append(cmd) or (True, ""))
monkeypatch.setattr(maint.os, "sync", lambda: None)
out = maint.handle({"id": "job-9", "action": "reboot"})
assert out["state"] == "rebooting"
assert json.loads((tmp_path / "job-9.json").read_text())["state"] == "rebooting"
assert called == [["systemctl", "reboot"]]
# ── the agent's side ─────────────────────────────────────────────────────────
def test_the_agent_leaves_a_whole_request_the_helper_accepts(maint, tmp_path):
a.request_maintenance("job-3", "drain", node="abcdefghijklmnopqrstuvwxy", requests_dir=str(tmp_path))
assert sorted(os.listdir(tmp_path)) == ["job-3.json"] # no temp file left behind
assert maint.read_request(str(tmp_path / "job-3.json"))["action"] == "drain"
def test_the_agent_refuses_to_ask_for_anything_else(tmp_path):
with pytest.raises(ValueError):
a.request_maintenance("job-3", "shell", requests_dir=str(tmp_path))
with pytest.raises(ValueError):
a.request_maintenance("../x", "update", requests_dir=str(tmp_path))
assert os.listdir(tmp_path) == []
def test_each_result_state_is_reported_once(tmp_path):
res = tmp_path / "job-4.json"
res.write_text(json.dumps({"id": "job-4", "action": "update", "state": "running"}))
seen: set = set()
events, keys = a.maintenance_results(seen, results_dir=str(tmp_path))
assert [e["state"] for e in events] == ["running"] and events[0]["kind"] == "maintenance"
assert a.maintenance_results(seen, results_dir=str(tmp_path))[0] # not posted yet: said again
seen.update(keys)
assert a.maintenance_results(seen, results_dir=str(tmp_path)) == ([], [])
res.write_text(json.dumps({"id": "job-4", "action": "update", "state": "done", "ok": True,
"packages": 12, "reboot_required": True}))
events, _ = a.maintenance_results(seen, results_dir=str(tmp_path))
assert events == [{"kind": "maintenance", "id": "job-4", "action": "update", "state": "done",
"ok": True, "packages": 12, "reboot_required": True}]
def test_an_old_or_broken_result_is_not_reported(tmp_path):
old = tmp_path / "job-5.json"
old.write_text(json.dumps({"id": "job-5", "action": "update", "state": "done"}))
week_ago = os.stat(old).st_mtime - 7 * 86400
os.utime(old, (week_ago, week_ago))
(tmp_path / "job-6.json").write_text("{not json")
(tmp_path / "job-7.json").write_text(json.dumps({"id": "../x", "state": "done"}))
assert a.maintenance_results(set(), results_dir=str(tmp_path)) == ([], [])
assert a.maintenance_results(set(), results_dir=str(tmp_path / "missing")) == ([], [])
def test_the_agent_says_whether_the_helper_is_installed(tmp_path):
unit, requests = tmp_path / "steward-maint.path", tmp_path / "requests"
assert a.maint_helper_installed(str(unit), str(requests)) is False
unit.write_text("[Path]\n")
requests.mkdir()
assert a.maint_helper_installed(str(unit), str(requests)) is True
# ── what Steward serves ──────────────────────────────────────────────────────
def test_the_helper_and_the_agent_agree_on_paths_and_actions(maint):
assert a.MAINT_REQUESTS == maint.REQUESTS and a.MAINT_RESULTS == maint.RESULTS
assert a.MAINT_ACTIONS == maint.ACTIONS
path_unit = (FILES / "steward-maint.path").read_text()
assert f"PathExistsGlob={maint.REQUESTS}/*.json" in path_unit
async def test_steward_serves_only_the_listed_files(app):
from plugins.host_agent.routes import AGENT_FILES, agent_file
assert sorted(AGENT_FILES) == sorted(p.name for p in FILES.iterdir() if p.is_file())
async with app.test_request_context("/plugins/host_agent/files/maint.py"):
ok = await agent_file("maint.py")
assert ok.status_code == 200
assert "ACTIONS = (" in await ok.get_data(as_text=True)
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