Aller au contenu principal

Tester vos playbooks Ansible avec testinfra

· 10 minutes de lecture
Stéphane ROBERT
Consultant DevOps

logo

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"
end
end

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.1
rootdir: /home/ubuntu/Projets/ansible/templates/playbook
plugins: testinfra-6.1.0
collected 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_installed
E assert False
E + 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_running
E assert False
E + 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 False
FAILED 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.1
rootdir: /home/ubuntu/Projets/ansible/templates/playbook
plugins: testinfra-6.1.0
collected 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/python3
cachedir: .pytest_cache
rootdir: /home/vagrant/Projets/work/ansible/formation-ansible/testinfra
plugins: testinfra-6.8.0
collected 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 host
sudo 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/python3
cachedir: .pytest_cache
rootdir: /home/vagrant/Projets/work/ansible/formation-ansible/testinfra
plugins: testinfra-6.8.0
collected 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 False
E + 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 info
FAILED 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