3"""Validate and privately snapshot typed Ansible variables for fleet operations."""
5from __future__
import annotations
10from collections.abc
import Iterator
11from contextlib
import contextmanager, suppress
12from dataclasses
import dataclass
13from pathlib
import Path
14from types
import MappingProxyType
15from typing
import NoReturn
16from unittest.mock
import patch
18import fleet_model
as fm
21RUNNER_REGISTRATION =
"runner registration"
22HIL_REGISTRATION =
"HIL registration"
23RUNNER_REMOVAL =
"runner removal"
25RUNNER_REGISTRATION_KEYS = frozenset({
"ci_runner_docker_registration_token"})
26HIL_REGISTRATION_KEYS = frozenset({
"dev_box_hil_runner_registration_token"})
27RUNNER_REMOVAL_KEYS = frozenset(
28 {
"ci_runner_docker_removal_token",
"ci_runner_docker_destroy_dataset"}
30TYPED_VAR_KEYS = MappingProxyType(
32 RUNNER_REGISTRATION: RUNNER_REGISTRATION_KEYS,
33 HIL_REGISTRATION: HIL_REGISTRATION_KEYS,
34 RUNNER_REMOVAL: RUNNER_REMOVAL_KEYS,
37EXACT_KEY_OPERATIONS = frozenset({RUNNER_REGISTRATION, HIL_REGISTRATION})
38TOKEN_KEYS = frozenset(
40 "ci_runner_docker_registration_token",
41 "ci_runner_docker_removal_token",
42 "dev_box_hil_runner_registration_token",
45CONTAINER_RUNNER_CLASSES = frozenset({
"docker_linux",
"docker_wsl"})
46CONTAINER_RUNNER_PLAYS = frozenset({
"ci-runner-docker",
"wsl-ci-host"})
47MAX_TYPED_VARS_BYTES = 64 * 1024
48PRIVATE_FILE_MODE = stat.S_IRUSR | stat.S_IWUSR
51@dataclass(frozen=True)
53 """Validated, caller-owned Ansible variables captured before side effects."""
59def _raise_fleet_error(message: str, cause: Exception |
None =
None) -> NoReturn:
60 """Raise one fleet precondition error, preserving an optional OS cause."""
62 raise fm.FleetError(message)
from cause
63 raise fm.FleetError(message)
66def _read_owned_vars_content(candidate: Path, operation: str) -> bytes:
67 """Read one bounded, owned, mode-0600 regular file without following it."""
68 if candidate.is_symlink():
69 _raise_fleet_error(f
"{operation} vars file must not be a symlink: {candidate}")
72 | getattr(os,
"O_CLOEXEC", 0)
73 | getattr(os,
"O_NOFOLLOW", 0)
74 | getattr(os,
"O_NONBLOCK", 0)
77 fd = os.open(candidate, flags)
78 except OSError
as exc:
80 f
"{operation} vars file is not a readable non-symlink: {candidate}: {exc}", exc
84 if not stat.S_ISREG(info.st_mode):
85 _raise_fleet_error(f
"{operation} vars file is not a regular file: {candidate}")
86 if info.st_size > MAX_TYPED_VARS_BYTES:
88 f
"{operation} vars file exceeds {MAX_TYPED_VARS_BYTES} bytes: {candidate}"
90 if info.st_uid != os.geteuid():
92 f
"{operation} vars file must be owned by uid {os.geteuid()}: {candidate}"
94 if stat.S_IMODE(info.st_mode) != PRIVATE_FILE_MODE:
95 _raise_fleet_error(f
"{operation} vars file must be mode 0600: {candidate}")
96 with os.fdopen(fd,
"rb", closefd=
True)
as stream:
97 content = stream.read(MAX_TYPED_VARS_BYTES + 1)
102 if len(content) > MAX_TYPED_VARS_BYTES:
103 _raise_fleet_error(f
"{operation} vars file grew beyond the size limit: {candidate}")
107def _validate_mapping(content: bytes, operation: str, candidate: Path) ->
None:
108 """Require a small typed YAML mapping for exactly one infra operation."""
109 allowed_keys = TYPED_VAR_KEYS[operation]
111 values = yaml.safe_load(content.decode(
"utf-8"))
112 except (UnicodeDecodeError, yaml.YAMLError)
as exc:
113 _raise_fleet_error(f
"{operation} vars file is not valid UTF-8 YAML: {candidate}", exc)
114 if not isinstance(values, dict)
or not values:
115 _raise_fleet_error(f
"{operation} vars file must contain a non-empty YAML mapping")
116 if any(
not isinstance(key, str)
for key
in values):
117 _raise_fleet_error(f
"{operation} vars file keys must all be strings")
119 unexpected = keys - allowed_keys
122 f
"{operation} vars file contains unsupported key(s): {', '.join(sorted(unexpected))}"
124 if operation
in EXACT_KEY_OPERATIONS
and keys != allowed_keys:
125 missing = allowed_keys - keys
127 f
"{operation} vars file is missing required key(s): {', '.join(sorted(missing))}"
129 for key
in keys & TOKEN_KEYS:
130 if not isinstance(values[key], str)
or not values[key].strip():
131 _raise_fleet_error(f
"{operation} key {key} must be a non-empty string")
132 if "ci_runner_docker_destroy_dataset" in values
and not isinstance(
133 values[
"ci_runner_docker_destroy_dataset"], bool
136 f
"{operation} key ci_runner_docker_destroy_dataset must be a YAML boolean"
140def read_typed_vars_file(raw_path: str, operation: str) -> TypedVars:
141 """Capture a canonical typed vars file before any converge side effect."""
142 if operation
not in TYPED_VAR_KEYS:
143 _raise_fleet_error(f
"unsupported typed vars operation: {operation}")
144 candidate = Path(raw_path).expanduser()
145 if not candidate.is_absolute():
146 candidate = Path.cwd() / candidate
147 content = _read_owned_vars_content(candidate, operation)
148 _validate_mapping(content, operation, candidate)
149 resolved = candidate.resolve(strict=
True)
150 if resolved.is_relative_to(fm.REPO_ROOT):
151 _raise_fleet_error(f
"{operation} vars file must live outside the checkout: {resolved}")
152 return TypedVars(resolved, content)
156def local_vars_snapshot(typed_vars: TypedVars) -> Iterator[Path]:
157 """Yield a canonical mode-0600 snapshot and remove it on every exit path."""
158 fd, raw_path = tempfile.mkstemp(prefix=
"ra8-ansible-vars-", suffix=
".yml")
159 path = Path(raw_path).resolve(strict=
True)
161 os.fchmod(fd, PRIVATE_FILE_MODE)
162 with os.fdopen(fd,
"wb", closefd=
True)
as stream:
163 stream.write(typed_vars.content)
169 path.unlink(missing_ok=
True)
172def _expect_refusal(path: Path, operation: str) -> bool:
173 """Return whether a typed vars fixture is rejected without escaping."""
175 read_typed_vars_file(str(path), operation)
176 except fm.FleetError:
181def _fixture(root: Path, name: str, content: bytes, mode: int = 0o600) -> Path:
182 """Create one isolated typed-vars fixture with an explicit mode."""
184 path.write_bytes(content)
189def _rejection_selftest(root: Path, good: Path) -> list[str]:
190 """Exercise every typed-file refusal without any converge side effect."""
191 failures: list[str] = []
193 (_fixture(root,
"wrong-mode.yml", good.read_bytes(), 0o644),
"non-0600"),
194 (_fixture(root,
"wrong-key.yml", b
"arbitrary_ansible_override: true\n"),
"out-of-schema"),
195 (_fixture(root,
"non-string-key.yml", b
"1: token\n"),
"non-string key"),
196 (_fixture(root,
"oversized.yml", b
"x" * (MAX_TYPED_VARS_BYTES + 1)),
"oversized"),
198 for path, label
in cases:
199 if not _expect_refusal(path, RUNNER_REGISTRATION):
200 failures.append(f
"{label} typed vars file was accepted")
201 if not _expect_refusal(root, RUNNER_REGISTRATION):
202 failures.append(
"non-regular typed vars path was accepted")
203 with patch.object(os,
"geteuid", return_value=os.geteuid() + 1):
204 if not _expect_refusal(good, RUNNER_REGISTRATION):
205 failures.append(
"wrong-owner typed file was accepted")
206 link = root /
"link.yml"
207 link.symlink_to(good)
208 if not _expect_refusal(link, RUNNER_REGISTRATION):
209 failures.append(
"symlinked typed file was accepted")
213def _fail_inside_snapshot(typed: TypedVars, observed: list[tuple[Path, bool]]) -> NoReturn:
214 """Raise from inside a snapshot context and expose only its former path."""
215 with local_vars_snapshot(typed)
as snapshot:
217 snapshot.is_absolute()
and stat.S_IMODE(snapshot.stat().st_mode) == PRIVATE_FILE_MODE
219 observed.append((snapshot, private))
220 message =
"exercise cleanup"
221 raise RuntimeError(message)
224def _snapshot_cleanup_selftest(typed: TypedVars) -> list[str]:
225 """Prove a local snapshot is canonical, private, and failure-cleaned."""
226 observed: list[tuple[Path, bool]] = []
227 with suppress(RuntimeError):
228 _fail_inside_snapshot(typed, observed)
229 failures = []
if observed
and observed[0][1]
else [
"local snapshot was not absolute/mode-0600"]
230 snapshot_path = observed[0][0]
if observed
else None
231 if snapshot_path
is None or snapshot_path.exists():
232 failures.append(
"local snapshot survived a failing converge")
236def run_selftest() -> list[str]:
237 """Exercise typed vars acceptance and refusal in both directions."""
238 with tempfile.TemporaryDirectory(prefix=
"ra8-fleet-vars-")
as scratch:
240 checkout = root /
"checkout"
242 with patch.object(fm,
"REPO_ROOT", checkout):
244 root,
"registration.yml", b
"ci_runner_docker_registration_token: test-token\n"
246 typed = read_typed_vars_file(str(good), RUNNER_REGISTRATION)
247 failures = _rejection_selftest(root, good)
248 if typed.source != good.resolve()
or typed.content != good.read_bytes():
249 failures.append(
"valid typed file was not captured canonically")
250 if not _expect_refusal(good,
"arbitrary operation"):
251 failures.append(
"unlisted typed operation was accepted")
252 removal = _fixture(root,
"removal.yml", b
"ci_runner_docker_destroy_dataset: false\n")
253 if read_typed_vars_file(str(removal), RUNNER_REMOVAL).source != removal.resolve():
254 failures.append(
"valid removal typed file was rejected")
255 return failures + _snapshot_cleanup_selftest(typed)