From 525ad64345dbbbf3f5974a159ad5111ba72899ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20ROBERT?= Date: Fri, 21 Aug 2026 22:24:53 +0200 Subject: [PATCH] =?UTF-8?q?fix(collections):=20d=C3=A9clarer=20les=20colle?= =?UTF-8?q?ctions=20Ansible=20employ=C3=A9es?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trois collections hors ansible.builtin sont utilisées et aucune n'était déclarée. Deux se trouvaient installées par hasard sur la machine de développement, la troisième non, et le capstone rhcsa-mock-exam échouait alors sur « rc=4, Stats : {} ». Le 4 est le code « unreachable » d'Ansible : le message envoie chercher un problème de réseau pendant que la cause est une dépendance absente. Quatre pistes ont été instruites avant la bonne, alors que les trois hôtes répondaient au ping. Recensé sur tous les labs et toutes les solutions déchiffrées : ansible.builtin 604 fois, ansible.posix 3, community.general 1, community.crypto 1. Après installation de la troisième, le capstone passe 20/20. Le test refuse qu'une collection employée manque à la déclaration. Il porte sur ce que les fichiers emploient, pas sur ce que la machine a d'installé : une suite verte parce que le poste est bien garni ne dit rien de celui du prochain qui clone. Il déchiffre les solutions au passage, et un second test vérifie que ce déchiffrement a eu lieu, sans quoi le premier passerait au vert sans avoir rien regardé. Un premier motif cherchait mot.mot.mot n'importe où et remontait auto.master, logs.tar, net.ipv4 et dl.flathub. Un garde-fou qui crie au loup se fait désactiver, donc il ne regarde que la position de clé dans une tâche, et la raison est écrite dans le fichier. Éprouvé par mutation : retirer community.crypto de la déclaration fait échouer le test en nommant le fichier fautif. Au passage, runtime.hosts_required disparaît du capstone : le champ n'est lu ni par dsoxlab ni par ce dépôt, et le compte d'hôtes est déjà porté par runtime.targets. C'est l'un des champs morts que les schémas JSON de dsoxlab 0.1.54 ont fait apparaître. Le message d'échec du replay n'est pas retouché : main le traite déjà depuis « un replay de solution en échec doit dire pourquoi » (#42). Validation : 84 labs verts sur 84, infra provisionnée, solutions rejouées. Closes #50 Co-Authored-By: Claude Opus 5 --- .pre-commit-config.yaml | 12 ++ labs/linux/capstones/rhcsa-mock-exam/lab.yaml | 1 - requirements.yml | 18 +++ tests/test_collections_declarees.py | 137 ++++++++++++++++++ 4 files changed, 167 insertions(+), 1 deletion(-) create mode 100644 requirements.yml create mode 100644 tests/test_collections_declarees.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f89c2d3..d1aa4e9 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -103,6 +103,18 @@ repos: pass_filenames: false files: 'labs/.*/(setup|cleanup)\.yaml$' + # Une collection Ansible employée mais non déclarée ne se voit pas : elle + # est installée par hasard sur la machine de qui a écrit le lab, absente + # sur celle du suivant, et l'échec se présente alors comme un « rc=4 » + # d'Ansible, qui ressemble à un problème de réseau. Vécu sur le capstone + # rhcsa-mock-exam (issue #50). + - id: collections-declarees + name: toute collection Ansible employée est déclarée + entry: uv run --no-project --with pytest --with pyyaml pytest tests/test_collections_declarees.py -q + language: system + pass_filenames: false + always_run: true + # Garde-fou d'outillage. Dans le dépôt Ansible jumeau, deux fois pendant # une même session, une modification a disparu sans trace git : un test de # catalogue, puis un hook. Plusieurs processus écrivent dans ces dépôts, diff --git a/labs/linux/capstones/rhcsa-mock-exam/lab.yaml b/labs/linux/capstones/rhcsa-mock-exam/lab.yaml index b13d82b..6766183 100644 --- a/labs/linux/capstones/rhcsa-mock-exam/lab.yaml +++ b/labs/linux/capstones/rhcsa-mock-exam/lab.yaml @@ -42,7 +42,6 @@ runtime: session: local default: server snapshot_required: false - hosts_required: 2 targets: - name: server host: alma-rhcsa-1.lab diff --git a/requirements.yml b/requirements.yml new file mode 100644 index 0000000..59a2399 --- /dev/null +++ b/requirements.yml @@ -0,0 +1,18 @@ +--- +# Collections Ansible que ce dépôt emploie réellement, hors `ansible.builtin`. +# +# Elles n'étaient déclarées nulle part : deux d'entre elles se trouvaient +# installées par hasard sur la machine de développement, la troisième non, et +# le capstone rhcsa-mock-exam échouait alors sur un « rc=4 » qui ressemble à un +# problème de réseau (issue #50). +# +# Installation : +# ansible-galaxy collection install -r requirements.yml +# +# `tests/test_collections_declarees.py` refuse qu'une collection employée dans +# labs/ ou solution/ manque à cette liste. + +collections: + - name: ansible.posix + - name: community.general + - name: community.crypto diff --git a/tests/test_collections_declarees.py b/tests/test_collections_declarees.py new file mode 100644 index 0000000..62af96d --- /dev/null +++ b/tests/test_collections_declarees.py @@ -0,0 +1,137 @@ +"""Toute collection Ansible employée doit être déclarée dans requirements.yml. + +Raison d'être (issue #50) : trois collections hors `ansible.builtin` étaient +utilisées et aucune n'était déclarée. Deux se trouvaient installées par hasard +sur la machine de développement, la troisième non, et le capstone +`rhcsa-mock-exam` échouait alors sur un « rc=4, Stats : {} », le code +« unreachable » d'Ansible, qui envoie chercher un problème de réseau pendant que +la cause est une dépendance absente. + +Le contrôle porte sur ce que les fichiers **emploient**, pas sur ce que la +machine a d'installé : une suite qui passe parce que la machine est bien garnie +ne dit rien de celle du prochain qui clone. + +Les solutions sont chiffrées par ansible-vault. Sans `.vault-pass`, elles ne sont +pas lisibles : le test le dit et se limite alors à `labs/`, plutôt que de rendre +un vert qui n'aurait rien vérifié. +""" + +from __future__ import annotations + +import re +import subprocess +from pathlib import Path + +import pytest +import yaml + +RACINE = Path(__file__).resolve().parent.parent +REQUIREMENTS = RACINE / "requirements.yml" +VAULT_PASS = RACINE / ".vault-pass" + +#: Un module en **position de clé** dans une tâche YAML : +#: +#: - name: … +#: community.crypto.openssh_keypair: +#: +#: Chercher le motif n'importe où dans le texte ne marche pas : un premier jet le +#: faisait, et remontait `auto.master`, `logs.tar`, `net.ipv4` ou `dl.flathub` : +#: des chemins, des archives et un nom de domaine. Un garde-fou qui crie au loup +#: se fait désactiver, donc il ne regarde que là où un module peut se trouver. +QUALIFIE = re.compile( + r"^\s*(?:-\s+)?([a-z][a-z0-9_]*\.[a-z][a-z0-9_]*)\.[a-z][a-z0-9_]*\s*:", + re.MULTILINE, +) + +#: Fournie avec ansible-core, jamais à déclarer. +INTEGREE = {"ansible.builtin"} + + +def _declarees() -> set[str]: + if not REQUIREMENTS.is_file(): + pytest.fail( + "requirements.yml est absent : les collections employées ne sont " + "déclarées nulle part, et leur absence se manifestera par un rc=4 " + "qui ressemble à un problème de réseau." + ) + contenu = yaml.safe_load(REQUIREMENTS.read_text(encoding="utf-8")) or {} + return { + entree["name"] if isinstance(entree, dict) else str(entree) + for entree in (contenu.get("collections") or []) + } + + +def _texte(fichier: Path) -> str: + """Rend le contenu, en déchiffrant si le fichier est sous vault.""" + brut = fichier.read_bytes() + if not brut.startswith(b"$ANSIBLE_VAULT"): + return brut.decode("utf-8", errors="replace") + if not VAULT_PASS.is_file(): + return "" + vu = subprocess.run( + ["ansible-vault", "view", "--vault-password-file", str(VAULT_PASS), str(fichier)], + capture_output=True, + text=True, + check=False, + ) + return vu.stdout if vu.returncode == 0 else "" + + +def _employees(racines: list[Path]) -> dict[str, set[str]]: + """Rend {collection: {fichiers qui l'emploient}}.""" + trouvees: dict[str, set[str]] = {} + for racine in racines: + if not racine.is_dir(): + continue + for fichier in list(racine.rglob("*.yaml")) + list(racine.rglob("*.yml")): + for nom in QUALIFIE.findall(_texte(fichier)): + if nom in INTEGREE: + continue + trouvees.setdefault(nom, set()).add( + str(fichier.relative_to(RACINE)) + ) + return trouvees + + +def test_toute_collection_employee_est_declaree() -> None: + """Le contrôle qui aurait épargné l'enquête de l'issue #50.""" + racines = [RACINE / "labs"] + if VAULT_PASS.is_file(): + racines.append(RACINE / "solution") + + employees = _employees(racines) + manquantes = { + nom: fichiers + for nom, fichiers in employees.items() + if nom not in _declarees() + } + + if manquantes: + detail = "\n".join( + f" {nom} : employée par {min(f)}" + + (f" et {len(f) - 1} autre(s)" if len(f) > 1 else "") + for nom, f in sorted(manquantes.items()) + ) + pytest.fail( + "Ces collections sont employées mais absentes de requirements.yml :\n" + f"{detail}\n" + "Sur une machine qui ne les a pas, l'échec se présentera comme un " + "rc=4 « unreachable », pas comme une dépendance manquante." + ) + + +def test_le_controle_voit_les_solutions_chiffrees() -> None: + """Garde-fou du garde-fou : sans lecture des solutions, il ne prouve rien. + + `community.crypto` n'est employée que dans une solution chiffrée. Si le + déchiffrement échoue en silence, le test précédent passerait au vert sans + avoir rien regardé : la panne du harnais déguisée en succès. + """ + if not VAULT_PASS.is_file(): + pytest.skip(".vault-pass absent : les solutions ne sont pas lisibles ici.") + + employees = _employees([RACINE / "solution"]) + assert employees, ( + "Aucune collection trouvée dans solution/ : le déchiffrement a " + "probablement échoué, et le contrôle ne mesure plus rien." + )