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
@@ -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():