3"""Parse the read-only preflight that gates native-listener maintenance."""
5from __future__
import annotations
14from collections.abc
import Mapping, Sequence
15from dataclasses
import dataclass
16from pathlib
import Path
19import fleet_model
as fm
20import fleet_path_authority
as fpa
22PRIVATE_DIRECTORY_MODE = 0o700
25class MaintenanceError(ValueError):
26 """The preflight did not provide an unambiguous safe decision."""
29@dataclass(frozen=
True)
30class MaintenanceRequest:
31 """One read-only preflight and its fail-closed idle-stop transport."""
33 preflight_argv: Sequence[str]
35 environment: Mapping[str, str]
37 idle_stop_argv: Sequence[str]
41@dataclass(frozen=True)
42class MaintenanceDecision:
43 """Whether the already-preflighted real converge should continue."""
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"]
65 """Build one inventory-backed playbook argv from locked authorities."""
66 variables = fm.role_vars(data, name, host)
68 *playbook_prefix(sys.executable),
71 f
"playbooks/{fm.PLAYS[play].playbook}",
75 json.dumps(variables),
77 if fm.CLASSES[str(host[
"class"])].transport ==
"wsl":
78 argv += [
"-e", f
"wsl_ci_host_id={name}"]
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"]
87def _require_real_directory(path: Path, label: str) -> Path:
88 """Require one existing directory with no lexical/resolved path drift."""
89 lexical = path.absolute()
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)
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()
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)
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)
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)
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)
147 raise MaintenanceError(
"; ".join(link_errors))
149 "HOME": pwd.getpwuid(os.getuid()).pw_dir,
152 "PATH":
"/usr/bin:/bin",
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:
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."""
168 **ansible_environment(environment, ansible_cwd),
169 "ANSIBLE_LOAD_CALLBACK_PLUGINS":
"1",
170 "ANSIBLE_STDOUT_CALLBACK":
"ansible.posix.json",
174def changed(stdout: str, host: str) -> bool:
175 """Return the exact host changed verdict from one successful preflight."""
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)
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
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
198def prepare(request: MaintenanceRequest) -> MaintenanceDecision:
199 """Run the read-only preflight, then idle-stop only for real drift."""
201 preflight = subprocess.run(
202 list(request.preflight_argv),
203 cwd=request.ansible_cwd,
204 env=callback_environment(request.environment, request.ansible_cwd),
210 sys.stdout.write(preflight.stdout)
211 sys.stderr.write(preflight.stderr)
212 if preflight.returncode:
213 return MaintenanceDecision(proceed=
False, status=preflight.returncode)
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)
220 print(
"fleet: native HIL listener is already converged; leaving it running")
221 return MaintenanceDecision(proceed=
False, status=0)
223 stop = subprocess.run(
224 list(request.idle_stop_argv),
225 input=request.helper_source,
230 return MaintenanceDecision(
231 proceed=stop.returncode == 0,
232 status=stop.returncode,
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")
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"),
251 return ansible_cwd, collections, hostile
254def _sanitizer_selftest(
255 root: Path, ansible_cwd: Path, collections: Path, hostile: dict[str, str]
257 """Prove exact configuration and managed executable selection."""
258 failures: list[str] = []
259 clean = callback_environment(hostile, ansible_cwd)
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",
267 if any(clean.get(key) != value
for key, value
in expected.items()):
268 failures.append(
"repository Ansible environment did not replace hostile controls")
270 "ANSIBLE_ROLES_PATH",
271 "ANSIBLE_CALLBACK_PLUGINS",
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")
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")
292def _expect_link_refusal(hostile: dict[str, str], ansible_cwd: Path, label: str) -> list[str]:
293 """Require one linked authority fixture to fail closed."""
295 callback_environment(hostile, ansible_cwd)
296 except MaintenanceError:
298 return [f
"{label} passed environment binding"]
302 root: Path, ansible_cwd: Path, collections: Path, hostile: dict[str, 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()
309 collections.symlink_to(real_collections, target_is_directory=
True)
310 failures.extend(_expect_link_refusal(hostile, ansible_cwd,
"symlinked collection root"))
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")
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"))
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"
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")
342 (
"absolute", outside),
343 (
"escape", Path(
"../outside")),
344 (
"broken", Path(
"missing")),
346 for name, target
in hostile_links:
348 link.symlink_to(target)
349 if not fpa.confined_link_errors(tree):
350 failures.append(f
"{name} collection link was accepted")
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)
365 "ANSIBLE_LOCAL_TEMP": str(local),
366 "ANSIBLE_SSH_CONTROL_PATH_DIR": str(control),
368 clean = callback_environment(selected, ansible_cwd)
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"}
376 callback_environment(relative, ansible_cwd)
377 except MaintenanceError:
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)}
385 callback_environment(symlinked, ansible_cwd)
386 except MaintenanceError:
389 failures.append(
"linked Ansible runtime directory was accepted")
392 callback_environment(selected, ansible_cwd)
393 except MaintenanceError:
396 failures.append(
"group-readable Ansible runtime directory was accepted")
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"))
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")
427 json.dumps({
"stats": {
"dev": {**clean, key: value}}})
428 for key, value
in ((key, 1)
for key
in non_success)
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)
434 {
"stats": {
"dev": {name: value
for name, value
in clean.items()
if name != key}}}
438 for attack
in attacks:
440 changed(attack,
"dev")
441 except MaintenanceError:
443 failures.append(
"ambiguous preflight result was accepted")