ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
fleet_typed_vars.py
Go to the documentation of this file.
1# SPDX-License-Identifier: MIT
2# Copyright (c) 2026 Brighton Sikarskie
3"""Validate and privately snapshot typed Ansible variables for fleet operations."""
4
5from __future__ import annotations
6
7import os
8import stat
9import tempfile
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
17
18import fleet_model as fm
19import yaml
20
21RUNNER_REGISTRATION = "runner registration"
22HIL_REGISTRATION = "HIL registration"
23RUNNER_REMOVAL = "runner removal"
24
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"}
29)
30TYPED_VAR_KEYS = MappingProxyType(
31 {
32 RUNNER_REGISTRATION: RUNNER_REGISTRATION_KEYS,
33 HIL_REGISTRATION: HIL_REGISTRATION_KEYS,
34 RUNNER_REMOVAL: RUNNER_REMOVAL_KEYS,
35 }
36)
37EXACT_KEY_OPERATIONS = frozenset({RUNNER_REGISTRATION, HIL_REGISTRATION})
38TOKEN_KEYS = frozenset(
39 {
40 "ci_runner_docker_registration_token",
41 "ci_runner_docker_removal_token",
42 "dev_box_hil_runner_registration_token",
43 }
44)
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
49
50
51@dataclass(frozen=True)
52class TypedVars:
53 """Validated, caller-owned Ansible variables captured before side effects."""
54
55 source: Path
56 content: bytes
57
58
59def _raise_fleet_error(message: str, cause: Exception | None = None) -> NoReturn:
60 """Raise one fleet precondition error, preserving an optional OS cause."""
61 if cause is not None:
62 raise fm.FleetError(message) from cause
63 raise fm.FleetError(message)
64
65
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}")
70 flags = (
71 os.O_RDONLY
72 | getattr(os, "O_CLOEXEC", 0)
73 | getattr(os, "O_NOFOLLOW", 0)
74 | getattr(os, "O_NONBLOCK", 0)
75 )
76 try:
77 fd = os.open(candidate, flags)
78 except OSError as exc:
79 _raise_fleet_error(
80 f"{operation} vars file is not a readable non-symlink: {candidate}: {exc}", exc
81 )
82 try:
83 info = os.fstat(fd)
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:
87 _raise_fleet_error(
88 f"{operation} vars file exceeds {MAX_TYPED_VARS_BYTES} bytes: {candidate}"
89 )
90 if info.st_uid != os.geteuid():
91 _raise_fleet_error(
92 f"{operation} vars file must be owned by uid {os.geteuid()}: {candidate}"
93 )
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)
98 fd = -1
99 finally:
100 if fd >= 0:
101 os.close(fd)
102 if len(content) > MAX_TYPED_VARS_BYTES:
103 _raise_fleet_error(f"{operation} vars file grew beyond the size limit: {candidate}")
104 return content
105
106
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]
110 try:
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")
118 keys = set(values)
119 unexpected = keys - allowed_keys
120 if unexpected:
121 _raise_fleet_error(
122 f"{operation} vars file contains unsupported key(s): {', '.join(sorted(unexpected))}"
123 )
124 if operation in EXACT_KEY_OPERATIONS and keys != allowed_keys:
125 missing = allowed_keys - keys
126 _raise_fleet_error(
127 f"{operation} vars file is missing required key(s): {', '.join(sorted(missing))}"
128 )
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
134 ):
135 _raise_fleet_error(
136 f"{operation} key ci_runner_docker_destroy_dataset must be a YAML boolean"
137 )
138
139
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)
153
154
155@contextmanager
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)
160 try:
161 os.fchmod(fd, PRIVATE_FILE_MODE)
162 with os.fdopen(fd, "wb", closefd=True) as stream:
163 stream.write(typed_vars.content)
164 fd = -1
165 yield path
166 finally:
167 if fd >= 0:
168 os.close(fd)
169 path.unlink(missing_ok=True)
170
171
172def _expect_refusal(path: Path, operation: str) -> bool:
173 """Return whether a typed vars fixture is rejected without escaping."""
174 try:
175 read_typed_vars_file(str(path), operation)
176 except fm.FleetError:
177 return True
178 return False
179
180
181def _fixture(root: Path, name: str, content: bytes, mode: int = 0o600) -> Path:
182 """Create one isolated typed-vars fixture with an explicit mode."""
183 path = root / name
184 path.write_bytes(content)
185 path.chmod(mode)
186 return path
187
188
189def _rejection_selftest(root: Path, good: Path) -> list[str]:
190 """Exercise every typed-file refusal without any converge side effect."""
191 failures: list[str] = []
192 cases = (
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"),
197 )
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")
210 return failures
211
212
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:
216 private = (
217 snapshot.is_absolute() and stat.S_IMODE(snapshot.stat().st_mode) == PRIVATE_FILE_MODE
218 )
219 observed.append((snapshot, private))
220 message = "exercise cleanup"
221 raise RuntimeError(message)
222
223
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")
233 return failures
234
235
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:
239 root = Path(scratch)
240 checkout = root / "checkout"
241 checkout.mkdir()
242 with patch.object(fm, "REPO_ROOT", checkout):
243 good = _fixture(
244 root, "registration.yml", b"ci_runner_docker_registration_token: test-token\n"
245 )
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)