Files
FabledSteward/tests/plugins/host_agent/test_maint_helper.py
T
bvandeusenandClaude Opus 5.5 6a68bd75d9
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 1m22s
CI / unit (push) Successful in 1m24s
CI / publish (push) Successful in 1m12s
feat(host_agent): a root helper the agent can start, for updates, reboots and drains only (#5766)
#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>
2026-10-14 18:34:28 -04:00

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