ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
fleet_runner_maintenance.py
Go to the documentation of this file.
1# SPDX-License-Identifier: MIT
2# Copyright (c) 2026 Brighton Sikarskie
3"""Parse the read-only preflight that gates native-listener maintenance."""
4
5from __future__ import annotations
6
7import json
8import os
9import pwd
10import stat
11import subprocess
12import sys
13import tempfile
14from collections.abc import Mapping, Sequence
15from dataclasses import dataclass
16from pathlib import Path
17from typing import Any
18
19import fleet_model as fm
20import fleet_path_authority as fpa
21
22PRIVATE_DIRECTORY_MODE = 0o700
23
24
25class MaintenanceError(ValueError):
26 """The preflight did not provide an unambiguous safe decision."""
27
28
29@dataclass(frozen=True)
30class MaintenanceRequest:
31 """One read-only preflight and its fail-closed idle-stop transport."""
32
33 preflight_argv: Sequence[str]
34 ansible_cwd: Path
35 environment: Mapping[str, str]
36 host: str
37 idle_stop_argv: Sequence[str]
38 helper_source: str
39
40
41@dataclass(frozen=True)
42class MaintenanceDecision:
43 """Whether the already-preflighted real converge should continue."""
44
45 proceed: bool
46 status: int
47
48
49def playbook_prefix(python: str) -> list[str]:
50 """Return the relocation-safe Ansible module entry under managed Python."""
51 candidate = Path(python)
52 if not candidate.is_file() or not os.access(candidate, os.X_OK):
53 message = f"locked Python executable is absent: {python}"
54 raise MaintenanceError(message)
55 return [str(candidate), "-m", "ansible.cli.playbook"]
56
57
58def playbook_argv(
59 data: dict[str, Any],
60 name: str,
61 host: dict[str, Any],
62 play: str,
63 extra: list[str],
64) -> list[str]:
65 """Build one inventory-backed playbook argv from locked authorities."""
66 variables = fm.role_vars(data, name, host)
67 argv = [
68 *playbook_prefix(sys.executable),
69 "-i",
70 str(fm.INVENTORY),
71 f"playbooks/{fm.PLAYS[play].playbook}",
72 "--limit",
73 name,
74 "-e",
75 json.dumps(variables),
76 ]
77 if fm.CLASSES[str(host["class"])].transport == "wsl":
78 argv += ["-e", f"wsl_ci_host_id={name}"]
79 return argv + extra
80
81
82def applies(host_class: str, plays: Sequence[str], mode: str) -> bool:
83 """Return whether this is the native HIL listener's mutating play."""
84 return mode == "apply" and host_class == "dev_box" and list(plays) == ["dev-box"]
85
86
87def _require_real_directory(path: Path, label: str) -> Path:
88 """Require one existing directory with no lexical/resolved path drift."""
89 lexical = path.absolute()
90 try:
91 resolved = lexical.resolve(strict=True)
92 except OSError as exc:
93 message = f"{label} is unavailable: {exc}"
94 raise MaintenanceError(message) from exc
95 if lexical != resolved or lexical.is_symlink() or not lexical.is_dir():
96 message = f"{label} is not a real repository directory"
97 raise MaintenanceError(message)
98 return resolved
99
100
101def _require_real_file(path: Path, label: str) -> Path:
102 """Require one existing regular file with no lexical/resolved path drift."""
103 lexical = path.absolute()
104 try:
105 resolved = lexical.resolve(strict=True)
106 except OSError as exc:
107 message = f"{label} is unavailable: {exc}"
108 raise MaintenanceError(message) from exc
109 if lexical != resolved or lexical.is_symlink() or not lexical.is_file():
110 message = f"{label} is not a real repository file"
111 raise MaintenanceError(message)
112 return resolved
113
114
115def _private_runtime_directory(environment: Mapping[str, str], key: str) -> str | None:
116 """Return one explicitly supplied, private Ansible runtime directory."""
117 value = environment.get(key)
118 if value is None:
119 return None
120 path = Path(value)
121 if not path.is_absolute():
122 message = f"{key} is not an absolute path"
123 raise MaintenanceError(message)
124 resolved = _require_real_directory(path, f"{key} directory")
125 metadata = resolved.stat()
126 if metadata.st_uid != os.getuid() or stat.S_IMODE(metadata.st_mode) != PRIVATE_DIRECTORY_MODE:
127 message = f"{key} is not owned by this account with mode 0700"
128 raise MaintenanceError(message)
129 return str(resolved)
130
131
132def ansible_environment(environment: Mapping[str, str], ansible_cwd: Path) -> dict[str, str]:
133 """Bind Ansible to the repository config without inherited control paths."""
134 resolved_cwd = _require_real_directory(ansible_cwd, "Ansible working directory")
135 if resolved_cwd.name != "ansible" or resolved_cwd.parent.name != "infra":
136 message = "Ansible working directory is not the repository infra/ansible root"
137 raise MaintenanceError(message)
138 repo_root = _require_real_directory(resolved_cwd.parents[1], "repository root")
139 config = _require_real_file(resolved_cwd / "ansible.cfg", "repository Ansible config")
140 collection_parent = _require_real_directory(repo_root / ".ansible", "collection parent")
141 collections = _require_real_directory(collection_parent / "collections", "collection root")
142 if config.parent != resolved_cwd or collections.parent != collection_parent:
143 message = "repository Ansible authorities escaped their exact owner"
144 raise MaintenanceError(message)
145 link_errors = fpa.confined_link_errors(collections)
146 if link_errors:
147 raise MaintenanceError("; ".join(link_errors))
148 clean = {
149 "HOME": pwd.getpwuid(os.getuid()).pw_dir,
150 "LANG": "C.UTF-8",
151 "LC_ALL": "C.UTF-8",
152 "PATH": "/usr/bin:/bin",
153 }
154 clean["ANSIBLE_CONFIG"] = str(config)
155 clean["ANSIBLE_COLLECTIONS_PATH"] = str(collections)
156 clean["ANSIBLE_COLLECTIONS_SCAN_SYS_PATH"] = "false"
157 clean["PYTHONNOUSERSITE"] = "1"
158 for key in ("ANSIBLE_LOCAL_TEMP", "ANSIBLE_SSH_CONTROL_PATH_DIR"):
159 value = _private_runtime_directory(environment, key)
160 if value is not None:
161 clean[key] = value
162 return clean
163
164
165def callback_environment(environment: Mapping[str, str], ansible_cwd: Path) -> dict[str, str]:
166 """Select the exact JSON callback on top of the bound repository config."""
167 return {
168 **ansible_environment(environment, ansible_cwd),
169 "ANSIBLE_LOAD_CALLBACK_PLUGINS": "1",
170 "ANSIBLE_STDOUT_CALLBACK": "ansible.posix.json",
171 }
172
173
174def changed(stdout: str, host: str) -> bool:
175 """Return the exact host changed verdict from one successful preflight."""
176 try:
177 document = json.loads(stdout)
178 except json.JSONDecodeError as exc:
179 message = "native-runner preflight did not emit JSON"
180 raise MaintenanceError(message) from exc
181 stats = document.get("stats") if isinstance(document, dict) else None
182 if not isinstance(stats, dict) or set(stats) != {host}:
183 message = "native-runner preflight stats do not name exactly one host"
184 raise MaintenanceError(message)
185 result = stats[host]
186 required = ("changed", "failures", "ignored", "rescued", "unreachable")
187 if not isinstance(result, dict) or any(
188 type(result.get(key)) is not int or result[key] < 0 for key in required
189 ):
190 message = "native-runner preflight stats are incomplete"
191 raise MaintenanceError(message)
192 if any(result[key] for key in required if key != "changed"):
193 message = "native-runner preflight reported a non-clean failure outcome"
194 raise MaintenanceError(message)
195 return result["changed"] > 0
196
197
198def prepare(request: MaintenanceRequest) -> MaintenanceDecision:
199 """Run the read-only preflight, then idle-stop only for real drift."""
200 # Exact Ansible argv is derived from the fleet declaration.
201 preflight = subprocess.run( # noqa: S603 -- fleet model supplies exact Ansible argv
202 list(request.preflight_argv),
203 cwd=request.ansible_cwd,
204 env=callback_environment(request.environment, request.ansible_cwd),
205 check=False,
206 capture_output=True,
207 text=True,
208 timeout=7200,
209 )
210 sys.stdout.write(preflight.stdout)
211 sys.stderr.write(preflight.stderr)
212 if preflight.returncode:
213 return MaintenanceDecision(proceed=False, status=preflight.returncode)
214 try:
215 has_changes = changed(preflight.stdout, request.host)
216 except MaintenanceError as exc:
217 print(f"fleet: error: {exc}", file=sys.stderr)
218 return MaintenanceDecision(proceed=False, status=2)
219 if not has_changes:
220 print("fleet: native HIL listener is already converged; leaving it running")
221 return MaintenanceDecision(proceed=False, status=0)
222 # The ssh transport and root-helper argv are fixed, with no shell-derived input.
223 stop = subprocess.run( # noqa: S603 -- fleet model supplies exact SSH helper argv
224 list(request.idle_stop_argv),
225 input=request.helper_source,
226 text=True,
227 check=False,
228 timeout=60,
229 )
230 return MaintenanceDecision(
231 proceed=stop.returncode == 0,
232 status=stop.returncode,
233 )
234
235
236def _environment_fixture(root: Path) -> tuple[Path, Path, dict[str, str]]:
237 """Create one isolated repository-shaped Ansible authority."""
238 ansible_cwd = root / "infra" / "ansible"
239 ansible_cwd.mkdir(parents=True)
240 collections = root / ".ansible" / "collections"
241 collections.mkdir(parents=True)
242 (ansible_cwd / "ansible.cfg").write_text("[defaults]\n", encoding="ascii")
243 hostile = {
244 "PATH": "/usr/bin:/bin",
245 "ANSIBLE_CONFIG": str(ansible_cwd / "hostile.cfg"),
246 "ANSIBLE_ROLES_PATH": str(ansible_cwd / "hostile-roles"),
247 "ANSIBLE_CALLBACK_PLUGINS": str(ansible_cwd / "hostile-callbacks"),
248 "PYTHONHOME": str(ansible_cwd / "hostile-python-home"),
249 "PYTHONPATH": str(ansible_cwd / "hostile-python-path"),
250 }
251 return ansible_cwd, collections, hostile
252
253
254def _sanitizer_selftest(
255 root: Path, ansible_cwd: Path, collections: Path, hostile: dict[str, str]
256) -> list[str]:
257 """Prove exact configuration and managed executable selection."""
258 failures: list[str] = []
259 clean = callback_environment(hostile, ansible_cwd)
260 expected = {
261 "PATH": "/usr/bin:/bin",
262 "ANSIBLE_CONFIG": str((ansible_cwd / "ansible.cfg").resolve()),
263 "ANSIBLE_COLLECTIONS_PATH": str(collections.resolve()),
264 "ANSIBLE_COLLECTIONS_SCAN_SYS_PATH": "false",
265 "PYTHONNOUSERSITE": "1",
266 }
267 if any(clean.get(key) != value for key, value in expected.items()):
268 failures.append("repository Ansible environment did not replace hostile controls")
269 forbidden = (
270 "ANSIBLE_ROLES_PATH",
271 "ANSIBLE_CALLBACK_PLUGINS",
272 "PYTHONHOME",
273 "PYTHONPATH",
274 )
275 if any(key in clean for key in forbidden):
276 failures.append("hostile Ansible or Python import root survived sanitization")
277 managed = root / "managed/bin"
278 hostile_bin = root / "hostile/bin"
279 managed.mkdir(parents=True)
280 hostile_bin.mkdir(parents=True)
281 python = managed / "python3"
282 for path in (python, hostile_bin / "ansible-playbook"):
283 path.write_text("#!/bin/sh\nexit 0\n", encoding="ascii")
284 path.chmod(0o755)
285 hostile["PATH"] = str(hostile_bin)
286 expected_prefix = [str(python), "-m", "ansible.cli.playbook"]
287 if playbook_prefix(str(python)) != expected_prefix:
288 failures.append("hostile PATH replaced the managed Ansible module entry")
289 return failures
290
291
292def _expect_link_refusal(hostile: dict[str, str], ansible_cwd: Path, label: str) -> list[str]:
293 """Require one linked authority fixture to fail closed."""
294 try:
295 callback_environment(hostile, ansible_cwd)
296 except MaintenanceError:
297 return []
298 return [f"{label} passed environment binding"]
299
300
301def _link_selftest(
302 root: Path, ansible_cwd: Path, collections: Path, hostile: dict[str, str]
303) -> list[str]:
304 """Prove config, collection root, and collection parent links are rejected."""
305 failures: list[str] = []
306 real_collections = root / "real-collections"
307 real_collections.mkdir()
308 collections.rmdir()
309 collections.symlink_to(real_collections, target_is_directory=True)
310 failures.extend(_expect_link_refusal(hostile, ansible_cwd, "symlinked collection root"))
311 collections.unlink()
312 collections.mkdir()
313
314 real_parent = root / "real-parent"
315 (real_parent / "collections").mkdir(parents=True)
316 (root / ".ansible").rename(root / ".ansible.saved")
317 (root / ".ansible").symlink_to(real_parent, target_is_directory=True)
318 failures.extend(_expect_link_refusal(hostile, ansible_cwd, "symlinked collection parent"))
319 (root / ".ansible").unlink()
320 (root / ".ansible.saved").rename(root / ".ansible")
321
322 config = ansible_cwd / "ansible.cfg"
323 real_config = ansible_cwd / "real.cfg"
324 config.rename(real_config)
325 config.symlink_to(real_config)
326 failures.extend(_expect_link_refusal(hostile, ansible_cwd, "symlinked Ansible config"))
327 return failures
328
329
330def _collection_tree_selftest(root: Path) -> list[str]:
331 """Prove contained links pass while absolute, broken, and escaping fail."""
332 failures: list[str] = []
333 tree = root / "collection-links"
334 tree.mkdir()
335 (tree / "target").write_text("owned\n", encoding="ascii")
336 (tree / "inside").symlink_to("target")
337 if fpa.confined_link_errors(tree):
338 failures.append("contained collection link was refused")
339 outside = root / "outside"
340 outside.write_text("external\n", encoding="ascii")
341 hostile_links = (
342 ("absolute", outside),
343 ("escape", Path("../outside")),
344 ("broken", Path("missing")),
345 )
346 for name, target in hostile_links:
347 link = tree / name
348 link.symlink_to(target)
349 if not fpa.confined_link_errors(tree):
350 failures.append(f"{name} collection link was accepted")
351 link.unlink()
352 return failures
353
354
355def _runtime_directory_selftest(root: Path) -> list[str]:
356 """Prove approved runtime directories survive and unsafe ones fail closed."""
357 ansible_cwd, _collections, hostile = _environment_fixture(root)
358 runtime = root / "runtime"
359 local = runtime / "local"
360 control = runtime / "control"
361 local.mkdir(parents=True, mode=0o700)
362 control.mkdir(mode=0o700)
363 selected = {
364 **hostile,
365 "ANSIBLE_LOCAL_TEMP": str(local),
366 "ANSIBLE_SSH_CONTROL_PATH_DIR": str(control),
367 }
368 clean = callback_environment(selected, ansible_cwd)
369 failures = []
370 if clean.get("ANSIBLE_LOCAL_TEMP") != str(local.resolve()):
371 failures.append("private Ansible local-temp directory was discarded")
372 if clean.get("ANSIBLE_SSH_CONTROL_PATH_DIR") != str(control.resolve()):
373 failures.append("private Ansible control-path directory was discarded")
374 relative = {**hostile, "ANSIBLE_LOCAL_TEMP": "relative"}
375 try:
376 callback_environment(relative, ansible_cwd)
377 except MaintenanceError:
378 pass
379 else:
380 failures.append("relative Ansible runtime directory was accepted")
381 linked = runtime / "linked"
382 linked.symlink_to(local, target_is_directory=True)
383 symlinked = {**hostile, "ANSIBLE_LOCAL_TEMP": str(linked)}
384 try:
385 callback_environment(symlinked, ansible_cwd)
386 except MaintenanceError:
387 pass
388 else:
389 failures.append("linked Ansible runtime directory was accepted")
390 control.chmod(0o755)
391 try:
392 callback_environment(selected, ansible_cwd)
393 except MaintenanceError:
394 pass
395 else:
396 failures.append("group-readable Ansible runtime directory was accepted")
397 return failures
398
399
400def _environment_selftest() -> list[str]:
401 """Prove exact Ansible/Python environment and no-link isolation."""
402 with tempfile.TemporaryDirectory(prefix="ra8-ansible-env-") as scratch:
403 first = Path(scratch) / "sanitizer"
404 ansible_cwd, collections, hostile = _environment_fixture(first)
405 failures = _sanitizer_selftest(first, ansible_cwd, collections, hostile)
406 second = Path(scratch) / "links"
407 ansible_cwd, collections, hostile = _environment_fixture(second)
408 failures.extend(_link_selftest(second, ansible_cwd, collections, hostile))
409 failures.extend(_collection_tree_selftest(Path(scratch)))
410 failures.extend(_runtime_directory_selftest(Path(scratch) / "runtime-directories"))
411 return failures
412
413
414def run_selftest() -> list[str]:
415 """Prove changed, malformed, and environment decisions."""
416 clean = {"changed": 0, "failures": 0, "ignored": 0, "rescued": 0, "unreachable": 0}
417 failures = _environment_selftest()
418 quiet = json.dumps({"stats": {"dev": clean}})
419 dirty = json.dumps({"stats": {"dev": {**clean, "changed": 2}}})
420 if changed(quiet, "dev") or not changed(dirty, "dev"):
421 failures.append("changed/no-op preflight decision drifted")
422 attacks = ["not json", json.dumps({"stats": {}})]
423 attacks.append(json.dumps({"stats": {"dev": clean, "star": clean}}))
424 outcomes = tuple(clean)
425 non_success = tuple(key for key in outcomes if key != "changed")
426 attacks.extend(
427 json.dumps({"stats": {"dev": {**clean, key: value}}})
428 for key, value in ((key, 1) for key in non_success)
429 )
430 attacks.extend(json.dumps({"stats": {"dev": {**clean, key: -1}}}) for key in outcomes)
431 attacks.extend(json.dumps({"stats": {"dev": {**clean, key: "0"}}}) for key in outcomes)
432 attacks.extend(
433 json.dumps(
434 {"stats": {"dev": {name: value for name, value in clean.items() if name != key}}}
435 )
436 for key in outcomes
437 )
438 for attack in attacks:
439 try:
440 changed(attack, "dev")
441 except MaintenanceError:
442 continue
443 failures.append("ambiguous preflight result was accepted")
444 return failures