Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 0 additions & 1 deletion labs/linux/capstones/rhcsa-mock-exam/lab.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,6 @@ runtime:
session: local
default: server
snapshot_required: false
hosts_required: 2
targets:
- name: server
host: alma-rhcsa-1.lab
Expand Down
18 changes: 18 additions & 0 deletions requirements.yml
Original file line number Diff line number Diff line change
@@ -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
137 changes: 137 additions & 0 deletions tests/test_collections_declarees.py
Original file line number Diff line number Diff line change
@@ -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."
)