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." + )