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
+27
View File
@@ -475,6 +475,10 @@ async def ingest():
if field in md and getattr(reg, field) != md[field]:
setattr(reg, field, md[field])
changed = True
# Whether the maintenance helper is installed (agent ≥ 1.17.0, #5766).
if isinstance(md.get("maint_helper"), bool) and reg.maint_helper != md["maint_helper"]:
reg.maint_helper = md["maint_helper"]
changed = True
host_ip = md.get("host_ip")
if host_ip and reg.host_ip != host_ip:
reg.host_ip = host_ip
@@ -631,6 +635,29 @@ async def agent_source():
return Response(body, mimetype="text/x-python")
# What a host installs besides agent.py: the units and the maintenance helper
# (#5766), served from one copy so install.sh and every playbook agree.
AGENT_FILES_DIR = Path(__file__).parent / "files"
AGENT_FILES = {
"maint.py": "text/x-python",
"steward-agent.service": "text/plain",
"steward-maint.service": "text/plain",
"steward-maint.path": "text/plain",
}
@host_agent_bp.get("/files/<name>")
@public_route("fetched by the installer and the agent playbooks; holds no secrets")
async def agent_file(name: str):
if name not in AGENT_FILES:
return _error(404, "not_found")
try:
body = (AGENT_FILES_DIR / name).read_text(encoding="utf-8")
except OSError:
return _error(500, "agent_missing")
return Response(body, mimetype=AGENT_FILES[name])
_FLEET_ORDER = {"stale": 0, "pending": 1, "up": 2}