From 6a68bd75d9c10e48854b4b94e38fa54283738ce7 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 14 Oct 2026 18:34:28 -0400 Subject: [PATCH] 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/ (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 --- docs/plugins/host-agent-design.md | 143 +++++------ plugins/host_agent/agent.py | 89 ++++++- plugins/host_agent/files/maint.py | 243 ++++++++++++++++++ .../host_agent/files/steward-agent.service | 26 ++ plugins/host_agent/files/steward-maint.path | 10 + .../host_agent/files/steward-maint.service | 10 + .../versions/host_agent_006_maint_helper.py | 21 ++ plugins/host_agent/models.py | 4 + plugins/host_agent/routes.py | 27 ++ plugins/host_agent/templates/install.sh.j2 | 52 ++-- plugins/host_agent/templates/panel.html | 5 + .../ansible/bundled/host_agent/install.yml | 61 +++-- .../ansible/bundled/host_agent/provision.yml | 61 +++-- steward/ansible/bundled/host_agent/update.yml | 46 +++- tests/core/test_host_page.py | 19 ++ .../host_agent/test_install_template.py | 34 ++- tests/plugins/host_agent/test_maint_helper.py | 175 +++++++++++++ 17 files changed, 851 insertions(+), 175 deletions(-) create mode 100644 plugins/host_agent/files/maint.py create mode 100644 plugins/host_agent/files/steward-agent.service create mode 100644 plugins/host_agent/files/steward-maint.path create mode 100644 plugins/host_agent/files/steward-maint.service create mode 100644 plugins/host_agent/migrations/versions/host_agent_006_maint_helper.py create mode 100644 tests/plugins/host_agent/test_maint_helper.py diff --git a/docs/plugins/host-agent-design.md b/docs/plugins/host-agent-design.md index 6e32fd5..1f7dfd7 100644 --- a/docs/plugins/host-agent-design.md +++ b/docs/plugins/host-agent-design.md @@ -58,9 +58,13 @@ Give Steward a fleet-glance view of remote Linux hosts — current resource usag ### File layout on the target host ``` -/usr/local/lib/steward-agent/agent.py # the script, target ~300 lines +/usr/local/lib/steward-agent/agent.py # the agent (runs as steward-agent) +/usr/local/lib/steward-agent/maint.py # the maintenance helper (runs as root, #5766) /etc/steward-agent.conf # key=value config, 0640 root:steward-agent -/etc/systemd/system/steward-agent.service # unit file +/etc/systemd/system/steward-agent.service # the agent's unit +/etc/systemd/system/steward-maint.{path,service} # starts the helper on a request +/var/lib/steward-agent/requests/ # the one place the agent writes (0700 steward-agent) +/var/lib/steward-maint/results/ # the helper's outcomes (root, readable) # dedicated system user: steward-agent ``` @@ -128,6 +132,50 @@ One Docker answer must arrive within 15 s and fit in 32 MiB, and one log answer Once an hour, and when the agent starts, a background thread asks the package manager what it would upgrade, so a slow apt never holds up a sample. On apt hosts it uses `/usr/lib/update-notifier/apt-check` (pending;security), or `apt list --upgradable` when that's missing. On dnf hosts it uses `dnf -C check-update`, `dnf -C updateinfo list --security`, and `needs-restarting -r` for whether to reboot. None of this needs root or the network: the commands read lists the system's own timers already fetched. Each command has 120 s. The last answer rides every sample as `updates`. Every sample also reads Debian's `/var/run/reboot-required` (and `.pkgs`) as `reboot`. Steward records `updates_pending`, `updates_security` and `reboot_required` (1/0) for rules, and keeps the newest on the registration. The host page's Agent card leads with "Reboot required since …", then "N updates waiting (M security)" or "Up to date", with when the check ran. `state["updates_due"] = True` asks for a check on the next sample; a maintenance job uses it once it has updated the host. + +### A root helper for updates and reboots (1.17.0, #5766) + +The agent can't update or reboot its host: it runs as `steward-agent` under +`NoNewPrivileges` and `ProtectSystem=strict`, so no sudo is possible, and that +is how it should stay. A separate helper, `maint.py`, runs as root, started by +systemd when the agent leaves it a request: + +- The agent writes `.json` = `{"id", "action", ["node"]}` into + `/var/lib/steward-agent/requests`, the one path its unit lets it write + (`ReadWritePaths=-…`; the `-` keeps an old host without the directory + starting). It writes a dot-file first and renames it, so the helper never + sees half a request. +- `steward-maint.path` (`PathExistsGlob=…/requests/*.json`) starts + `steward-maint.service`, a oneshot running `maint.py` as root. +- The helper opens each request with `O_NOFOLLOW`, wants a regular file under + 4 KiB, and accepts only `update`, `reboot`, `drain` or `undrain`, an id of + letters, digits and dashes, and a node id in the swarm's own 25-character + form. It deletes every request, valid or not, and writes the outcome to + `/var/lib/steward-maint/results/.json` (root's; kept a week). A request + whose id already has a result is not run again. +- **update**: `apt-get update` then `dist-upgrade` keeping existing config + files, with `NEEDRESTART_MODE=l` so needrestart only lists services rather + than restarting the Docker daemon under its containers; or + `dnf -y upgrade --refresh`. The result says how many packages changed and + whether a reboot is now required. +- **reboot**: writes `rebooting`, syncs, `systemctl reboot`. Any request after + it waits for the next boot. +- **drain / undrain**: `docker node update --availability drain|active`, on + the given node or this host's own. Only a manager can; a worker's helper says + so in its result. + +The agent reports each result's new state once, as a `maintenance` event with +its next post that gets through (results older than a day are not news). When +an update finishes it re-checks what's waiting straight away rather than at +the next hourly check. Its metadata says `maint_helper: true` once the path +unit is installed and it can write its request directory; the host's Agent +panel shows that under Manage agent. **Update agent** installs the helper on a +host that has an older agent. + +So a stolen agent token can make its host update, reboot or drain, and nothing +else. Steward handing a host a job and following it through the reboot is +#5767; the cadence that does it one host at a time is #5768. + ### What happened to the host (1.14.0, #5756) A post may carry `events`, said once each, with the next post that gets through: @@ -362,92 +410,17 @@ sudo sh install.sh ### Rendered script structure (Jinja template `install.sh.j2`) -```sh -#!/bin/sh -# Steward host agent installer -# Generated for: {{ host_name }} ({{ host_address }}) -# Steward URL: {{ url }} -set -e - -STEWARD_URL="{{ url }}" -AGENT_TOKEN="{{ token }}" -AGENT_VERSION="{{ agent_version }}" - -AGENT_USER="steward-agent" -AGENT_DIR="/usr/local/lib/steward-agent" -CONF_FILE="/etc/steward-agent.conf" -UNIT_FILE="/etc/systemd/system/steward-agent.service" - -# ── preflight ──────────────────────────────────────────────────────────────── -[ "$(id -u)" = "0" ] || { echo "must run as root (use sudo)"; exit 1; } -command -v systemctl >/dev/null 2>&1 || { echo "systemd not found — unsupported init system"; exit 1; } -command -v python3 >/dev/null 2>&1 || { echo "python3 not found — install python3 first"; exit 1; } - -# Handle --uninstall -if [ "${1:-}" = "--uninstall" ]; then - systemctl disable --now steward-agent.service 2>/dev/null || true - rm -f "$UNIT_FILE" "$CONF_FILE" - rm -rf "$AGENT_DIR" - systemctl daemon-reload - # keep the system user — removing it risks orphaned files elsewhere - echo "uninstalled" - exit 0 -fi - -# ── create system user ─────────────────────────────────────────────────────── -if ! id "$AGENT_USER" >/dev/null 2>&1; then - useradd --system --no-create-home --shell /usr/sbin/nologin "$AGENT_USER" -fi - -# ── drop the agent file ────────────────────────────────────────────────────── -mkdir -p "$AGENT_DIR" -curl -sSL "${STEWARD_URL}/plugins/host_agent/agent.py" -o "$AGENT_DIR/agent.py" -chmod 0755 "$AGENT_DIR/agent.py" -chown root:root "$AGENT_DIR/agent.py" - -# ── write config ───────────────────────────────────────────────────────────── -cat > "$CONF_FILE" < "$UNIT_FILE" <` (a fixed list; anything else is 404). install.sh and the install, provision and update playbooks all fetch them, so the unit can't differ between a host installed by hand and one installed by Ansible (#5766). - **Agent binary served by Steward itself.** `GET /plugins/host_agent/agent.py` serves the script bundled with the plugin, so version drift between install-script and agent is impossible — re-running the installer picks up whatever agent the currently-running Steward ships. - **Systemd hardening is cheap and correct.** `NoNewPrivileges`, `ProtectSystem=strict`, `ProtectHome`, `PrivateTmp`, `ReadOnlyPaths=/proc /sys`. The agent only needs read access to `/proc`, `/sys`, and mount points; denying everything else narrows blast radius. - **Uninstall is a first-class flag**, not a separate script. Same one-liner with `--uninstall` appended. diff --git a/plugins/host_agent/agent.py b/plugins/host_agent/agent.py index 59cdd5e..a2085c2 100644 --- a/plugins/host_agent/agent.py +++ b/plugins/host_agent/agent.py @@ -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 `.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: diff --git a/plugins/host_agent/files/maint.py b/plugins/host_agent/files/maint.py new file mode 100644 index 0000000..792ca70 --- /dev/null +++ b/plugins/host_agent/files/maint.py @@ -0,0 +1,243 @@ +#!/usr/bin/env python3 +"""Steward maintenance helper (#5766): runs as root, started by steward-maint.path +when the host agent leaves a request, and does only what ACTIONS lists. + +The agent (an unprivileged user under NoNewPrivileges) can't update or reboot +its host itself. It writes `.json` = {"id", "action", ["node"]} into +REQUESTS, the one directory it may write. This helper reads each request +without following links, refuses anything outside the list, deletes it, does +the action, and writes the outcome to RESULTS (root's own directory, which the +agent reads back and reports to Steward). A stolen agent token can therefore +make its host update, reboot or drain, and nothing else. + +Stdlib only; served by Steward at /plugins/host_agent/files/maint.py. +""" +from __future__ import annotations + +import json +import os +import re +import shutil +import subprocess +import sys +import time +from datetime import datetime, timezone + +REQUESTS = "/var/lib/steward-agent/requests" +RESULTS = "/var/lib/steward-maint/results" +ACTIONS = ("update", "reboot", "drain", "undrain") +REQUEST_MAX_BYTES = 4096 +RESULTS_KEPT_SECONDS = 7 * 86400 +UPDATE_DEADLINE_SECONDS = 3000 +COMMAND_DEADLINE_SECONDS = 600 +OUTPUT_KEPT_CHARS = 2000 +_ID = re.compile(r"^[A-Za-z0-9-]{1,64}$") +_NODE = re.compile(r"^[a-z0-9]{25}$") + + +def now_iso() -> str: + return datetime.now(timezone.utc).isoformat() + + +def read_request(path: str): + """The request at `path` as a dict, or None when it is not a small regular + file holding a valid request. Never follows a link the agent planted.""" + try: + fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK) + except OSError: + return None + try: + st = os.fstat(fd) + if not (st.st_mode & 0o170000 == 0o100000) or st.st_size > REQUEST_MAX_BYTES: + return None + raw = os.read(fd, REQUEST_MAX_BYTES + 1) + finally: + os.close(fd) + try: + req = json.loads(raw.decode("utf-8")) + except (UnicodeDecodeError, ValueError): + return None + return req if valid(req) else None + + +def valid(req) -> bool: + """A request names a known action, has a plain id, and a node id only in + the swarm's own form.""" + if not isinstance(req, dict) or req.get("action") not in ACTIONS: + return False + if not isinstance(req.get("id"), str) or not _ID.match(req["id"]): + return False + node = req.get("node") + return node is None or (isinstance(node, str) and bool(_NODE.match(node))) + + +def run(cmd: list, deadline: int = COMMAND_DEADLINE_SECONDS, env: dict | None = None): + """(ok, output tail) of a command, bounded by `deadline`.""" + try: + done = subprocess.run(cmd, capture_output=True, text=True, timeout=deadline, + env=dict(os.environ, LANG="C", LC_ALL="C", **(env or {}))) + except subprocess.TimeoutExpired: + return False, f"{cmd[0]} didn't finish in {deadline} s" + except OSError as exc: + return False, str(exc) + out = (done.stdout or "") + (done.stderr or "") + return done.returncode == 0, out[-OUTPUT_KEPT_CHARS:] + + +def reboot_required() -> bool | None: + if os.path.exists("/var/run/reboot-required"): + return True + if shutil.which("apt-get"): + return False + if shutil.which("needs-restarting"): + try: + return subprocess.run(["needs-restarting", "-r"], capture_output=True, + timeout=60).returncode == 1 + except (OSError, subprocess.TimeoutExpired): + return None + return None + + +def upgraded_count(output: str) -> int | None: + """Packages an apt or dnf run changed, from its summary line.""" + m = re.search(r"(\d+) upgraded, (\d+) newly installed", output) # apt + if m: + return int(m.group(1)) + int(m.group(2)) + counts = re.findall(r"^\s*(?:Upgrade|Install)\s+(\d+) Packages?", output, re.MULTILINE) # dnf + return sum(int(c) for c in counts) if counts else None + + +def do_update() -> dict: + """Upgrade every package, keeping existing config files and leaving + service restarts to a reboot: needrestart only lists, so the Docker daemon + isn't restarted under its containers mid-upgrade.""" + if shutil.which("apt-get"): + env = {"DEBIAN_FRONTEND": "noninteractive", "NEEDRESTART_MODE": "l"} + ok, out = run(["apt-get", "update"], env=env) + if not ok: + return {"ok": False, "detail": "apt-get update failed: " + out} + ok, out = run(["apt-get", "-y", "-o", "Dpkg::Options::=--force-confdef", + "-o", "Dpkg::Options::=--force-confold", "dist-upgrade"], + deadline=UPDATE_DEADLINE_SECONDS, env=env) + if not ok: + return {"ok": False, "detail": "apt-get dist-upgrade failed: " + out} + changed = upgraded_count(out) + run(["apt-get", "-y", "autoremove"], env=env) + elif shutil.which("dnf"): + ok, out = run(["dnf", "-y", "upgrade", "--refresh"], deadline=UPDATE_DEADLINE_SECONDS) + if not ok: + return {"ok": False, "detail": "dnf upgrade failed: " + out} + changed = upgraded_count(out) + else: + return {"ok": False, "detail": "no apt-get or dnf on this host"} + return {"ok": True, "packages": changed, "reboot_required": reboot_required(), + "detail": out[-500:]} + + +def own_node() -> str | None: + ok, out = run(["docker", "info", "--format", "{{.Swarm.NodeID}}"], deadline=30) + node = out.strip() if ok else "" + return node if _NODE.match(node) else None + + +def do_node(availability: str, node: str | None) -> dict: + """Set a swarm node's availability (drain before a reboot, active after). + Only a manager can; a worker's helper says so and Steward asks a manager.""" + if not shutil.which("docker"): + return {"ok": False, "detail": "no docker on this host"} + node = node or own_node() + if node is None: + return {"ok": False, "detail": "this host is not in a swarm"} + ok, out = run(["docker", "node", "update", "--availability", availability, node], deadline=60) + return {"ok": ok, "node": node, "detail": out.strip()[-500:]} + + +def write_result(result: dict) -> None: + """RESULTS/.json, written whole (a temp file renamed into place).""" + os.makedirs(RESULTS, mode=0o755, exist_ok=True) + final = os.path.join(RESULTS, result["id"] + ".json") + tmp = final + ".tmp" + with open(tmp, "w") as fh: + json.dump(result, fh) + os.chmod(tmp, 0o644) + os.replace(tmp, final) + + +def already_handled(job_id: str) -> bool: + """A request the agent sent again, for a job already run, is not run twice.""" + return os.path.exists(os.path.join(RESULTS, job_id + ".json")) + + +def handle(req: dict) -> dict: + base = {"id": req["id"], "action": req["action"], "started_at": now_iso()} + write_result({**base, "state": "running"}) + action = req["action"] + if action == "update": + outcome = do_update() + elif action in ("drain", "undrain"): + outcome = do_node("drain" if action == "drain" else "active", req.get("node")) + else: # reboot + result = {**base, "state": "rebooting", "ok": True, "finished_at": now_iso()} + write_result(result) + os.sync() + run(["systemctl", "reboot"], deadline=60) + return result + result = {**base, **outcome, "state": "done" if outcome.get("ok") else "failed", + "finished_at": now_iso()} + write_result(result) + return result + + +def prune_results(now: float | None = None) -> None: + now = time.time() if now is None else now + try: + names = os.listdir(RESULTS) + except OSError: + return + for name in names: + path = os.path.join(RESULTS, name) + try: + if now - os.lstat(path).st_mtime > RESULTS_KEPT_SECONDS: + os.unlink(path) + except OSError: + pass + + +def pending_requests() -> list: + """Request files, oldest first.""" + try: + names = [n for n in os.listdir(REQUESTS) if n.endswith(".json")] + except OSError: + return [] + paths = [os.path.join(REQUESTS, n) for n in names] + + def age(p): + try: + return os.lstat(p).st_mtime + except OSError: + return 0.0 + return sorted(paths, key=age) + + +def main() -> int: + prune_results() + for path in pending_requests(): + req = read_request(path) + try: + os.unlink(path) # whatever it held: never re-read in a loop + except OSError: + pass + if req is None: + sys.stderr.write(f"[steward-maint] ignored {os.path.basename(path)}: not a valid request\n") + continue + if already_handled(req["id"]): + continue + result = handle(req) + sys.stderr.write(f"[steward-maint] {req['action']} {req['id']}: {result['state']}\n") + if result["state"] == "rebooting": + break # the rest waits for the next boot + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/plugins/host_agent/files/steward-agent.service b/plugins/host_agent/files/steward-agent.service new file mode 100644 index 0000000..6320d3f --- /dev/null +++ b/plugins/host_agent/files/steward-agent.service @@ -0,0 +1,26 @@ +# Served by Steward at /plugins/host_agent/files/steward-agent.service and +# installed by install.sh and the host_agent playbooks: one copy of the unit. +[Unit] +Description=Steward host agent +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=steward-agent +Environment=STEWARD_AGENT_CONFIG=/etc/steward-agent.conf +ExecStart=/usr/bin/env python3 /usr/local/lib/steward-agent/agent.py +Restart=on-failure +RestartSec=5 +# Stopping waits for the agent's last post, bounded at 10 s. +TimeoutStopSec=30 +NoNewPrivileges=true +ProtectSystem=strict +ProtectHome=true +PrivateTmp=true +ReadOnlyPaths=/proc /sys +# The one place the agent writes: requests to the maintenance helper (#5766). +ReadWritePaths=-/var/lib/steward-agent/requests + +[Install] +WantedBy=multi-user.target diff --git a/plugins/host_agent/files/steward-maint.path b/plugins/host_agent/files/steward-maint.path new file mode 100644 index 0000000..27a6fba --- /dev/null +++ b/plugins/host_agent/files/steward-maint.path @@ -0,0 +1,10 @@ +# Starts the maintenance helper when the host agent leaves a request (#5766). +[Unit] +Description=Steward maintenance requests from the host agent + +[Path] +PathExistsGlob=/var/lib/steward-agent/requests/*.json +Unit=steward-maint.service + +[Install] +WantedBy=paths.target diff --git a/plugins/host_agent/files/steward-maint.service b/plugins/host_agent/files/steward-maint.service new file mode 100644 index 0000000..5eb8ea8 --- /dev/null +++ b/plugins/host_agent/files/steward-maint.service @@ -0,0 +1,10 @@ +# Runs as root, and does only what maint.py's fixed list allows: update the +# host's packages, reboot it, or drain / reactivate its swarm node (#5766). +[Unit] +Description=Steward maintenance helper + +[Service] +Type=oneshot +ExecStart=/usr/bin/env python3 /usr/local/lib/steward-agent/maint.py +# A full upgrade on a slow mirror; past this systemd stops it. +TimeoutStartSec=3600 diff --git a/plugins/host_agent/migrations/versions/host_agent_006_maint_helper.py b/plugins/host_agent/migrations/versions/host_agent_006_maint_helper.py new file mode 100644 index 0000000..8f4cfd5 --- /dev/null +++ b/plugins/host_agent/migrations/versions/host_agent_006_maint_helper.py @@ -0,0 +1,21 @@ +# plugins/host_agent/migrations/versions/host_agent_006_maint_helper.py +"""host_agent: whether a host has the maintenance helper (#5766) + +Revision ID: host_agent_006_maint_helper +""" +from typing import Sequence, Union +from alembic import op +import sqlalchemy as sa + +revision: str = "host_agent_006_maint_helper" +down_revision: Union[str, None] = "host_agent_005_updates" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column("host_agent_registrations", sa.Column("maint_helper", sa.Boolean(), nullable=True)) + + +def downgrade() -> None: + op.drop_column("host_agent_registrations", "maint_helper") diff --git a/plugins/host_agent/models.py b/plugins/host_agent/models.py index 2099bc9..52257dd 100644 --- a/plugins/host_agent/models.py +++ b/plugins/host_agent/models.py @@ -47,6 +47,10 @@ class HostAgentRegistration(Base): reboot_required = Column(Boolean, nullable=True) reboot_required_since = Column(DateTime(timezone=True), nullable=True) reboot_packages = Column(Text, nullable=True) + # Whether the host has the root maintenance helper (steward-maint.path and + # its request directory) that updates and reboots go through (#5766); None + # until an agent that says reports. + maint_helper = Column(Boolean, nullable=True) created_at = Column(DateTime(timezone=True), nullable=False, default=_utcnow) updated_at = Column(DateTime(timezone=True), nullable=False, default=_utcnow, onupdate=_utcnow) diff --git a/plugins/host_agent/routes.py b/plugins/host_agent/routes.py index ff168e9..fcb005a 100644 --- a/plugins/host_agent/routes.py +++ b/plugins/host_agent/routes.py @@ -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/") +@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} diff --git a/plugins/host_agent/templates/install.sh.j2 b/plugins/host_agent/templates/install.sh.j2 index 5def950..3ad1d65 100644 --- a/plugins/host_agent/templates/install.sh.j2 +++ b/plugins/host_agent/templates/install.sh.j2 @@ -11,7 +11,9 @@ AGENT_VERSION="{{ agent_version }}" AGENT_USER="steward-agent" AGENT_DIR="/usr/local/lib/steward-agent" CONF_FILE="/etc/steward-agent.conf" -UNIT_FILE="/etc/systemd/system/steward-agent.service" +UNIT_DIR="/etc/systemd/system" +UNITS="steward-agent.service steward-maint.service steward-maint.path" +FILES_URL="${STEWARD_URL}/plugins/host_agent/files" # ── preflight ──────────────────────────────────────────────────────────────── [ "$(id -u)" = "0" ] || { echo "must run as root (use sudo)"; exit 1; } @@ -20,9 +22,10 @@ command -v python3 >/dev/null 2>&1 || { echo "python3 not found — install py # Handle --uninstall if [ "${1:-}" = "--uninstall" ]; then - systemctl disable --now steward-agent.service 2>/dev/null || true - rm -f "$UNIT_FILE" "$CONF_FILE" - rm -rf "$AGENT_DIR" + systemctl disable --now steward-maint.path steward-agent.service 2>/dev/null || true + for u in $UNITS; do rm -f "$UNIT_DIR/$u"; done + rm -f "$CONF_FILE" + rm -rf "$AGENT_DIR" /var/lib/steward-agent /var/lib/steward-maint systemctl daemon-reload # keep the system user — removing it risks orphaned files elsewhere echo "uninstalled" @@ -36,7 +39,7 @@ fi # ── drop the agent file ────────────────────────────────────────────────────── mkdir -p "$AGENT_DIR" -curl -sSL "${STEWARD_URL}/plugins/host_agent/agent.py" -o "$AGENT_DIR/agent.py" +curl -fsSL "${STEWARD_URL}/plugins/host_agent/agent.py" -o "$AGENT_DIR/agent.py" chmod 0755 "$AGENT_DIR/agent.py" chown root:root "$AGENT_DIR/agent.py" @@ -53,31 +56,24 @@ EOF chown "root:$AGENT_USER" "$CONF_FILE" chmod 0640 "$CONF_FILE" -# ── write systemd unit ─────────────────────────────────────────────────────── -cat > "$UNIT_FILE" < {% endif %} + {# The root helper that carries out scheduled updates and reboots (#5766). #} +

+ {% if reg.maint_helper %}Maintenance helper installed: Steward can update and reboot this host. + {% else %}No maintenance helper yet: Update agent installs it, so Steward can update and reboot this host.{% endif %} +

{% if ansible_available and target and managed_key_set %}
diff --git a/steward/ansible/bundled/host_agent/install.yml b/steward/ansible/bundled/host_agent/install.yml index 92af713..589ad7f 100644 --- a/steward/ansible/bundled/host_agent/install.yml +++ b/steward/ansible/bundled/host_agent/install.yml @@ -74,32 +74,47 @@ interval_seconds = {{ agent_interval }} notify: restart steward-agent - - name: Install systemd unit - ansible.builtin.copy: - dest: /etc/systemd/system/steward-agent.service + # The units and the maintenance helper come from Steward: one copy for + # install.sh and every playbook (#5766). The agent may write only its + # request directory; the helper and its results stay root's. + - name: Create the maintenance directories + ansible.builtin.file: + path: "{{ item.path }}" + state: directory + owner: "{{ item.owner }}" + group: "{{ item.owner }}" + mode: "{{ item.mode }}" + loop: + - {path: /var/lib/steward-agent, owner: root, mode: "0755"} + - {path: /var/lib/steward-agent/requests, owner: steward-agent, mode: "0700"} + - {path: /var/lib/steward-maint, owner: root, mode: "0755"} + - {path: /var/lib/steward-maint/results, owner: root, mode: "0755"} + + - name: Download the maintenance helper + ansible.builtin.get_url: + url: "{{ steward_url }}/plugins/host_agent/files/maint.py" + dest: /usr/local/lib/steward-agent/maint.py + owner: root + mode: "0755" + force: true + + - name: Install the agent and maintenance units + ansible.builtin.get_url: + url: "{{ steward_url }}/plugins/host_agent/files/{{ item }}" + dest: "/etc/systemd/system/{{ item }}" + owner: root mode: "0644" - content: | - [Unit] - Description=Steward host agent - After=network-online.target - Wants=network-online.target - - [Service] - Type=simple - User=steward-agent - Environment=STEWARD_AGENT_CONFIG=/etc/steward-agent.conf - ExecStart=/usr/bin/env python3 /usr/local/lib/steward-agent/agent.py - Restart=on-failure - RestartSec=5 - NoNewPrivileges=true - ProtectSystem=strict - ProtectHome=true - PrivateTmp=true - - [Install] - WantedBy=multi-user.target + force: true + loop: [steward-agent.service, steward-maint.service, steward-maint.path] notify: restart steward-agent + - name: Watch for maintenance requests + ansible.builtin.systemd: + name: steward-maint.path + enabled: true + state: started + daemon_reload: true + - name: Enable and start the agent ansible.builtin.systemd: name: steward-agent diff --git a/steward/ansible/bundled/host_agent/provision.yml b/steward/ansible/bundled/host_agent/provision.yml index bd9bc83..a9cfd26 100644 --- a/steward/ansible/bundled/host_agent/provision.yml +++ b/steward/ansible/bundled/host_agent/provision.yml @@ -130,32 +130,47 @@ interval_seconds = {{ agent_interval }} notify: restart steward-agent - - name: Install systemd unit - ansible.builtin.copy: - dest: /etc/systemd/system/steward-agent.service + # The units and the maintenance helper come from Steward: one copy for + # install.sh and every playbook (#5766). The agent may write only its + # request directory; the helper and its results stay root's. + - name: Create the maintenance directories + ansible.builtin.file: + path: "{{ item.path }}" + state: directory + owner: "{{ item.owner }}" + group: "{{ item.owner }}" + mode: "{{ item.mode }}" + loop: + - {path: /var/lib/steward-agent, owner: root, mode: "0755"} + - {path: /var/lib/steward-agent/requests, owner: steward-agent, mode: "0700"} + - {path: /var/lib/steward-maint, owner: root, mode: "0755"} + - {path: /var/lib/steward-maint/results, owner: root, mode: "0755"} + + - name: Download the maintenance helper + ansible.builtin.get_url: + url: "{{ steward_url }}/plugins/host_agent/files/maint.py" + dest: /usr/local/lib/steward-agent/maint.py + owner: root + mode: "0755" + force: true + + - name: Install the agent and maintenance units + ansible.builtin.get_url: + url: "{{ steward_url }}/plugins/host_agent/files/{{ item }}" + dest: "/etc/systemd/system/{{ item }}" + owner: root mode: "0644" - content: | - [Unit] - Description=Steward host agent - After=network-online.target - Wants=network-online.target - - [Service] - Type=simple - User=steward-agent - Environment=STEWARD_AGENT_CONFIG=/etc/steward-agent.conf - ExecStart=/usr/bin/env python3 /usr/local/lib/steward-agent/agent.py - Restart=on-failure - RestartSec=5 - NoNewPrivileges=true - ProtectSystem=strict - ProtectHome=true - PrivateTmp=true - - [Install] - WantedBy=multi-user.target + force: true + loop: [steward-agent.service, steward-maint.service, steward-maint.path] notify: restart steward-agent + - name: Watch for maintenance requests + ansible.builtin.systemd: + name: steward-maint.path + enabled: true + state: started + daemon_reload: true + - name: Enable and start the agent ansible.builtin.systemd: name: steward-agent diff --git a/steward/ansible/bundled/host_agent/update.yml b/steward/ansible/bundled/host_agent/update.yml index 82f60f9..bcc2573 100644 --- a/steward/ansible/bundled/host_agent/update.yml +++ b/steward/ansible/bundled/host_agent/update.yml @@ -1,7 +1,8 @@ --- -# description: Update the Steward host agent (refresh agent.py + restart) on hosts already running it. +# description: Update the Steward host agent (refresh agent.py, its units and the maintenance helper, then restart) on hosts already running it. # steward:category: host-agent -# Update an already-installed Steward host agent: refresh agent.py and restart. +# Update an already-installed Steward host agent: refresh agent.py, its systemd +# units and the maintenance helper (#5766), then restart. # # Deliberately does NOT touch the registration token or /etc/steward-agent.conf # — the host already has a working token, so an update leaves identity alone and @@ -41,6 +42,47 @@ force: true notify: restart steward-agent + # The units and the maintenance helper come from Steward: one copy for + # install.sh and every playbook (#5766). The agent may write only its + # request directory; the helper and its results stay root's. + - name: Create the maintenance directories + ansible.builtin.file: + path: "{{ item.path }}" + state: directory + owner: "{{ item.owner }}" + group: "{{ item.owner }}" + mode: "{{ item.mode }}" + loop: + - {path: /var/lib/steward-agent, owner: root, mode: "0755"} + - {path: /var/lib/steward-agent/requests, owner: steward-agent, mode: "0700"} + - {path: /var/lib/steward-maint, owner: root, mode: "0755"} + - {path: /var/lib/steward-maint/results, owner: root, mode: "0755"} + + - name: Download the maintenance helper + ansible.builtin.get_url: + url: "{{ steward_url }}/plugins/host_agent/files/maint.py" + dest: /usr/local/lib/steward-agent/maint.py + owner: root + mode: "0755" + force: true + + - name: Install the agent and maintenance units + ansible.builtin.get_url: + url: "{{ steward_url }}/plugins/host_agent/files/{{ item }}" + dest: "/etc/systemd/system/{{ item }}" + owner: root + mode: "0644" + force: true + loop: [steward-agent.service, steward-maint.service, steward-maint.path] + notify: restart steward-agent + + - name: Watch for maintenance requests + ansible.builtin.systemd: + name: steward-maint.path + enabled: true + state: started + daemon_reload: true + handlers: - name: restart steward-agent ansible.builtin.systemd: diff --git a/tests/core/test_host_page.py b/tests/core/test_host_page.py index 83e25ba..1d726e1 100644 --- a/tests/core/test_host_page.py +++ b/tests/core/test_host_page.py @@ -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 diff --git a/tests/plugins/host_agent/test_install_template.py b/tests/plugins/host_agent/test_install_template.py index 835242f..509754b 100644 --- a/tests/plugins/host_agent/test_install_template.py +++ b/tests/plugins/host_agent/test_install_template.py @@ -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(): diff --git a/tests/plugins/host_agent/test_maint_helper.py b/tests/plugins/host_agent/test_maint_helper.py new file mode 100644 index 0000000..90b7ec2 --- /dev/null +++ b/tests/plugins/host_agent/test_maint_helper.py @@ -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