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
+87 -2
View File
@@ -22,7 +22,7 @@ from collections import deque
from concurrent.futures import ThreadPoolExecutor, wait as wait_futures
from datetime import datetime, timezone
AGENT_VERSION = "1.16.0"
AGENT_VERSION = "1.17.0"
# Default path to the local Docker Engine socket. Overridable via the
# `docker_socket` config key; collection is silently skipped if it's absent or
@@ -291,6 +291,7 @@ def collect_metadata() -> dict:
"distro": distro,
"arch": u.machine,
"host_ip": detect_primary_ip(),
"maint_helper": maint_helper_installed(),
}
@@ -1441,6 +1442,82 @@ def collect_reboot(path: str = REBOOT_REQUIRED):
"packages": packages}
# ── maintenance helper (#5766) ───────────────────────────────────────────────
# The agent can't update or reboot its host: it runs unprivileged under
# NoNewPrivileges. It leaves a request in MAINT_REQUESTS, the one directory its
# unit lets it write; steward-maint.path starts the root helper (maint.py),
# which does only update / reboot / drain / undrain and writes each outcome to
# MAINT_RESULTS. The agent reads those back and reports each new state once.
MAINT_REQUESTS = "/var/lib/steward-agent/requests"
MAINT_RESULTS = "/var/lib/steward-maint/results"
MAINT_PATH_UNIT = "/etc/systemd/system/steward-maint.path"
MAINT_ACTIONS = ("update", "reboot", "drain", "undrain")
MAINT_RESULT_MAX_BYTES = 8192
# The helper keeps results a week; only this recent a one is reported, so a
# job a reboot interrupted still reaches Steward from the agent's next start
# and old ones don't repeat on every start.
MAINT_REPORT_WINDOW_SECONDS = 86400
_MAINT_ID = re.compile(r"^[A-Za-z0-9-]{1,64}$")
_MAINT_FIELDS = ("action", "state", "ok", "packages", "reboot_required", "node",
"detail", "started_at", "finished_at")
def maint_helper_installed(unit: str = MAINT_PATH_UNIT,
requests_dir: str = MAINT_REQUESTS) -> bool:
"""The helper's path unit is installed and the agent may leave it a request."""
return os.path.exists(unit) and os.access(requests_dir, os.W_OK)
def request_maintenance(job_id: str, action: str, node: str | None = None,
requests_dir: str = MAINT_REQUESTS) -> None:
"""Leave the helper a request, whole: written to a dot-file the path unit
doesn't match, then renamed to `<id>.json`."""
if action not in MAINT_ACTIONS or not _MAINT_ID.match(job_id or ""):
raise ValueError(f"not a maintenance request: {action!r} {job_id!r}")
req = {"id": job_id, "action": action}
if node:
req["node"] = node
tmp = os.path.join(requests_dir, f".{job_id}.tmp")
with open(tmp, "w") as fh:
json.dump(req, fh)
os.replace(tmp, os.path.join(requests_dir, f"{job_id}.json"))
def maintenance_results(seen: set, results_dir: str = MAINT_RESULTS,
now: float | None = None) -> tuple:
"""(events, keys): a `maintenance` event for each result whose (id, state)
isn't in `seen`. The caller adds `keys` to `seen` once Steward has them."""
now = time.time() if now is None else now
try:
names = sorted(n for n in os.listdir(results_dir) if n.endswith(".json"))
except OSError:
return [], []
events, keys = [], []
for name in names:
path = os.path.join(results_dir, name)
try:
st = os.stat(path)
if st.st_size > MAINT_RESULT_MAX_BYTES:
continue
with open(path) as fh:
res = json.load(fh)
except (OSError, ValueError):
continue # mid-write or gone: next interval
if not isinstance(res, dict) or not _MAINT_ID.match(str(res.get("id", ""))):
continue
key = (res["id"], res.get("state"))
if key in seen:
continue
if now - st.st_mtime > MAINT_REPORT_WINDOW_SECONDS:
continue # long finished: not news to anyone
event = {"kind": "maintenance", "id": res["id"]}
event.update({k: res[k] for k in _MAINT_FIELDS if k in res})
events.append(event)
keys.append(key)
return events, keys
def collect_self(state: dict) -> dict:
"""The agent's own footprint: resident memory and CPU % of one core since
the last sample (#5764). A 1.13.0 agent once grew to 2.1 GB and spun for a
@@ -1764,6 +1841,7 @@ def main_loop(conf_path: str) -> int:
lost_since = None
# Carries previous net/disk counters + monotonic ts for rate computation.
rate_state: dict = {}
maint_seen: set = set()
_log("INFO", f"steward-host-agent {AGENT_VERSION} starting "
f"(url={cfg['url']}, interval={cfg['interval_seconds']}s)")
@@ -1787,11 +1865,13 @@ def main_loop(conf_path: str) -> int:
docker_log_exclude=docker_log_exclude)
buffered = buffer.drain()
send, held = split_backlog(buffered)
maint_events, maint_keys = maintenance_results(maint_seen)
payload = build_payload(
samples=send + [sample],
hostname=hostname,
metadata=metadata,
events=events + ([lost_contact_event(lost_since)] if lost_since else []),
events=events + ([lost_contact_event(lost_since)] if lost_since else [])
+ maint_events,
)
ok, status = post_payload(cfg["url"], cfg["token"], payload)
@@ -1799,6 +1879,11 @@ def main_loop(conf_path: str) -> int:
if lost_since:
_log("INFO", f"reached Steward again (unreachable since {lost_since})")
events, lost_since = [], None
maint_seen.update(maint_keys)
# An update just finished: what's left waiting is known now, not in an hour.
if any(e.get("action") == "update" and e.get("state") in ("done", "failed")
for e in maint_events):
rate_state["updates_due"] = True
for s in held:
buffer.push(s)
if send: