Tester vos playbooks Ansible avec testinfra
Dans le monde de l’automatisation des infrastructures, où la moindre erreur peut se propager à grande échelle en quelques secondes, la fiabilité et la sécurité deviennent des piliers incontournables. Mais comment s’assurer que nos playbooks Ansible, ces précieuses recettes qui orchestrent nos environnements informatiques, sont à la hauteur de ces enjeux critiques ? La réponse réside dans une démarche rigoureuse de tests automatisés, une pratique encore trop souvent négligée dans le processus de développement.
Pourquoi Tester vos Playbooks Ansible avec TestInfra ?
Testinfra se présente comme le gardien de la qualité dans cet univers, offrant aux développeurs et aux ingénieurs DevOps un outil puissant pour écrire et exécuter des tests sur leurs infrastructures automatisées. En permettant de vérifier si les serveurs configurés répondent aux attentes, Testinfra aide à prévenir les défaillances avant qu’elles ne surviennent, garantissant ainsi que chaque déploiement reflète exactement nos intentions.
Comme pour les roles, je fais le choix de mettre en place du TDD ou Test Driven Development pour écrire mes playbooks Ansible, ce qui consiste à :
- Écrire un test.
- Vérifier qu’il échoue.
- Écrire le code pour faire passer ce test.
- Vérifier qu’il passe.
- Améliorer le code si-nécessaire, plus maintenable si besoin avant de reprendre un nouveau cycle.
Avant de commencer, vous pouvez consulter mon guide sur TestInfra.
Mise en place du lab
Testinfra est écrit en python et donc pour l’installer, on va utiliser pip
:
pip install pytest-testinfra --user
Je vais utiliser la structure suivante :
├── README.md├── Vagrantfile├── ansible.cfg├── files├── inventory│ └── test│ ├── group_vars│ │ ├── all│ │ │ └── vars.yaml│ │ ├── id100_name│ │ │ └── vars.yaml│ │ └── id200_name│ │ └── vars.yaml│ ├── host_vars│ │ └── hostname.yml│ └── hosts├── playbooks│ ├── first.yml│ └── provision-playbook.yml├── roles├── roles-requirements.yml├── tests│ └── webservers.py└── vars
Dans mon inventaire (inventories/hosts) j’ai ceci :
[all]
[www]host1
[www:vars]nginx_version="1.21.6"
[bdd]host2
Pour ceux qui ne connaissent pas Vagrant, je vous renvoie à mon billet sur vagrant. Sinon voici le contenu du Vagrantfile :
# -*- mode: ruby -*-# vi: set ft=ruby :ENV['VAGRANT_NO_PARALLEL'] = 'yes'Vagrant.configure("2") do |config| config.vm.provider :libvirt do |libvirt| libvirt.cpus = 1 libvirt.memory = 1024 end config.vm.boot_timeout = 600 config.vm.synced_folder "src/", "/test", disabled: true config.vm.box = "generic/ubuntu2204" config.hostmanager.enabled = true config.hostmanager.manage_host = true config.vm.define "host1" config.vm.define "host2" config.vm.provision "ansible" do |ansible| ansible.playbook = "playbooks/provision-playbook.yml" endend
Le playbook provision-playbook.yml
:
---- hosts: all gather_facts: no become: true
# Vagrant provison runs this file, so you don't actually need an inventory # it does that for you. # Basically we setup a bunch of environment stuff so we can ssh into the host # Using all the data from all.yml
tasks: - name: Allow password authentication lineinfile: path: /etc/ssh/sshd_config regexp: "^PasswordAuthentication" line: "PasswordAuthentication yes" state: present notify: restart sshd
- name: Set authorized key took from file authorized_key: user: vagrant state: present key: "{{ lookup('file', '/home/vagrant/.ssh/id_ed25519.pub') }}"
handlers: - name: restart sshd service: name: sshd state: restarted
On peut lancer le provisionnement :
vagrant up
Ecriture d’un premier jeu de tests
A la racine du projet créer un répertoire tests dans lequel nous allons déposer nos fichiers de tests.
Comme dit plus haut TestInfra est écrit en python et donc les tests reprennent la syntaxe python.
Mon fichier de test webservers.py :
def test_nginx_is_installed(host): nginx = host.package("nginx") assert nginx.is_installed assert nginx.version.startswith("1.21")
def test_nginx_running_and_enabled(host): nginx = host.service("nginx") assert nginx.is_running assert nginx.is_enabled
Cet exemple vérifie :
- que le package nginx :
- est installé
- et sa version est une 1.21.xxx
- que le service nginx :
- est activé
- est démarré
Lancement du test
Pour lancer le test, on utilise cette commande :
py.test -vv --hosts=webservers --connection=ansible --ansible-inventory=inventories/ --force-ansible tests/webservers.py
On limite le test au groupe webservers avec le fichier de test webservers.py
Vous devriez obtenir ce résultat :
==== test session starts ====platform linux -- Python 3.8.6, pytest-6.2.1, py-1.10.0, pluggy-0.13.1rootdir: /home/ubuntu/Projets/ansible/templates/playbookplugins: testinfra-6.1.0collected 2 items
tests/webservers.py [100%]
========= FAILURES ==========___ test_nginx_is_installed[ansible://host1] ____
host = <testinfra.host.Host ansible://host1>
def test_nginx_is_installed(host): nginx = host.package("nginx")> assert nginx.is_installedE assert FalseE + where False = <package nginx>.is_installed
tests/webservers.py:3: AssertionError test_nginx_running_and_enabled[ansible://host1]
host = <testinfra.host.Host ansible://host1>
def test_nginx_running_and_enabled(host): nginx = host.service("nginx")> assert nginx.is_runningE assert FalseE + where False = <service nginx>.is_running
tests/webservers.py:9: AssertionError== short test summary info ==FAILED tests/webservers.py::test_nginx_is_installed[ansible://host1] - assert FalseFAILED tests/webservers.py::test_nginx_running_and_enabled[ansible://host1] - assert False===== 2 failed in 8.71s =====
Normal que ça plante puisqu’on a encore rien installé.
Ecriture du playbook
On écrit le playbook first.yml
pour installer nginx à partir du dépôt officiel
(pour récupérer des versions récentes) :
---- name: Testinfra hosts: www become: true tasks: - name: (Debian/Ubuntu) Add NGINX signing key ansible.builtin.apt_key: id: 573BFD6B3D8FBC641079A6ABABF5BD827BD9BF62 keyring: /usr/share/keyrings/nginx-archive-keyring.gpg url: https://nginx.org/keys/nginx_signing.key - name: Add repo ansible.builtin.apt_repository: filename: nginx repo: deb [signed-by=/usr/share/keyrings/nginx-archive-keyring.gpg] https://nginx.org/packages/mainline/ubuntu jammy nginx update_cache: true mode: 0644 state: present - name: Use nginx repo ansible.builtin.blockinfile: path: /etc/apt/preferences.d/99nginx create: true block: | Package: * Pin: origin nginx.org Pin: release o=nginx Pin-Priority: 900 mode: 0644 state: present
- name: Install nginx ansible.builtin.apt: name: "nginx={{ nginx_version }}*" state: present - name: Active nginx et le démarre ansible.builtin.service: name: nginx state: started enabled: true
On peut le lancer sur nos machines vagrant
:
ansible-playbook -i inventory playbooks/first.yml
Nginx est installé avec la version 1.21.6
Relance du test
On obtient cette fois ce résultat.
==== test session starts ====platform linux -- Python 3.8.6, pytest-6.2.1, py-1.10.0, pluggy-0.13.1rootdir: /home/ubuntu/Projets/ansible/templates/playbookplugins: testinfra-6.1.0collected 2 items
tests/webservers.py .. [100%]
==== 2 passed in 11.63s =====
Amélioration du jeu de test
On aimerait pouvoir vérifier que la version du package nginx installé est bien celle définie dans l’inventaire.
Nous allons utiliser une petite astuce permettant de récupérer les données de
l’inventaire dans notre test de version. Voici le contenu de la nouvelle version
de notre webservers.py
.
from packaging.specifiers import SpecifierSet
def test_nginx_is_installed(host): nginx = host.package("nginx") hostname = host.backend.get_hostname() inventory = host.backend.ansible_runner.inventory
version_waited = inventory['_meta']['hostvars'][hostname]['nginx_version'] vspec = SpecifierSet("~=%s.0"% version_waited) assert vspec.contains(host.package("nginx").version.split('~')[0]) assert nginx.is_installed
def test_nginx_running_and_enabled(host): nginx = host.service("nginx") assert nginx.is_running assert nginx.is_enabled
On lance à nouveau les tests, ça fonctionne.
py.test -vvv --hosts=www --connection=ansible --ansible-inventory=inventory/ --force-ansible tests/test.py
= test session starts ==platform linux -- Python 3.10.6, pytest-7.1.3, pluggy-1.0.0 -- /usr/bin/python3cachedir: .pytest_cacherootdir: /home/vagrant/Projets/work/ansible/formation-ansible/testinfraplugins: testinfra-6.8.0collected 2 items
tests/test.py::test_nginx_is_installed[ansible://host1] PASSED [ 50%]tests/test.py::test_nginx_running_and_enabled[ansible://host1] PASSED [100%]
== 2 passed in 7.67s ===
Connectez-vous sur le host1
en ssh et forcer la mise à jour :
ssh hostsudo apt dist-upgrade -y
On relance les tests :
py.test -vvv --hosts=www --connection=ansible --ansible-inventory=inventory/ --force-ansible tests/test.py
= test session starts ==platform linux -- Python 3.10.6, pytest-7.1.3, pluggy-1.0.0 -- /usr/bin/python3cachedir: .pytest_cacherootdir: /home/vagrant/Projets/work/ansible/formation-ansible/testinfraplugins: testinfra-6.8.0collected 2 items
tests/test.py::test_nginx_is_installed[ansible://host1] FAILED [ 50%]tests/test.py::test_nginx_running_and_enabled[ansible://host1] PASSED [100%]
======= FAILURES =======__ test_nginx_is_installed[ansible://host1] __
host = <testinfra.host.Host ansible://host1>
def test_nginx_is_installed(host): nginx = host.package("nginx") inventory = host.backend.ansible_runner.inventory hostname = host.backend.get_hostname() version_waited = inventory['_meta']['hostvars'][hostname]['nginx_version'] vspec = SpecifierSet("~=%s.0"% version_waited)> assert vspec.contains(host.package("nginx").version.split('~')[0])E AssertionError: assert FalseE + where False = <bound method SpecifierSet.contains of <SpecifierSet('~=1.21.6.0')>>('1.23.2-1')E + where <bound method SpecifierSet.contains of <SpecifierSet('~=1.21.6.0')>> = <SpecifierSet('~=1.21.6.0')>.contains
tests/test.py:9: AssertionError--- Captured log call ---DEBUG testinfra:base.py:293 RUN CommandResult(command=b"ansible --tree /tmp/tmpsnremx1o -i inventory/ -m shell --args 'uname -s' host1", exit_status=0, stdout=b'host1 | CHANGED | rc=0 >>\nLinux\n', stderr=None)INFO testinfra:ansible.py:74 RUN Ansible('shell', 'uname -s', {'check': False}): {'ansible_facts': {'discovered_interpreter_python': '/usr/bin/python3'}, 'changed': True, 'cmd': 'uname -s', 'delta': '0:00:00.003646', 'end': '2022-10-21 14:47:04.155791', 'msg': '', 'rc': 0, 'start': '2022-10-21 14:47:04.152145', 'stderr': '', 'stderr_lines': [], 'stdout': 'Linux', 'stdout_lines': ['Linux']}DEBUG testinfra:base.py:293 RUN CommandResult(command='uname -s', exit_status=0, stdout='Linux', stderr='')DEBUG testinfra:base.py:293 RUN CommandResult(command=b"ansible --tree /tmp/tmpsjawaoyo -i inventory/ -m shell --args 'lsb_release -a' host1", exit_status=0, stdout=b'host1 | CHANGED | rc=0 >>\nDistributor ID:\tUbuntu\nDescription:\tUbuntu 22.04.1 LTS\nRelease:\t22.04\nCodename:\tjammyNo LSB modules are available.\n', stderr=None)INFO testinfra:ansible.py:74 RUN Ansible('shell', 'lsb_release -a', {'check': False}): {'ansible_facts': {'discovered_interpreter_python': '/usr/bin/python3'}, 'changed': True, 'cmd': 'lsb_release -a', 'delta': '0:00:00.040099', 'end': '2022-10-21 14:47:05.060485', 'msg': '', 'rc': 0, 'start': '2022-10-21 14:47:05.020386', 'stderr': 'No LSB modules are available.', 'stderr_lines': ['No LSB modules are available.'], 'stdout': 'Distributor ID:\tUbuntu\n' 'Description:\tUbuntu 22.04.1 LTS\n' 'Release:\t22.04\n' 'Codename:\tjammy', 'stdout_lines': ['Distributor ID:\tUbuntu', 'Description:\tUbuntu 22.04.1 LTS', 'Release:\t22.04', 'Codename:\tjammy']}DEBUG testinfra:base.py:293 RUN CommandResult(command='lsb_release -a', exit_status=0, stdout='Distributor ID:\tUbuntu\nDescription:\tUbuntu 22.04.1 LTS\nRelease:\t22.04\nCodename:\tjammy', stderr='No LSB modules are available.')DEBUG testinfra:base.py:293 RUN CommandResult(command=b"ansible --tree /tmp/tmp5j8gmryw -i inventory/ -m shell --args 'uname -m' host1", exit_status=0, stdout=b'host1 | CHANGED | rc=0 >>\nx86_64\n', stderr=None)INFO testinfra:ansible.py:74 RUN Ansible('shell', 'uname -m', {'check': False}): {'ansible_facts': {'discovered_interpreter_python': '/usr/bin/python3'}, 'changed': True, 'cmd': 'uname -m', 'delta': '0:00:00.003295', 'end': '2022-10-21 14:47:06.034448', 'msg': '', 'rc': 0, 'start': '2022-10-21 14:47:06.031153', 'stderr': '', 'stderr_lines': [], 'stdout': 'x86_64', 'stdout_lines': ['x86_64']}DEBUG testinfra:base.py:293 RUN CommandResult(command='uname -m', exit_status=0, stdout='x86_64', stderr='')DEBUG testinfra:base.py:293 RUN CommandResult(command=b'ansible --tree /tmp/tmpaz_4v9t2 -i inventory/ -m shell --args \'dpkg-query -f \'"\'"\'${Status} ${Version}\'"\'"\' -W nginx\' host1', exit_status=0, stdout=b'host1 | CHANGED | rc=0 >>\ninstall ok installed 1.23.2-1~jammy\n', stderr=None)INFO testinfra:ansible.py:74 RUN Ansible('shell', "dpkg-query -f '${Status} ${Version}' -W nginx", {'check': False}): {'ansible_facts': {'discovered_interpreter_python': '/usr/bin/python3'}, 'changed': True, 'cmd': "dpkg-query -f '${Status} ${Version}' -W nginx", 'delta': '0:00:00.012768', 'end': '2022-10-21 14:47:06.872994', 'msg': '', 'rc': 0, 'start': '2022-10-21 14:47:06.860226', 'stderr': '', 'stderr_lines': [], 'stdout': 'install ok installed 1.23.2-1~jammy', 'stdout_lines': ['install ok installed 1.23.2-1~jammy']}DEBUG testinfra:base.py:293 RUN CommandResult(command="dpkg-query -f '${Status} ${Version}' -W nginx", exit_status=0, stdout='install ok installed 1.23.2-1~jammy', stderr='')=== short test summary infoFAILED tests/test.py::test_nginx_is_installed[ansible://host1] - AssertionError: assert False== 1 failed, 1 passed in 7.70s ==
Mince la version 1.23.2 n’est pas celle que présente l’inventaire (qui est notre source de vérité). Notre configuration a drifté. Plus qu’à l’intégrer à votre CI pour tout contrôler de temps en temps. Il faudra écrire plein d’autres tests :
- comme l’absence de package qui ne devrait pas être présent comme tcpdump, …
- des droits sur des dossiers critiques
- des configurations durcies
- …
Vraiment cool non ?
Conclusion
Les tests automatisés, loin d’être un luxe ou une après-pensée, s’avèrent être un investissement essentiel pour tout projet d’automatisation. Ils offrent une assurance contre les erreurs humaines et les régressions involontaires, garantissant que chaque changement apporté à vos infrastructures est validé contre un ensemble de critères bien définis avant d’être déployé.
En adoptant Testinfra aux côtés d’Ansible, vous bénéficiez d’une synergie puissante qui élève la qualité et la robustesse de votre automatisation à de nouveaux sommets. Les exemples et bonnes pratiques partagés ici visent à vous équiper des connaissances nécessaires pour intégrer efficacement les tests dans vos projets, vous permettant ainsi de construire des infrastructures plus sûres et plus résilientes.
Enfin, je vous encourage à ne pas voir les tests comme une corvée ou un obstacle, mais comme une opportunité d’apprendre, d’innover et d’améliorer continuellement vos pratiques d’automatisation. Partagez vos expériences, vos succès et vos défis dans les commentaires ou avec la communauté. La collaboration et le partage de connaissances sont des piliers de l’amélioration continue et de la réussite dans le monde de l’automatisation.
Plus loin
Si vous voulez plus de tutorials Ansible je vous renvoie sur le billet de l’introduction à ansible