ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
check_ansible_collections.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# SPDX-License-Identifier: MIT
3# Copyright (c) 2026 Brighton Sikarskie
4"""Check installed Ansible collections against the exact Galaxy manifest."""
5
6from __future__ import annotations
7
8import argparse
9import ast
10import json
11import re
12import sys
13import tempfile
14from pathlib import Path
15
16import yaml
17
18sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "dev"))
19
20import fleet_path_authority as fpa
21
22ROOT = Path(__file__).resolve().parents[2]
23REQUIREMENTS = ROOT / "infra" / "ansible" / "requirements.yml"
24ANSIBLE_ROOT = ROOT / "infra" / "ansible"
25ESSENTIAL_COLLECTIONS = frozenset({"ansible.posix", "community.hashi_vault", "kubernetes.core"})
26BUILTIN_COLLECTIONS = frozenset({"ansible.builtin", "ansible.legacy"})
27MODULE_RE = re.compile(r"^\s*([a-z][a-z0-9_]*\.[a-z][a-z0-9_]*\.[a-z][a-z0-9_]*):\s*(?:#.*)?$")
28LOOKUP_RE = re.compile(r"\b(?:lookup|query)\‍(\s*['\"]([a-z][a-z0-9_]*\.[a-z][a-z0-9_]*)\.")
29CALLBACK_RE = re.compile(r"([a-z][a-z0-9_]*\.[a-z][a-z0-9_]*)\.[a-z][a-z0-9_]*")
30
31
32def _callback_collections(root: Path) -> set[str]:
33 """Discover collection callbacks selected by first-party Python transports."""
34 scripts = root / "scripts" / "dev"
35 search = scripts if scripts.is_dir() else root
36 found: set[str] = set()
37 for path in sorted(search.rglob("*.py")):
38 tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
39 for node in ast.walk(tree):
40 if not isinstance(node, ast.Dict):
41 continue
42 for key, value in zip(node.keys, node.values, strict=True):
43 if not (
44 isinstance(key, ast.Constant)
45 and key.value == "ANSIBLE_STDOUT_CALLBACK"
46 and isinstance(value, ast.Constant)
47 and isinstance(value.value, str)
48 ):
49 continue
50 match = CALLBACK_RE.fullmatch(value.value)
51 if match is not None:
52 found.add(match.group(1))
53 return found
54
55
56def consumer_collections(root: Path = ROOT) -> set[str]:
57 """Discover Galaxy names used by Ansible YAML and Python callbacks."""
58 ansible_root = root / "infra" / "ansible"
59 if not ansible_root.is_dir():
60 ansible_root = root
61 found: set[str] = set()
62 for path in sorted((*ansible_root.rglob("*.yml"), *ansible_root.rglob("*.yaml"))):
63 if path.name == "requirements.yml":
64 continue
65 for line in path.read_text(encoding="utf-8").splitlines():
66 if line.lstrip().startswith("#"):
67 continue
68 module_match = MODULE_RE.match(line)
69 if module_match is not None:
70 found.add(".".join(module_match.group(1).split(".")[:2]))
71 found.update(LOOKUP_RE.findall(line))
72 found.update(_callback_collections(root))
73 return found - BUILTIN_COLLECTIONS
74
75
76def expected_versions(path: Path = REQUIREMENTS, consumer_root: Path = ROOT) -> dict[str, str]:
77 """Return exact collection versions from the repository manifest."""
78 document = yaml.safe_load(path.read_text(encoding="ascii"))
79 collections = document.get("collections") if isinstance(document, dict) else None
80 if not isinstance(collections, list) or not collections:
81 message = "Ansible collection manifest is empty or malformed"
82 raise ValueError(message)
83 expected: dict[str, str] = {}
84 for record in collections:
85 if not isinstance(record, dict):
86 message = "Ansible collection record must be a mapping"
87 raise TypeError(message)
88 name = record.get("name")
89 version = record.get("version")
90 if not isinstance(name, str) or not isinstance(version, str):
91 message = "Ansible collection name and version must be strings"
92 raise TypeError(message)
93 if (
94 name in expected
95 or re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+(?:[-+][0-9A-Za-z.-]+)?", version) is None
96 ):
97 message = f"Ansible collection {name!r} is duplicated or not a semver pin"
98 raise ValueError(message)
99 expected[name] = version
100 consumers = consumer_collections(consumer_root)
101 missing_essential = ESSENTIAL_COLLECTIONS - consumers
102 if missing_essential:
103 message = f"essential Ansible consumers disappeared: {sorted(missing_essential)}"
104 raise ValueError(message)
105 if set(expected) != consumers:
106 message = (
107 "Galaxy manifest/consumer mismatch: "
108 f"manifest-only={sorted(set(expected) - consumers)}, "
109 f"consumer-only={sorted(consumers - set(expected))}"
110 )
111 raise ValueError(message)
112 return expected
113
114
115def manifest_selftest() -> None:
116 """Prove manifest/consumer exactness and the essential intent floor."""
117 with tempfile.TemporaryDirectory() as raw_root:
118 root = Path(raw_root)
119 tasks = root / "roles" / "sample" / "tasks"
120 tasks.mkdir(parents=True)
121 scripts = root / "scripts" / "dev"
122 scripts.mkdir(parents=True)
123 consumers = """---
124- name: Kubernetes consumer
125 kubernetes.core.k8s:
126- name: Vault consumer
127 ansible.builtin.debug:
128 msg: "{{ lookup('community.hashi_vault.vault_kv2_get', 'secret') }}"
129"""
130 (tasks / "main.yml").write_text(consumers, encoding="utf-8")
131 callback = """environment = {
132 "ANSIBLE_STDOUT_CALLBACK": "ansible.posix.json",
133}
134"""
135 (scripts / "callback.py").write_text(callback, encoding="utf-8")
136 manifest = root / "requirements.yml"
137 exact = """---
138collections:
139 - name: ansible.posix
140 version: 2.2.0
141 - name: community.hashi_vault
142 version: 7.1.0
143 - name: kubernetes.core
144 version: 6.5.0
145"""
146 manifest.write_text(exact, encoding="utf-8")
147 expected_versions(manifest, root)
148 if consumer_collections(root) != ESSENTIAL_COLLECTIONS:
149 message = "YAML/module/lookup/callback consumer discovery drifted"
150 raise AssertionError(message)
151 manifest.write_text(exact + " - name: stale.extra\n version: 1.0.0\n")
152 try:
153 expected_versions(manifest, root)
154 except ValueError:
155 pass
156 else:
157 message = "manifest-only collection passed"
158 raise AssertionError(message)
159 manifest.write_text(exact.replace(" - name: kubernetes.core\n version: 6.5.0\n", ""))
160 try:
161 expected_versions(manifest, root)
162 except ValueError:
163 pass
164 else:
165 message = "consumer-only collection passed"
166 raise AssertionError(message)
167
168
169def installed_versions(document: object, root: Path) -> dict[str, list[tuple[str, str]]]:
170 """Inventory every local physical collection location without collapsing it."""
171 if not isinstance(document, dict):
172 message = "ansible-galaxy collection list JSON must be an object"
173 raise TypeError(message)
174 root = root.absolute()
175 installed: dict[str, list[tuple[str, str]]] = {}
176 for inventory_path, collections in document.items():
177 if not isinstance(inventory_path, str) or not isinstance(collections, dict):
178 continue
179 try:
180 Path(inventory_path).absolute().relative_to(root)
181 except ValueError:
182 continue
183 for name, record in collections.items():
184 if isinstance(name, str) and isinstance(record, dict):
185 version = record.get("version")
186 if isinstance(version, str):
187 installed.setdefault(name, []).append((inventory_path, version))
188 return installed
189
190
191def check(document: object, root: Path, expected: dict[str, str] | None = None) -> list[str]:
192 """Return missing, extra, duplicate-location, and wrong-version findings."""
193 wanted = expected or expected_versions()
194 present = installed_versions(document, root)
195 findings = fpa.confined_link_errors(root)
196 for name in sorted(wanted.keys() | present.keys()):
197 records = present.get(name, [])
198 required = wanted.get(name)
199 if required is None:
200 findings.append(f"{name}: unexpected local collection {records}")
201 elif len(records) != 1 or records[0][1] != required:
202 findings.append(f"{name}: expected one {required}, installed {records or ['absent']}")
203 return findings
204
205
206def selftest() -> int:
207 """Prove scoped exact-set checking in every direction."""
208 manifest_selftest()
209 with tempfile.TemporaryDirectory() as raw:
210 root = Path(raw) / "collections"
211 local_root = root / "ansible_collections"
212 local_root.mkdir(parents=True)
213 expected = {"ansible.posix": "1.2.3", "kubernetes.core": "4.5.6"}
214 local = str(local_root)
215 good = {
216 local: {name: {"version": version} for name, version in expected.items()},
217 "/usr/share/ansible/collections": {"global.extra": {"version": "9.9.9"}},
218 }
219 if check(good, root, expected):
220 print("selftest: matching scoped inventory failed", file=sys.stderr)
221 return 1
222 cases = (
223 {local: {"ansible.posix": {"version": "1.2.3"}}},
224 {local: {**good[local], "local.extra": {"version": "1.0.0"}}},
225 {local: {name: {"version": "0.0.0"} for name in expected}},
226 {
227 local: good[local],
228 str(root / "duplicate"): {"ansible.posix": {"version": "1.2.3"}},
229 },
230 )
231 if any(not check(case, root, expected) for case in cases):
232 print("selftest: missing/extra/wrong/duplicate case passed", file=sys.stderr)
233 return 1
234 outside = Path(raw) / "outside"
235 outside.mkdir()
236 link = local_root / "ansible"
237 link.symlink_to(outside, target_is_directory=True)
238 if not check(good, root, expected):
239 print("selftest: escaping collection link passed", file=sys.stderr)
240 return 1
241 print("check_ansible_collections.py --selftest: PASS")
242 return 0
243
244
245def main() -> int:
246 """Check stdin inventory or run the offline selftest."""
247 parser = argparse.ArgumentParser(description=__doc__)
248 parser.add_argument("--stdin", action="store_true", help="read ansible-galaxy JSON from stdin")
249 parser.add_argument("--root", type=Path, help="repository-local collections root")
250 parser.add_argument("--selftest", action="store_true")
251 args = parser.parse_args()
252 if args.selftest:
253 return selftest()
254 if not args.stdin or args.root is None:
255 parser.error("--stdin and --root are required outside selftest mode")
256 try:
257 document = json.load(sys.stdin)
258 findings = check(document, args.root)
259 except (OSError, TypeError, ValueError, json.JSONDecodeError, yaml.YAMLError) as error:
260 print(f"check_ansible_collections.py: FATAL: {error}", file=sys.stderr)
261 return 2
262 if findings:
263 print("\n".join(findings), file=sys.stderr)
264 return 1
265 print("Ansible Galaxy collections match infra/ansible/requirements.yml")
266 return 0
267
268
269if __name__ == "__main__":
270 raise SystemExit(main())
-copyright
void main(void)
The application entry point Reset_Handler hands control to.
Definition main.c:298