4"""Check installed Ansible collections against the exact Galaxy manifest."""
6from __future__
import annotations
14from pathlib
import Path
18sys.path.insert(0, str(Path(__file__).resolve().parents[1] /
"dev"))
20import fleet_path_authority
as fpa
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_]*")
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):
42 for key, value
in zip(node.keys, node.values, strict=
True):
44 isinstance(key, ast.Constant)
45 and key.value ==
"ANSIBLE_STDOUT_CALLBACK"
46 and isinstance(value, ast.Constant)
47 and isinstance(value.value, str)
50 match = CALLBACK_RE.fullmatch(value.value)
52 found.add(match.group(1))
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():
61 found: set[str] = set()
62 for path
in sorted((*ansible_root.rglob(
"*.yml"), *ansible_root.rglob(
"*.yaml"))):
63 if path.name ==
"requirements.yml":
65 for line
in path.read_text(encoding=
"utf-8").splitlines():
66 if line.lstrip().startswith(
"#"):
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
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)
95 or re.fullmatch(
r"[0-9]+\.[0-9]+\.[0-9]+(?:[-+][0-9A-Za-z.-]+)?", version)
is None
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:
107 "Galaxy manifest/consumer mismatch: "
108 f
"manifest-only={sorted(set(expected) - consumers)}, "
109 f
"consumer-only={sorted(consumers - set(expected))}"
111 raise ValueError(message)
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)
124- name: Kubernetes consumer
126- name: Vault consumer
127 ansible.builtin.debug:
128 msg: "{{ lookup('community.hashi_vault.vault_kv2_get', 'secret') }}"
130 (tasks /
"main.yml").write_text(consumers, encoding=
"utf-8")
131 callback =
"""environment = {
132 "ANSIBLE_STDOUT_CALLBACK": "ansible.posix.json",
135 (scripts /
"callback.py").write_text(callback, encoding=
"utf-8")
136 manifest = root /
"requirements.yml"
139 - name: ansible.posix
141 - name: community.hashi_vault
143 - name: kubernetes.core
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")
153 expected_versions(manifest, root)
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",
""))
161 expected_versions(manifest, root)
165 message =
"consumer-only collection passed"
166 raise AssertionError(message)
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):
180 Path(inventory_path).absolute().relative_to(root)
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))
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)
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']}")
206def selftest() -> int:
207 """Prove scoped exact-set checking in every direction."""
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)
216 local: {name: {
"version": version}
for name, version
in expected.items()},
217 "/usr/share/ansible/collections": {
"global.extra": {
"version":
"9.9.9"}},
219 if check(good, root, expected):
220 print(
"selftest: matching scoped inventory failed", file=sys.stderr)
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}},
228 str(root /
"duplicate"): {
"ansible.posix": {
"version":
"1.2.3"}},
231 if any(
not check(case, root, expected)
for case
in cases):
232 print(
"selftest: missing/extra/wrong/duplicate case passed", file=sys.stderr)
234 outside = Path(raw) /
"outside"
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)
241 print(
"check_ansible_collections.py --selftest: PASS")
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()
254 if not args.stdin
or args.root
is None:
255 parser.error(
"--stdin and --root are required outside selftest mode")
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)
263 print(
"\n".join(findings), file=sys.stderr)
265 print(
"Ansible Galaxy collections match infra/ansible/requirements.yml")
269if __name__ ==
"__main__":
270 raise SystemExit(
main())
void main(void)
The application entry point Reset_Handler hands control to.