feat(host_agent): a root helper the agent can start, for updates, reboots and drains only (#5766)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 1m22s
CI / unit (push) Successful in 1m24s
CI / publish (push) Successful in 1m12s

#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>
This commit is contained in:
2026-10-14 18:34:28 -04:00
co-authored by Claude Opus 5.5
parent 210a35c956
commit 6a68bd75d9
17 changed files with 851 additions and 175 deletions
+19
View File
@@ -260,3 +260,22 @@ async def test_the_agent_card_leads_with_a_needed_reboot_then_the_updates(app):
assert page.index("Reboot required") < page.index("12 updates waiting (3 security)")
assert "libc6" in page
assert "Up to date" in quiet and "Reboot required" not in quiet
async def test_manage_agent_says_whether_the_maintenance_helper_is_installed(app):
"""#5766: the helper arrives with Update agent; until then the panel says so."""
from datetime import datetime, timezone
from tests._templates import render_plugin_template
host = SimpleNamespace(id="h1", name="box", address="10.0.0.5")
reg = SimpleNamespace(agent_version="1.17.0", last_seen_at=datetime.now(timezone.utc),
maint_helper=True)
kw = dict(host=host, reporting=True, stale=False, target=None, ansible_available=False,
managed_key_set=False, downtime=[])
async with app.test_request_context("/plugins/host_agent/panel/h1"):
session["user_role"] = "admin"
installed = await render_plugin_template(app, "host_agent", "panel.html", reg=reg, **kw)
missing = await render_plugin_template(
app, "host_agent", "panel.html",
reg=SimpleNamespace(**{**vars(reg), "maint_helper": None}), **kw)
assert "Maintenance helper installed" in installed
assert "No maintenance helper yet: Update agent installs it" in missing
@@ -30,27 +30,37 @@ def test_render_contains_token_and_url():
assert "testhost" in out
def test_render_contains_hardening_directives():
out = _render()
UNIT_DIR = TEMPLATE_DIR.parent / "files"
def test_the_agent_unit_is_hardened_and_may_write_only_its_requests():
unit = (UNIT_DIR / "steward-agent.service").read_text()
for needle in (
"NoNewPrivileges=yes",
"User=steward-agent",
"NoNewPrivileges=true",
"ProtectSystem=strict",
"ProtectHome=yes",
"PrivateTmp=yes",
"ProtectHome=true",
"PrivateTmp=true",
"ReadOnlyPaths=/proc /sys",
):
assert needle in out, f"missing {needle}"
assert needle in unit, f"missing {needle}"
writable = [ln for ln in unit.splitlines() if ln.startswith("ReadWritePaths=")]
assert writable == ["ReadWritePaths=-/var/lib/steward-agent/requests"]
def test_the_script_installs_the_served_units_not_its_own_copy():
out = _render()
assert "[Service]" not in out # no inline unit left to drift
assert 'UNITS="steward-agent.service steward-maint.service steward-maint.path"' in out
assert '${FILES_URL}/$u' in out and '${FILES_URL}/maint.py' in out
assert "systemctl enable --now steward-maint.path" in out
assert "systemctl restart steward-agent.service" in out
def test_render_has_uninstall_branch():
out = _render()
assert "--uninstall" in out
assert "systemctl disable --now steward-agent.service" in out
def test_render_has_systemctl_enable():
out = _render()
assert "systemctl enable --now steward-agent.service" in out
assert "systemctl disable --now steward-maint.path steward-agent.service" in out
def test_render_uses_agent_py_url():
@@ -0,0 +1,175 @@
# 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