Ansible - Tester vos playbooks avec testinfra
Publié le : 19 janvier 2021 | Mis à jour le : 27 juin 2023La suite de la formation Ansible: les tests
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.
Installation de testinfra
Testinfra est écrit en python et donc pour l’installer, on va utiliser pip
:
pip install pytest-testinfra
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
Ajout d’un inventaire
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/", "/srv/website", 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_rsa.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 FF [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éfini 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
En fait, on utilise les méthodes et les données de la backend de testinfra. Comme je n’ai pas trouvé la documentation, je suis allé fouiller dans l’objet host depuis la console python :
python3
Python 3.10.6 (main, Aug 10 2022, 11:40:04) [GCC 11.3.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> import testinfra
>>> host = testinfra.get_host("ansible://localhost?ansible_connection=local", sudo=True)
>>> from pprint import pprint
>>> pprint(host.__dict__)
{'backend': <testinfra.backend.ansible.AnsibleBackend object at 0x7f974cdeb0a0>}
>>>
print(dir(host.backend))
['HAS_RUN_ANSIBLE', 'HAS_RUN_SALT', 'NAME', '__abstractmethods__', '__class__', '__delattr__', '__dict__', '__dir__', '__doc__', '__eq__', '__format__', '__ge__', '__getattribute__', '__gt__', '__hash__', '__init__', '__init_subclass__', '__le__', '__lt__', '__module__', '__ne__', '__new__', '__reduce__', '__reduce_ex__', '__repr__', '__setattr__', '__sizeof__', '__str__', '__subclasshook__', '__weakref__', '_abc_impl', '_encoding', '_host', 'ansible_inventory', 'ansible_runner', 'decode', 'encode', 'encoding', 'force_ansible', 'get_command', 'get_connection_type', 'get_encoding', 'get_hostname', 'get_hosts', 'get_pytest_id', 'get_sudo_command', 'get_variables', 'host', 'hostname', 'parse_containerspec', 'parse_hostspec', 'quote', 'result', 'run', 'run_ansible', 'run_local', 'set_host', 'ssh_config', 'ssh_identity_file', 'sudo', 'sudo_user']
>>>
host.backend.get_hostname()
'localhost'
Donc à partir de là, je suis allé sur l’ami google et en cherchant avec testinfra, ansible et get_hostname comme mots clé, j’ai trouvé le code source et quelques exemples.
On lance à nouveau les tests ca 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
- …
Vraiment cool non ?
Plus loin
Si vous voulez plus de tutorials Ansible je vous renvoie sur le billet de l'introduction à ansible