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>
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user