
Ce guide construit un rôle users sans jamais écrire une tâche avant le test qui la réclame. Trois cycles s'enchaînent, chacun partant d'une assertion qui échoue pour finir sur du code juste suffisant. Le rôle final crée des utilisateurs Linux avec un shell choisi et une appartenance à des groupes secondaires.
L'intérêt de dérouler ces cycles en entier n'est pas d'obtenir le rôle, qui tient en une tâche. C'est de voir ce que les tests attrapent, et surtout ce qu'ils laissent passer : vous rencontrerez ici un test au vert sur un rôle cassé, et une régression réelle qu'aucune assertion ne signale.
Ce que vous allez apprendre
Section intitulée « Ce que vous allez apprendre »- Écrire
verify.ymlavanttasks/main.yml, et vérifier que le test échoue pour la bonne raison. - Dérouler trois cycles rouge-vert-refactor sur un rôle qui grossit.
- Reconnaître un test au vert qui ne prouve pas ce que vous croyez.
- Comprendre ce que
append: truechange, en observant l'état réel du système. - Verrouiller les entrées du rôle une fois son comportement stabilisé.
Prérequis
Section intitulée « Prérequis »- Avoir suivi Introduction TDD Molecule pour le principe du cycle rouge-vert-refactor.
- Un scénario Molecule fonctionnel, monté selon Installer Molecule et sa CLI. Un scénario fraîchement généré ne convient pas, il passe au vert sans rien exécuter.
- Podman opérationnel en mode utilisateur.
Le rôle à construire
Section intitulée « Le rôle à construire »Écrire la spécification avant le premier test évite de découvrir le besoin en codant. Celle-ci tient en quatre lignes, et chacune deviendra une assertion.
- Créer une liste d'utilisateurs Linux, fournie par la variable
users_to_create. - Chaque utilisateur peut demander un
shell:particulier. - Chaque utilisateur peut appartenir à des
groups:secondaires. - Sans
shell:explicite, l'utilisateur reçoit une valeur par défaut configurable.
Le jeu de données de test découle directement de cette spécification. alice exerce toutes les options, bob et carol n'en demandent aucune et servent à vérifier les valeurs par défaut.
Étape 0, un scénario prêt à tester
Section intitulée « Étape 0, un scénario prêt à tester »Le rôle se crée dans un répertoire roles/, car c'est cette arborescence que le scénario Molecule ira chercher. Notez que la commande prend le nom du rôle en argument : la lancer depuis un répertoire déjà créé, avec un point, échoue en signalant que le répertoire existe.
mkdir -p roles && cd rolesansible-galaxy role init users --offlinecd usersmolecule init scenarioLe scénario généré ne teste rien tant qu'il n'a pas été configuré. Quatre réglages sont nécessaires, tous détaillés dans le guide d'installation : supprimer les squelettes create.yml et destroy.yml, déclarer le driver et l'image, ajouter le roles_path, et corriger le nom du rôle dans converge.yml. Un cinquième point concerne ce rôle précis : ansible-galaxy ne renseigne ni namespace ni role_name dans meta/main.yml, et Molecule refuse de démarrer sans eux.
galaxy_info: namespace: demo role_name: usersLe playbook de convergence porte le jeu de données de la spécification. Il appelle le rôle et rien d'autre, ce qui garde le test lisible quand les cycles s'accumulent.
---- name: Converge hosts: all vars: users_to_create: - name: alice shell: /bin/zsh groups: - wheel - name: bob - name: carol roles: - role: usersPourquoi become: true fait échouer le test
Section intitulée « Pourquoi become: true fait échouer le test »Ce playbook n'a pas de become: true, et c'est délibéré. La plupart des exemples que vous croiserez en mettent un par réflexe, hérité des playbooks de production où l'on se connecte en utilisateur non privilégié. Dans un conteneur de test, la situation est inverse et le réflexe devient une panne.
$ podman exec instance iduid=0(root) gid=0(root) groups=0(root)Molecule se connecte déjà en root. Pire, l'image centos:stream9 ne contient pas sudo. Un become: true déclenche donc une escalade impossible, et le message d'erreur ne mentionne jamais le conteneur.
sudo: PAM account management error: Authentication service cannot retrieve authentication infosudo: a password is requiredL'échec frappe dès la collecte des facts, avant toute tâche utile, et il touche aussi bien converge.yml que verify.yml. Retirer become: true des deux playbooks est le seul correctif nécessaire.
Cycle 1, alice existe et utilise le bon shell
Section intitulée « Cycle 1, alice existe et utilise le bon shell »Écrire le test avant le code
Section intitulée « Écrire le test avant le code »Le premier test traduit les deux premières lignes de la spécification. Il commence par une vérification d'existence qui utilise une astuce classique : appeler le module user en mode simulation et considérer que tout changement annoncé signifie que l'utilisateur manquait.
---- name: Verify hosts: all tasks: - name: Vérifier que alice existe ansible.builtin.user: name: alice state: present check_mode: true register: alice_check failed_when: alice_check is changed
- name: Relever la ligne passwd d'alice ansible.builtin.command: getent passwd alice register: alice_passwd changed_when: false
- name: Assertion sur le shell d'alice ansible.builtin.assert: that: - "'/bin/zsh' in alice_passwd.stdout" fail_msg: "alice doit avoir /bin/zsh"Le changed_when: false sur la commande getent n'est pas cosmétique. Une commande de lecture qui se déclare modifiée ferait échouer l'étape d'idempotence plus tard, pour une raison sans rapport avec le rôle.
Confirmer que le test échoue
Section intitulée « Confirmer que le test échoue »Cette étape se saute souvent, et c'est une erreur. Un test qui n'a jamais échoué ne prouve rien : il pourrait être vert parce qu'il ne vérifie rien du tout.
molecule converge # le rôle est vide, rien ne se passemolecule verify # rouge, alice n'existe pasLe rouge de cette première tâche est peu bavard, car failed_when ne produit pas de message personnalisé.
fatal: [instance]: FAILED! => {"changed": true, "failed_when_result": true,"msg": "Task failed: Action failed: Unknown error."}Retenez la traduction : failed_when_result: true signifie que votre condition d'échec s'est réalisée, ici parce que le mode simulation annonçait une création. Les assertions écrites avec assert sont bien plus lisibles, ce qui est une bonne raison de les préférer dès que possible.
Le code minimal
Section intitulée « Le code minimal »La règle du TDD est d'écrire le moins de code possible pour changer la couleur du test. Ici, créer les utilisateurs suffit à franchir la première tâche, sans toucher au shell.
---- name: Créer les utilisateurs ansible.builtin.user: name: "{{ item.name }}" state: present loop: "{{ users_to_create }}"molecule converge # crée alice, bob et carolmolecule verify # rouge, mais plus loin qu'avantLe rouge s'est déplacé vers l'assertion sur le shell, et ce déplacement est le vrai signal de progression.
fatal: [instance]: FAILED! => { "assertion": "'/bin/zsh' in alice_passwd.stdout",Compléter jusqu'au vert
Section intitulée « Compléter jusqu'au vert »Le shell manquant s'ajoute avec un filtre default, qui couvre du même coup bob et carol sans leur imposer de déclaration.
- name: Créer les utilisateurs ansible.builtin.user: name: "{{ item.name }}" shell: "{{ item.shell | default('/bin/bash') }}" state: present loop: "{{ users_to_create }}"molecule convergemolecule verify # vertUn test vert peut mentir
Section intitulée « Un test vert peut mentir »Le cycle est terminé, le test est vert, et pourtant le rôle vient de produire un compte inutilisable. La démonstration tient en une commande.
$ podman exec instance getent passwd alicealice:x:1000:1000::/home/alice:/bin/zsh
$ podman exec instance ls -l /bin/zshls: cannot access '/bin/zsh': No such file or directoryAlice a bien /bin/zsh dans sa ligne passwd, et ce shell n'existe pas sur le système. useradd ne vérifie pas que le chemin fourni pointe vers un exécutable présent, donc Ansible ne remonte aucune erreur. Alice ne pourra jamais ouvrir de session.
Le test était vert parce qu'il posait la mauvaise question. Il interrogeait une déclaration, la ligne de /etc/passwd, au lieu de vérifier un effet réel. La correction consiste à ajouter l'assertion qui manquait.
- name: Vérifier que le shell d'alice existe réellement ansible.builtin.stat: path: /bin/zsh register: alice_shell
- name: Assertion sur la présence du shell ansible.builtin.assert: that: - alice_shell.stat.exists fail_msg: "/bin/zsh est déclaré mais absent du système"Ce test passe au rouge immédiatement, ce qui prouve qu'il sert à quelque chose. La réparation ne relève pas du rôle, dont le travail est de poser le shell demandé : elle relève de la préparation de l'environnement de test, et c'est exactement le rôle du playbook prepare.yml, exécuté par Molecule avant la convergence.
---- name: Prepare hosts: all tasks: - name: Installer les shells attendus par les tests ansible.builtin.dnf: name: zsh state: presentCycle 2, les groupes secondaires
Section intitulée « Cycle 2, les groupes secondaires »La troisième ligne de la spécification demande qu'alice appartienne à wheel. La commande id liste les groupes effectifs d'un compte, ce qui en fait une source plus fiable que la lecture de /etc/group.
- name: Relever les groupes d'alice ansible.builtin.command: id alice register: alice_id changed_when: false
- name: Assertion alice membre de wheel ansible.builtin.assert: that: - "'wheel' in alice_id.stdout" fail_msg: "alice doit appartenir à wheel"molecule verify # rouge, alice n'est dans aucun groupe secondaireLe code, et ce que append change vraiment
Section intitulée « Le code, et ce que append change vraiment »La correction ajoute deux clés au module. La première transmet la liste demandée, la seconde mérite une démonstration plutôt qu'une affirmation.
- name: Créer les utilisateurs ansible.builtin.user: name: "{{ item.name }}" shell: "{{ item.shell | default('/bin/bash') }}" groups: "{{ item.groups | default([]) }}" append: true state: present loop: "{{ users_to_create }}"Sans append: true, la clé groups: est exhaustive : Ansible aligne l'appartenance de l'utilisateur sur la liste fournie, en le retirant de tous les groupes absents. L'expérience se mène en trois commandes, en supposant qu'alice appartienne déjà à adm pour une raison quelconque.
# alice appartient à adm avant le passage du rôle$ podman exec instance id aliceuid=1000(alice) gid=1000(alice) groups=1000(alice),4(adm),10(wheel)
# convergence avec un rôle dépourvu de append: true$ podman exec instance id aliceuid=1000(alice) gid=1000(alice) groups=1000(alice),10(wheel)Alice a perdu adm, silencieusement, parce que le rôle ne connaissait pas ce groupe. En production, ce comportement retire des accès à des comptes de service et se découvre au pire moment.
Le plus instructif est ailleurs : pendant cette régression, molecule verify restait vert. Le test ne surveillait que wheel, donc la perte d'un autre groupe lui échappait entièrement. Une suite de tests ne protège que ce qu'elle observe, et c'est pourquoi une assertion s'ajoute à chaque comportement que vous voulez garantir dans le temps.
molecule convergemolecule verify # vertLe revers de append: true mérite d'être connu : le rôle devient incapable de retirer un utilisateur d'un groupe. Retirer une appartenance devient une opération manuelle, ou demande une tâche dédiée.
Cycle 3, refactorer sous la protection des tests
Section intitulée « Cycle 3, refactorer sous la protection des tests »Le troisième cycle ne commence pas par un test rouge, et il faut le dire clairement plutôt que de faire semblant. L'assertion sur carol passe immédiatement, puisque le filtre default('/bin/bash') écrit au cycle 1 produit déjà le bon résultat.
- name: Relever la ligne passwd de carol ansible.builtin.command: getent passwd carol register: carol_passwd changed_when: false
- name: Assertion carol utilise le shell par défaut ansible.builtin.assert: that: - "'/bin/bash' in carol_passwd.stdout"Ce test n'est pas inutile pour autant : il fige un comportement acquis et servira de filet pour la modification qui suit. C'est une phase de refactor pure, où le comportement observable ne doit pas bouger d'un pouce.
Le défaut à corriger est la valeur /bin/bash codée en dur dans les tâches. Un utilisateur du rôle qui voudrait un autre défaut devrait modifier tasks/main.yml, ce qui revient à forker le rôle. La bonne place d'une valeur modifiable est defaults/main.yml.
---users_default_shell: /bin/bashusers_create_home: trueusers_to_create: []Déclarer users_to_create: [] évite l'échec brutal quand quelqu'un appelle le rôle sans rien lui passer : la boucle tourne à vide au lieu de planter sur une variable indéfinie.
---- name: Créer les utilisateurs ansible.builtin.user: name: "{{ item.name }}" shell: "{{ item.shell | default(users_default_shell) }}" groups: "{{ item.groups | default([]) }}" append: true create_home: "{{ users_create_home }}" state: present loop: "{{ users_to_create }}" loop_control: label: "{{ item.name }}"Le loop_control.label change la lisibilité de la sortie sans rien changer au comportement. Sans lui, Ansible affiche le dictionnaire complet de chaque utilisateur à chaque itération ; avec lui, la sortie tient sur une ligne par compte.
molecule converge # ok=2 changed=0, aucun effet de bordmolecule verify # vert, le refactor n'a rien cassémolecule idempotence # changed=0Le changed=0 du converge est le résultat qui compte. Il indique que la réécriture produit exactement le même état que la version précédente, ce qui est la définition d'un refactor réussi.
Verrouiller les entrées du rôle
Section intitulée « Verrouiller les entrées du rôle »Une fois le comportement stable, les entrées publiques du rôle méritent un contrat. Un fichier meta/argument_specs.yml décrit les variables attendues, leur type et les valeurs acceptables.
---argument_specs: main: options: users_to_create: type: list elements: dict options: name: type: str required: true shell: type: str choices: - /bin/bash - /bin/zsh - /bin/sh - /sbin/nologin groups: type: list elements: str default: [] users_default_shell: type: str default: /bin/bashL'effet se constate en demandant un shell hors de la liste. Ansible s'arrête sur une erreur explicite, avant d'avoir modifié quoi que ce soit sur la machine.
fatal: [instance]: FAILED! => {"argument_errors": ["value of shell must be one of:/bin/bash, /bin/zsh, /bin/sh, /sbin/nologin, got: /bin/csh found in users_to_create"]}instance : ok=1 changed=0 unreachable=0 failed=1Le changed=0 est le point important : l'exécution s'interrompt à la validation, donc aucun compte n'est créé à moitié. Sans ce contrat, une faute de frappe dans un shell produirait des utilisateurs inutilisables, exactement comme le /bin/zsh absent rencontré plus haut. Le format complet de ce fichier, la validation des structures imbriquées et la documentation automatique qu'il alimente sont traités dans Valider les entrées avec argument_specs.
Le cycle complet
Section intitulée « Le cycle complet »Les trois cycles se sont déroulés en gardant l'instance vivante entre les étapes, ce qui est la bonne méthode pendant le développement. Le contrôle final se fait sur un environnement neuf, où la séquence entière s'exécute depuis la création jusqu'à la destruction.
molecule testLa sortie attendue enchaîne les étapes sans intervention, et deux lignes résument la santé du rôle.
INFO [default > converge] Executed: Successfulinstance : ok=3 changed=0 (idempotence)instance : ok=10 changed=0 (verify)INFO [default > destroy] Executed: SuccessfulLe changed=0 de l'étape d'idempotence prouve que rejouer le rôle ne modifie plus rien, et les dix tâches du verify couvrent les quatre lignes de la spécification initiale.
Pratiquer dans le lab
Section intitulée « Pratiquer dans le lab »Pièges courants
Section intitulée « Pièges courants »| Symptôme | Cause | Solution |
|---|---|---|
sudo: a password is required dès la collecte des facts | become: true dans un conteneur où l'on est déjà root, sans sudo installé | Retirer become: des playbooks du scénario |
| Test vert mais l'utilisateur ne peut pas se connecter | Le shell déclaré n'existe pas sur le système | Vérifier son existence avec stat, l'installer dans prepare.yml |
| Un utilisateur perd ses groupes après convergence | groups: sans append: true aligne l'appartenance sur la seule liste fournie | Ajouter append: true |
| Une régression passe inaperçue | Aucune assertion ne couvre le comportement perdu | Ajouter un test par garantie attendue |
Task failed: Action failed: Unknown error. | Un failed_when s'est réalisé, sans message associé | Lire failed_when_result, préférer assert et son fail_msg |
| L'étape d'idempotence échoue sur une commande de lecture | command se déclare modifiée par défaut | Ajouter changed_when: false |
À retenir
Section intitulée « À retenir »- Un test doit avoir échoué au moins une fois : sans rouge constaté, rien ne prouve qu'il vérifie quelque chose.
- Le rouge qui se déplace d'une assertion à la suivante est le signal de progression du cycle.
- Un test vert n'est pas une preuve de fonctionnement : interroger un état déclaré au lieu d'un effet réel laisse passer un shell inexistant.
append: trueévite qu'une convergence retire un utilisateur de ses autres groupes, et aucun test ne le signalera si vous ne l'écrivez pas.- Le refactor se valide par
changed=0au converge, qui atteste d'un état identique à l'état précédent. argument_specs.ymlarrive après la stabilité et bloque l'exécution avant toute modification du système.