Aller au contenu
Infrastructure as Code medium

Cycle TDD complet : développer un rôle users avec Molecule

21 min de lecture

Logo Ansible

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.

  • Écrire verify.yml avant tasks/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: true change, en observant l'état réel du système.
  • Verrouiller les entrées du rôle une fois son comportement stabilisé.

É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.

  1. Créer une liste d'utilisateurs Linux, fournie par la variable users_to_create.
  2. Chaque utilisateur peut demander un shell: particulier.
  3. Chaque utilisateur peut appartenir à des groups: secondaires.
  4. 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.

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.

Fenêtre de terminal
mkdir -p roles && cd roles
ansible-galaxy role init users --offline
cd users
molecule init scenario

Le 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.

meta/main.yml
galaxy_info:
namespace: demo
role_name: users

Le 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.

molecule/default/converge.yml
---
- name: Converge
hosts: all
vars:
users_to_create:
- name: alice
shell: /bin/zsh
groups:
- wheel
- name: bob
- name: carol
roles:
- role: users

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.

Fenêtre de terminal
$ podman exec instance id
uid=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 info
sudo: a password is required

L'é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.

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.

molecule/default/verify.yml
---
- 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.

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.

Fenêtre de terminal
molecule converge # le rôle est vide, rien ne se passe
molecule verify # rouge, alice n'existe pas

Le 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.

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.

tasks/main.yml
---
- name: Créer les utilisateurs
ansible.builtin.user:
name: "{{ item.name }}"
state: present
loop: "{{ users_to_create }}"
Fenêtre de terminal
molecule converge # crée alice, bob et carol
molecule verify # rouge, mais plus loin qu'avant

Le 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",

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 }}"
Fenêtre de terminal
molecule converge
molecule verify # vert

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.

Fenêtre de terminal
$ podman exec instance getent passwd alice
alice:x:1000:1000::/home/alice:/bin/zsh
$ podman exec instance ls -l /bin/zsh
ls: cannot access '/bin/zsh': No such file or directory

Alice 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.

molecule/default/prepare.yml
---
- name: Prepare
hosts: all
tasks:
- name: Installer les shells attendus par les tests
ansible.builtin.dnf:
name: zsh
state: present

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"
Fenêtre de terminal
molecule verify # rouge, alice n'est dans aucun groupe secondaire

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.

Fenêtre de terminal
# alice appartient à adm avant le passage du rôle
$ podman exec instance id alice
uid=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 alice
uid=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.

Fenêtre de terminal
molecule converge
molecule verify # vert

Le 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.

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.

defaults/main.yml
---
users_default_shell: /bin/bash
users_create_home: true
users_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.

tasks/main.yml
---
- 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.

Fenêtre de terminal
molecule converge # ok=2 changed=0, aucun effet de bord
molecule verify # vert, le refactor n'a rien cassé
molecule idempotence # changed=0

Le 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.

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.

meta/argument_specs.yml
---
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/bash

L'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=1

Le 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.

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.

Fenêtre de terminal
molecule test

La sortie attendue enchaîne les étapes sans intervention, et deux lignes résument la santé du rôle.

INFO [default > converge] Executed: Successful
instance : ok=3 changed=0 (idempotence)
instance : ok=10 changed=0 (verify)
INFO [default > destroy] Executed: Successful

Le 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.

SymptômeCauseSolution
sudo: a password is required dès la collecte des factsbecome: 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 connecterLe shell déclaré n'existe pas sur le systèmeVérifier son existence avec stat, l'installer dans prepare.yml
Un utilisateur perd ses groupes après convergencegroups: sans append: true aligne l'appartenance sur la seule liste fournieAjouter append: true
Une régression passe inaperçueAucune assertion ne couvre le comportement perduAjouter 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 lecturecommand se déclare modifiée par défautAjouter changed_when: false
  • 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=0 au converge, qui atteste d'un état identique à l'état précédent.
  • argument_specs.yml arrive après la stabilité et bloque l'exécution avant toute modification du système.

Ce site vous est utile ?

Sachez que moins de 1% des lecteurs soutiennent ce site.

Je maintiens +700 guides gratuits, sans pub ni tracking. Un soutien, même symbolique, m'aide à couvrir l'hébergement et à garder ces ressources gratuites. Merci pour votre appui.

Le formulaire ne s'affiche pas ? Ouvrir Ko-fi dans un onglet.

Abonnez-vous et suivez mon actualité DevSecOps sur LinkedIn