Loading search data...

Ansible - Tester vos playbooks avec testinfra

Publié le : 19 janvier 2021 | Mis à jour le : 27 juin 2023

logo

La 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

Mots clés :

devops ansible tutorials infra as code formation ansible

Si vous avez apprécié cet article de blog, vous pouvez m'encourager à produire plus de contenu en m'offrant un café sur  Ko-Fi. Vous pouvez aussi passer votre prochaine commande sur amazon, sans que cela ne vous coûte plus cher, via  ce lien . Vous pouvez aussi partager le lien sur twitter ou Linkedin via les boutons ci-dessous. Je vous remercie pour votre soutien.

Autres Articles


Commentaires: