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>
258 lines
12 KiB
Python
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
|