#5766 "A root helper the agent can start, for a fixed set of maintenance actions and nothing else", milestone 514. The agent runs unprivileged under NoNewPrivileges, so it can't update or reboot its host. It now leaves a request in /var/lib/steward-agent/requests, the one path its unit lets it write; steward-maint.path starts maint.py as root, which reads it without following links, accepts only update, reboot, drain or undrain, deletes it, and writes the outcome to /var/lib/steward-maint/results. The agent (1.17.0) reports each new result state once as a `maintenance` event, re-checks pending updates when an update finishes, and says in its metadata whether the helper is installed. The units and the helper are served from one copy at /plugins/host_agent/files/<name> (a fixed list), and install.sh plus the install, provision and update playbooks all fetch them, replacing three inline copies of the unit. Update agent installs the helper on existing hosts; the Agent panel says under Manage agent whether it is there. Steward handing out jobs is #5767; the cadence is #5768. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
176 lines
8.0 KiB
Python
176 lines
8.0 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
|