Introduction aux Ansible Content Collections

le 19/11/2019 par Rémi Rey
Tags: Software Engineering

On ne présente plus Ansible, un outil de configuration management et de provisionning Open Source qui se démarque pour sa souplesse, la simplicité de son architecture et de son utilisation.

La version 2.9 introduit un nouveau concept (précédemment en tech preview) qui touche au mécanisme de distribution de contenu : Les collections.

Quels problèmes sont résolus avec les collections ? Comment en tant que développeur de playbook vais-je utiliser ce nouveau concept ? Je vous propose un petit tour d’horizon qui vous permettra de mieux comprendre les collections Ansible ainsi qu’une présentation de la commande ansible-test qui permet de tester le contenu d’une collection.

Le challenge des mainteneurs

Si l’on regarde le projet sur Github, on peut facilement voir que la somme de travail de l’équipe de Redhat est conséquente :

Plus de 4 000 issues ouvertes et 2 000 Pull Requests à traiter au moment de l’écriture de ces lignes.

Derrière ces chiffres se cache une organisation centralisée du code Ansible. Toutes les contributions au projet passent par un seul et même repository, il n’y a pas de séparation entre le cœur d’Ansible et les contributions de la communauté qui étendent l’outil avec divers modules.

En comparaison, un projet comme Terraform d’Hashicorp a vu ses sources se séparer entre le cœur de l’outil et les providers. Les providers peuvent donc suivre leur propre cycle de vie et les correctifs être déployés fréquemment. Le projet du cœur du produit est donc libéré (délivré) du “bruit” lié aux contributions de la communauté.

En effectuant une recherche dans le repository Ansible, sur les presque 4 000 modules disponibles, pas loin de 3 000 modules sont marqués comme supportés par la communauté !

Les mainteneurs du projets ont donc cherché un moyen de faire basculer les contributions de la communauté en dehors du projet principal (ansible/ansible) afin de permettre aux modules et autres plugins d’avoir un cycle de release plus rapide et indépendant vis à vis du cœur Ansible.

La solution choisie se tourne vers Ansible Galaxy et un nouveau format de distribution supporté par Galaxy : les collections. Le plan annoncé ici consiste à faire sortir les contributions de la communauté du dépôt principal pour les transformer en collections dont les sources seront dans un dépôt différent.

Il sera donc impossible de parler d’Ansible sans évoquer les collections dans un avenir très proche, et nous pouvons commencer à manipuler ce nouveau concept dès la version 2.9 d’Ansible.

Je n’irai pas plus loin concernant la stratégie de déplacement des sources des modules “community” dans cet article. La stratégie des mainteneurs n’étant pas encore communiquée. Cependant l’objectif reste d’avoir Ansible “battery included” suite à la commande pip install ansible.

C’est quoi une collection ?

Les collections sont un simple format de distribution de contenu pour Ansible Galaxy. Dans les faits, une collection est une grosse archive compressée qui contient votre contenu dans une arborescence stricte :

collection/
├── docs/
├── galaxy.yml
├── plugins/
│   ├── modules/
│   │   └── module1.py
│   ├── inventory/
│   └── .../
├── README.md
├── roles/
│   ├── role1/
│   ├── role2/
│   └── .../
├── playbooks/

On y retrouve un répertoire roles dans lequel les utilisateurs de Galaxy ayant déjà publié un rôle pourront placer leur code et retrouver leurs habitudes. La nouveauté se trouve évidemment dans la présence de répertoires visant à accueillir le nouveau contenu distribuable sur Galaxy, parmis les plus intéressants on trouvera :

  • plugins/modules : pour vos modules.
  • plugins/inventory : pour vos plugins d’inventaire.
  • plugins/filter : pour vos filtres.
  • plugins/module_utils : pour le code commun à vos plugins !

Une collection vous permettra donc de créer une suite de composants utilisables avec Ansible. Les filtres et modules ne seront plus liés à un rôle mais utilisables n’importe où dès le moment où la collection sera installée. La présence du répertoire plugins/module_utils permettra également de factoriser le code (Python vraisemblablement) entre les différents composants de la collection ce qui facilitera la maintenance pour l’auteur de la collection.

Créer une collection

Pour illustrer l’utilisation des collections nous allons créer une collection contenant un rôle et un filtre et nous tenterons d’utiliser ces deux composants dans un playbook. Le rôle et le filtre seront des plus inutiles et ne serviront qu’à illustrer l’utilisation d’une collection.

Commençons par créer un répertoire de travail en créant un répertoire ansible_collections :

$ mkdir ansible_collections

Le nom du répertoire n’est pas anodin, Ansible sera très regardant sur l’arborescence menant à une collection et vos collections devront impérativement être placées dans un répertoire ansible_collections !

Dans la suite de l’article nous partirons du principe que les commandes doivent être exécutées depuis ce répertoire.

Pour faciliter la vie aux utilisateurs, la commande ansible-galaxy a été enrichie d’une sous-commande collection :

$ ansible-galaxy collection --help
usage: ansible-galaxy collection [-h] COLLECTION_ACTION ...

positional arguments:
COLLECTION_ACTION
init Initialize new collection with the base structure of a
collection.
build Build an Ansible collection artifact that can be publish
to Ansible Galaxy.
publish Publish a collection artifact to Ansible Galaxy.
install Install collection(s) from file(s), URL(s) or Ansible
Galaxy

optional arguments:
-h, --help show this help message and exit

La commande init nous permet de créer l’arborescence de base d’une collection :

$ ansible-galaxy collection init rrey.my_collection
- Collection rrey.my_collection was created successfully

Le nom de la collection doit respecter le format ., ces deux notions permettent de faire de la classification sur votre compte Ansible Galaxy.

Nous nous retrouvons donc avec l’arborescence suivante :

rrey/
└── my_collection
├── README.md
├── docs
├── galaxy.yml
├── plugins
│   ├── README.md
├── roles

Créons maintenant un rôle idiot qui affichera simplement un message de debug :

$ ansible-galaxy role init idiot
- Role idiot was created successfully

Il devient important d’utiliser la commande ansible-galaxy pour créer la structure du role car la commande va créer les fichiers attendus pour une publication sur Galaxy. Après une rapide modification du fichier meta/main.yml nous pouvons écrire notre rôle :


# rrey/my_collection/roles/idiot/tasks/main.yml
---

- name: mon debug
  debug:
    msg: "{{ ansible_default_ipv4.address }}"

...

Le rôle affichera simplement l’adresse ipv4 de la machine cible dans une tâche de debug. Créons également un filtre qui ne fera rien de plus intelligent mais qui nous servira à illustrer l’utilisation d’un filtre présent dans notre collection :


# rrey/my_collection/plugins/filter/my_custom_filters.py

from __future__ import (absolute_import, division, print_function)
__metaclass__ = type


def split_ip(value):
    return value.split(".")


class FilterModule(object):

    def filters(self):
        return {
            'split_ip': split_ip
        }

Ce fichier permet de créer un filtre jinja2 appelé split_ip qui nous renverra les chiffres composants une address IP sous forme de liste.

Revenons à notre rôle et utilisons ce filtre :


# rrey/my_collection/roles/idiot/tasks/main.yml
---

- name: mon debug
  debug:
    msg: "{{ ansible_default_ipv4.address | rrey.my_collection.split_ip }}"

...

Nous rencontrons la première subtilité de l’utilisation d’une collection. Il est désormais nécessaire de préciser le namespace et le nom de la collection que nous utilisons : rrey.my_collection Cette forme d’appel est appelée Fully Qualified Collection Name (FQCN).

Nous avons maintenant une collection avec un filtre et un rôle, essayons de tester l’utilisation de cette collection.

Testons l’utilisation de notre collection

Afin de pouvoir utiliser notre collection nous devons préciser à Ansible son emplacement car nous n’avons pas créé celle-ci dans l’un des emplacements reconnu par défaut :

  • $HOME/.ansible/collections
  • /usr/share/ansible/collections

Note : Vous remarquerez que ces emplacements par défaut ne contiennent pas le fameux répertoire ansible_collections auquel je fais référence en début d’article. Il sera pourtant bien créé par la commande d’installation que nous verrons un peu plus tard dans l’article. La documentation n’est actuellement pas très explicite sur le côté indispensable de ce répertoire et méritera une petite mise à jour.

Nous pouvons utiliser deux méthodes pour ajouter un chemin vers des collections :

  • A travers ansible.cfg en ajoutant le paramètre collections_paths dans la section defaults
  • A travers la variable d’environnement ANSIBLE_COLLECTIONS_PATHS

Les 2 méthodes nécessitent de renseigner la liste complètes des emplacements de collections. J’ai choisis d’utiliser ansible.cfg pour cet article, nous devons donc créer le fichier ansible.cfg dans notre répertoire avec le contenu suivant:


# ansible.cfg
[defaults]
collections_paths = ~/.ansible/collections:/usr/share/ansible/collections:/Users/remi.rey

Dans mon exemple j’ai ajouté l’emplacement /Users/remi.rey car c’est l’endroit dans lequel j’ai créé le répertoire collections_paths en début d’article. N’oubliez pas d’adapter le chemin avec l’emplacement que vous avez utilisé sur votre poste.

Créons ensuite un playbook de test utilisant notre collection :


# test.yml

- hosts: localhost
  gather_facts: true
  tasks:
      - import_role:
          name: rrey.my_collection.idiot

Notre playbook est des plus simple, nous exécutons en local le rôle idiot en précisant le FQCN de la collection lors de l’appel du rôle.

L'exécution de notre playbook nous donne la sortie suivante :

$ ansible-playbook test.yml

PLAY [localhost] **************************************************************************************************************************

TASK [Gathering Facts] ********************************************************************************************************************
ok: [localhost]

TASK [idiot : mon debug] ******************************************************************************************************************
ok: [localhost] => {
    "msg": [
        "10",
        "103",
        "253",
        "109"
    ]
}

PLAY RECAP ********************************************************************************************************************************
localhost                  : ok=2    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

Nous avons bien le message de debug attendu renvoyant l’adresse de mon poste traité par mon filtre.

Une autre forme d’appel est possible dans notre playbook pour éviter la répétition du FQCN :


# test.yml

- hosts: localhost
  gather_facts: true
  collections:
    - rrey.my_collection
  tasks:
      - import_role:
          name: idiot

L’ajout du paramètre collections avec notre collection comme élément de liste nous permet de supprimer le namespace et le nom de la collection lors de l’appel du rôle.

Publication de notre collection

Maintenant que nous avons une collection (des plus inutiles, mais qui sert notre besoin de démo), nous pouvons réfléchir à la publier sur Ansible Galaxy. La première étape passe par la mise à jour du fichier galaxy.yml situé dans rrey/my_collection. Voici l’exemple minimal que nous utiliserons :


# rrey/my_collection/galaxy.yml

namespace: rrey
name: my_collection
version: 1.0.0
readme: README.md

authors:
- Rémi REY 

license:
- GPL-2.0-or-later
license_file: ''


tags: []
dependencies: {}
repository: http://example.com/repository
documentation: http://docs.example.com
homepage: http://example.com
issues: http://example.com/issue/tracker

Le fichier généré par la commande ansible-galaxy collection init contient de la documentation sous forme de commentaires que j’ai supprimés dans mon exemple pour rester concis.

Attention avec le versioning, une fois la collection publiée il sera impossible de supprimer votre version (seul le marquage deprecated sera possible). Ne faites pas comme dans mon exemple où je marque directement ma collection en version 1.0.0 pour la première publication, il sera de bon ton de passer par des versions expérimentale en 0.X.Y (0.0.1 par exemple).

Note : le paramètre dependencies permet de déclarer une dépendance avec une autre collection. Par exemple:


dependencies: {
    "geerlingguy.k8s": ">=0.9.0"
}

Nous allons maintenant construire le livrable attendu par Ansible Galaxy (une archive compressée) à l’aide de la commande ansible-galaxy :

$ cd rrey/my_collection
$ ansible-galaxy collection build
Created collection for rrey.my_collection at /Users/remi.rey/tmp/rrey/my_collection/rrey-my_collection-1.0.0.tar.gz

La collection n’est toujours pas publiée, mais l’archive est générée en local et les plus curieux pourront en afficher le contenu avec la commande tar :


$ tar tvzf rrey-my_collection-1.0.0.tar.gz
-rw-r--r-- 0 0      0         578 29 oct 17:27 MANIFEST.json
-rw-r--r-- 0 0      0        2237 29 oct 17:27 FILES.json
drwxr-xr-x  0 0      0           0 29 oct 15:46 plugins/
-rw-r--r-- 0 0      0         957 29 oct 15:22 plugins/README.md
drwxr-xr-x  0 0      0           0 29 oct 16:08 plugins/filter/
-rw-r--r-- 0 0      0         252 29 oct 15:59 plugins/filter/my_custom_filters.py
drwxr-xr-x  0 0      0           0 29 oct 16:22 roles/
drwxr-xr-x  0 0      0           0 29 oct 15:44 roles/idiot/
drwxr-xr-x  0 0      0           0 29 oct 16:09 roles/idiot/tasks/
-rw-r--r-- 0 0      0         113 29 oct 16:09 roles/idiot/tasks/main.yml
drwxr-xr-x  0 0      0           0 29 oct 15:13 docs/
-rw-r--r-- 0 0      0          76 29 oct 15:13 README.md

Nous retrouvons notre arborescence de collection, les répertoires de namespace et de nom de collection ayant disparus. Ceux-ci étant présents dans le nom de l’archive, ils ne sont pas nécessaires dans l’archive.

Des fichiers font leur apparition :

  • FILES.json : qui contient la liste des fichiers avec leur sha256.
  • MANIFEST.json : qui contient des métadonnées sur la collection, on y retrouvera les informations du fichier galaxy.yml.

Ne nous attardons pas plus longuement sur l’archive et allons publier notre collection ! Vous l’aurez probablement deviné, la commande ansible-galaxy va nous permettre de faire la publication :

$ ansible-galaxy collection publish ./rrey-my_collection-1.0.0.tar.gz --api-key votre_cle_d_api_a_recuperer_sur_galaxy
Publishing collection artifact '/Users/remi.rey/tmp/rrey/my_collection/rrey-my_collection-1.0.0.tar.gz' to default https://galaxy.ansible.com/api/
Collection has been published to the Galaxy server default https://galaxy.ansible.com/api/
Waiting until Galaxy import task https://galaxy.ansible.com/api/v2/collection-imports/502 has completed
Collection has been successfully published and imported to the Galaxy server default https://galaxy.ansible.com/api/

La commande nécessite une clé d’API en argument, cette clé est récupérable depuis les préférences de votre compte Ansible Galaxy.

Note : La création d’un compte Galaxy n’est possible que si vous possédez un compte Github.

Vous pouvez maintenant vous connecter sur Ansible Galaxy et vous rendre dans le menu “My Content” pour y voir apparaître votre collection:

On pourra remarquer qu’un score de qualité a été positionné sur notre collection ! Celui-ci est basé sur l'exécution des linters yamlint et ansible-lint sur notre code. Il est possible de voir le détail des tests depuis l’interface :

Il faudra donc faire le nécessaire pour avoir la meilleure note possible pour attirer des utilisateurs ou des mainteneurs !

La page principale de votre collection affichera des informations intéressantes comme le nombre de téléchargements et les liens vers le repo Github, la doc, le tracker d’issues ou le site du projet. Ces informations sont toutes issues du fichier galaxy.yml, d’où l’importance de le compléter soigneusement :

Utilisation d’une collection venant de Galaxy

Maintenant que nous avons une collection sur Galaxy, plaçons nous dans la peau d’un utilisateur.

Créons un nouveau répertoire avec notre playbook de test. Le fait de changer de répertoire fera disparaître la détection automatique de la collection en locale.

$ mkdir galaxy-test
$ cp test.yml galaxy-test/
$ cd galaxy-test/

Installons maintenant la collection comme un utilisateur lambda d’Ansible Galaxy :

$ ansible-galaxy collection install rrey.my_collection
Process install dependency map
Starting collection install process
Installing 'rrey.my_collection:1.0.0' to '/Users/remi.rey/.ansible/collections/ansible_collections/rrey/my_collection'

La commande install ne demande que le nom de la collection sous sa forme. Comme nous pouvons le voir sur la sortie standard, la collection a été copiée dans $HOME/.ansible/collections/ansible_collections/

Il est également possible de préciser la version de la collection dans la commande install :

$ ansible-galaxy collection install rrey.my_collection:1.0.0

Nous devrions donc a priori être capable d’exécuter le playbook sans opération supplémentaire :

$ ansible-playbook test.yml

PLAY [localhost] **************************************************************************************************************************

TASK [Gathering Facts] ********************************************************************************************************************
ok: [localhost]

TASK [idiot : mon debug] ******************************************************************************************************************
ok: [localhost] => {
    "msg": [
        "10",
        "103",
        "253",
        "109"
    ]
}

PLAY RECAP ********************************************************************************************************************************
localhost                  : ok=2    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

Tout fonctionne ! L’installation de collections peut également se faire à travers un fichier requirements.yml :


---
collections:
- name: rrey.my_collection
  version: 1.0.0

Et la commande d’installation devient :

$ ansible-galaxy collection install -r requirements.yml

On retrouvera donc les mécanismes existant avec l’utilisation de rôle sur Galaxy.

Les tests (c’est la vie) !

Nous n’avons pas parlé de tests jusqu’à présent et Ansible 2.9 introduit une nouvelle commande bien connue des contributeurs au projet Ansible : ansible-test

ansible-test est l’outil qui permet l'exécution des tests sur le projet ansible/ansible et celui-ci mériterait un article à lui seul pour couvrir toutes ses fonctionnalités mais cette présentation de Matt Clay à l’AnsibleFest Atlanta 2019 permet d’avoir un bon aperçu. A voir absolument ! (Si un article vous intéresse sur ce sujet, envoyez-moi une boite de chocolat au siège d’OCTO Technology) L’outil va nous permettre de jouer des tests unitaires (avec unittest et pytest) et des tests d’intégration (avec des rôles Ansible) mais également des tests syntaxiques (pep8, yamllint).

Rajoutons donc quelques tests sur le contenu de notre collection, en nous concentrant sur le filtre.

Tests unitaires

Commençons par la base de la pyramide de tests, les tests unitaires. Créons tout d’abord l’arborescence attendue par ansible-test pour un test unitaire :

$ cd rrey/my_collection/
$ mkdir -p tests/unit/plugins/filter/

l’arborescence est importante car elle permet à ansible-test de retrouver les différents types de tests supportés (units, sanity, integration etc ...). La commande help de ansible-test nous permet de voir cette liste:

$ ansible-test --help
usage: ansible-test [-h] COMMAND ...

positional arguments:
  COMMAND
    integration        posix integration tests
    network-integration
                       network integration tests
    windows-integration
                       windows integration tests
    units              unit tests
    sanity             sanity tests
    shell              open an interactive shell
    coverage           code coverage management and reporting
    env                show information about the test environment

optional arguments:
  -h, --help           show this help message and exit

Note : Attention au piège avec ansible 2.9.0, la commande ansible-test --help ne fonctionne que dans le répertoire d’une collection, soit rrey/my_collection dans mon exemple. C’est un bug, qui est reporté ici.

Avec cette arborescence, nous pouvons écrire notre premier test unitaire :


# tests/unit/plugins/filter/test_my_custom_filters.py

import unittest
from ansible_collections.rrey.my_collection.plugins.filter.my_custom_filters import split_ip


class TestMyFilter(unittest.TestCase):

    def test_filter_can_split_an_ip(self):

        # Given
        filter_input = "10.0.0.1"
        # When
        result = split_ip(filter_input)
        # Then
        self.assertEqual(result, ["10", "0", "0", "1"])

Je teste ici le cas nominal en appelant la fonction qui implémente mon filtre split_ip avec une chaîne contenant une adresse IP valide. Exécutons la commande permettant d'exécuter le test :


$ cd rrey/my_collection
$ ansible-test units --docker
[...]
Unit test with Python 3.7
============================= test session starts ==============================
platform linux -- Python 3.7.4, pytest-5.2.1, py-1.8.0, pluggy-0.13.0
rootdir: /root/ansible/ansible_collections/rrey/my_collection/tests/unit/plugins/filter, inifile: /root/ansible/test/lib/ansible_test/_data/pytest.ini
plugins: forked-1.1.1, mock-1.11.1, f5-sdk-3.0.21, xdist-1.30.0
gw0 I / gw1 I
gw0 [1] / gw1 [1]

.
- generated xml file: /root/ansible/ansible_collections/rrey/my_collection/tests/output/junit/python3.7-units.xml -
============================== 1 passed in 1.15s ===============================
Unit test with Python 3.8
============================= test session starts ==============================
platform linux -- Python 3.8.0, pytest-5.2.1, py-1.8.0, pluggy-0.13.0
rootdir: /root/ansible/ansible_collections/rrey/my_collection/tests/unit/plugins/filter, inifile: /root/ansible/test/lib/ansible_test/_data/pytest.ini
plugins: forked-1.1.1, mock-1.11.1, f5-sdk-3.0.21, xdist-1.30.0
gw0 I / gw1 I
gw0 [1] / gw1 [1]

.
- generated xml file: /root/ansible/ansible_collections/rrey/my_collection/tests/output/junit/python3.8-units.xml -
============================== 1 passed in 1.23s ===============================

L’option --docker permet de demander à ansible-test de jouer le test dans un container docker, ce qui me permettra de toujours exécuter mon test dans un environnement isolé. En 2019 avec docker, nous ne devrions plus entendre “ça marche sur mon poste” dans les open spaces.

Non seulement mon test est validé mais nous pouvons voir qu’il a été exécuté 5 fois avec des interpréteurs différents (Python 2.7, 3.5, 3.6, 3.7 et 3.8). (J’ai volontairement supprimé une partie de la sortie standard renvoyée par la commande)

Si vous vous demandiez ce qu’apporterait ansible-test à votre environnement d’Intégration Continue, vous devriez avoir un début de réponse ; une seule commande permet de lancer les tests avec les 5 interpréteurs Python supportés officiellement par Ansible.

On remarquera au passage qu’un rapport de test Junit au format XML est généré pour chaque exécution. Il sera donc facile d’avoir un rapport consultable depuis une belle interface web avec des outils compatible (Gitlab et Jenkins en autres).

Si j’avais développé mon filtre en TDD, j’aurais pu tout de suite remarquer que mon filtre n’allait pas très bien réagir si je lui donnais autre chose qu’une chaîne en input et ajouter le test suivant :


import unittest
from ansible.module_utils._text import to_bytes
from ansible_collections.rrey.my_collection.plugins.filter.my_custom_filters import split_ip


class TestMyFilter(unittest.TestCase):

    def test_filter_can_split_an_ip(self):
        filter_input = "10.0.0.1"
        result = split_ip(filter_input)
        self.assertEqual(result, ["10", "0", "0", "1"])

    def test_filter_with_invalid_input(self):
        filter_input = 42
        with self.assertRaises(AnsibleError) as result:
            split_ip(filter_input)
        self.assertEqual(result.exception.args[0], "split_ip expects a str type argument, got int")

En relançant mon test j’obtiens l’erreur suivante :


[...]
============================= test session starts ==============================
platform linux2 -- Python 2.7.15+, pytest-4.6.6, py-1.8.0, pluggy-0.13.0
rootdir: /root/ansible/ansible_collections/rrey/my_collection/tests/unit/plugins/filter, inifile: /root/ansible/test/lib/ansible_test/_data/pytest.ini
plugins: forked-1.1.1, mock-1.11.1, f5-sdk-3.0.21, xdist-1.30.0
gw0 I / gw1 I
gw0 [2] / gw1 [2]

.F
=================================== FAILURES ===================================
_________________ TestMyFilter.test_filter_with_invalid_input __________________
[gw1] linux2 -- Python 2.7.15 /tmp/python-f9trp1k5-ansible/python
self = 

    def test_filter_with_invalid_input(self):
        filter_input = 42
>       result = split_ip(filter_input)

tests/unit/plugins/filter/test_my_custom_filters.py:15:
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _

value = 42

    def split_ip(value):
>       return value.split(".")
E       AttributeError: 'int' object has no attribute 'split'

plugins/filter/my_custom_filters.py:6: AttributeError
[...]

Si ma collection est destinée à être publiée sur Galaxy, autant faire en sorte que les utilisateurs finaux n’aient pas à lire les exceptions Python pour comprendre le crash du filtre. Modifions notre filtre pour améliorer son fonctionnement :


from __future__ import (absolute_import, division, print_function)
from ansible.errors import AnsibleFilterError
__metaclass__ = type


def split_ip(value):
    if not isinstance(value, str):
        raise AnsibleFilterError("split_ip expects a str type argument, got %s" % type(value).__name__)
    return value.split(".")


class FilterModule(object):

    def filters(self):
        return {
            'split_ip': split_ip
        }

En relançant ma commande ansible-test, j’ai bien mes 2 tests qui passent maintenant :


[...]
============================= test session starts ==============================
platform linux -- Python 3.8.0, pytest-5.2.1, py-1.8.0, pluggy-0.13.0
rootdir: /root/ansible/ansible_collections/rrey/my_collection/tests/unit/plugins/filter, inifile: /root/ansible/test/lib/ansible_test/_data/pytest.ini
plugins: forked-1.1.1, mock-1.11.1, f5-sdk-3.0.21, xdist-1.30.0
gw0 I / gw1 I
gw0 [2] / gw1 [2]

..
- generated xml file: /root/ansible/ansible_collections/rrey/my_collection/tests/output/junit/python3.8-units.xml -
============================== 2 passed in 1.27s ===============================

Nous pourrions encore améliorer notre filtre, car il pourrait vérifier avec une regex que la chaîne correspond bien à une forme d’adresse IP, mais ce n’est pas l’objectif de l’article.

Tests d’intégration

Avec ansible-test les tests d'intégration sont réalisés à l’aide de rôles Ansible. Nous allons écrire un rôle qui fera appel à notre filtre et nous ferons des assertions après chaque appel pour vérifier que le filtre a renvoyé ce qui est attendu.

Créons tout d’abord l’arborescence attendue par ansible-test pour un test integration :

$ cd rrey/my_collection/
$ mkdir -p tests/integration/targets/my_custom_filters/{tasks,defaults}

Dans le répertoire tests nous créons un répertoire integration qui permettra à la commande ansible-test integration de trouver tous les tests d'intégration.

Nous trouvons ensuite un répertoire targets que nous n’avions pas pour les tests unitaires. C’est dans ce répertoire que nous allons créer nos rôles de tests. Dans mon exemple le répertoire my_custom_filters est un rôle qui me permettra de tester mon filtre.

Dans mon rôle my_custom_filters, je vais créer des variables que j’utiliserai pendant mes tests :


# tests/integration/targets/my_custom_filters/defaults/main.yml
---

valid_address: "10.0.0.1"
invalid_address: "I am not an address"
non_str_value: 42

...

Je peux maintenant créer les tâches de tests dans mon rôle :


---

- name: call the filter with a valid address
  set_fact:
    result: "{{ valid_address | rrey.my_collection.split_ip }}"

- assert:
    that: 'result == ["10", "0", "0", "1"]'

- name: call the filter with a invalid address
  set_fact:
    result: "{{ invalid_address | rrey.my_collection.split_ip }}"

- assert:
    that: 'result == ["I am not an address"]'

- name: call the filter with a non string input
  set_fact:
    result: "{{ non_str_value | rrey.my_collection.split_ip }}"
  ignore_errors: yes
  register: err

- assert:
    that:
      - 'err.failed == true'
      - 'err.msg == "split_ip expects a str type argument, got int"'

...

Pour mes tests j’utilise le module set_fact pour stocker le résultat du traitement de mes variables par mon filtre et le module assert pour vérifier que la variable contient bien la valeur attendue.

L’un de mes tests couvre le cas que j’ai implémenté à l’aide de mes tests unitaires. Je donne au filtre un entier qui doit provoquer une erreur de traitement par le filtre. Pour pouvoir tester qu’une erreur a été renvoyée, j’utilise le paramètre ignore_errors: yes qui va éviter l’arrêt d’Ansible lors de l’échec de traitement du filtre et j’enregistre l’output du module set_fact qui contiendra le JSON suivant:


{
  "failed": true,
  "msg": "split_ip expects a str type argument, got int"
}

Je peux donc m'assurer que la tâche a échouée et que j’ai reçu le message d’erreur attendu avec le dernier assert de mon exemple.

Sanity Tests

Le dernier type de tests dont je parlerai dans cet article est le test syntaxique ou “sanity test”. Ces tests ne nécessitent aucune action de votre part et peuvent immédiatement être exécutés :

$ ansible-test sanity --docker
[...]
Running sanity test 'empty-init' with Python 3.6
Running sanity test 'future-import-boilerplate' with Python 3.6
See documentation for help: https://docs.ansible.com/ansible/2.9/dev_guide/testing/sanity/future-import-boilerplate.html
ERROR: Found 1 future-import-boilerplate issue(s) which need to be resolved:
ERROR: tests/unit/plugins/filter/test_my_custom_filters.py:0:0: missing: from __future__ import (absolute_import, division, print_function)
Running sanity test 'ignores'
Running sanity test 'import' with Python 2.6
Running sanity test 'import' with Python 2.7
Running sanity test 'import' with Python 3.5
Running sanity test 'import' with Python 3.6
Running sanity test 'import' with Python 3.7
Running sanity test 'import' with Python 3.8
Running sanity test 'line-endings' with Python 3.6
Running sanity test 'metaclass-boilerplate' with Python 3.6
ERROR: Found 1 metaclass-boilerplate issue(s) which need to be resolved:
ERROR: tests/unit/plugins/filter/test_my_custom_filters.py:0:0: missing: __metaclass__ = type
See documentation for help: https://docs.ansible.com/ansible/2.9/dev_guide/testing/sanity/metaclass-boilerplate.html
Running sanity test 'no-assert' with Python 3.6
Running sanity test 'no-basestring' with Python 3.6
Running sanity test 'no-dict-iteritems' with Python 3.6
Running sanity test 'no-dict-iterkeys' with Python 3.6
Running sanity test 'no-dict-itervalues' with Python 3.6
Running sanity test 'no-get-exception' with Python 3.6
Running sanity test 'no-illegal-filenames' with Python 3.6
Running sanity test 'no-main-display' with Python 3.6
Running sanity test 'no-smart-quotes' with Python 3.6
Running sanity test 'no-unicode-literals' with Python 3.6
Running sanity test 'pep8' with Python 3.6
Running sanity test 'pslint'
Running sanity test 'pylint' with Python 3.6
Running sanity test 'replace-urlopen' with Python 3.6
Running sanity test 'rstcheck' with Python 3.6
Running sanity test 'shebang' with Python 3.6
Running sanity test 'shellcheck'
Running sanity test 'symlinks' with Python 3.6
Running sanity test 'use-argspec-type-path' with Python 3.6
Running sanity test 'use-compat-six' with Python 3.6
Running sanity test 'validate-modules' with Python 3.6
Running sanity test 'yamllint' with Python 3.6
ERROR: The 2 sanity test(s) listed below (out of 41) failed. See error output above for details.
future-import-boilerplate
metaclass-boilerplate

On peut voir que la commande me remonte des erreurs dans le code de mon filtre avec un lien vers la documentation en ligne expliquant le problème. En corrigeant les fichiers mis en évidence comme la documentation le demande, l'exécution des tests passent sans erreur.

On peut voir que pas moins de 42 types de tests sont exécutés, incluant notamment yamllint, pylint et pep8.

Une petite minute … Souvenez-vous de ma publication sur Galaxy, l’interface m’avait attribuée un score de qualité de 4.5/5, quelles étaient les erreurs ?

  • ansible-lint E701: Role info should contain platforms
  • ansible-lint E703: Should change default metadata: license

Tiens donc … Ces erreurs (des warnings en fait) ne font pas partie des problèmes que nous avons corrigés jusqu’ici. La raison est simple mais pas forcément satisfaisante : Ansible Galaxy execute yamllint et ansible-lint sur le code publié mais ansible-test n’utilise pas ansible-lint

Si cela vous choque, sachez que le sujet a été discuté sur cette issue GitHub et qu’il n’est pas prévu que ansible-lint soit intégré à ansible-test... Il reste possible d’installer ansible-lint et de lancer la commande nous-même :

$ pip install ansible-lint
$ cd rrey/my_collection/roles
$ ansible-lint *
[701] Role info should contain platforms
/Users/remi.rey/ansible_collections/rrey/my_collection/roles/idiot/meta/main.yml:1
{'meta/main.yml': {'galaxy_info': {'author': 'Rémi REY', 'description': 'Un exemple idiot de role', 'company': 'YOLOCORP', 'license': 'license (GPL-2.0-or-later, MIT, etc)', 'min_ansible_version': 2.9, 'galaxy_tags': [], '__line__': 1, '__file__': '/Users/remi.rey/ansible_collections/rrey/my_collection/roles/idiot/meta/main.yml'}, 'dependencies': [], '__line__': 1, '__file__': '/Users/remi.rey/ansible_collections/rrey/my_collection/roles/idiot/meta/main.yml'}}

[703] Should change default metadata: license
/Users/remi.rey/ansible_collections/rrey/my_collection/roles/idiot/meta/main.yml:1
{'meta/main.yml': {'galaxy_info': {'author': 'Rémi REY', 'description': 'Un exemple idiot de role', 'company': 'YOLOCORP', 'license': 'license (GPL-2.0-or-later, MIT, etc)', 'min_ansible_version': 2.9, 'galaxy_tags': [], '__line__': 1, '__file__': '/Users/remi.rey/ansible_collections/rrey/my_collection/roles/idiot/meta/main.yml'}, 'dependencies': [], '__line__': 1, '__file__': '/Users/remi.rey/ansible_collections/rrey/my_collection/roles/idiot/meta/main.yml'}}

Nous retrouvons bien les 2 warnings qui étaient affichés sur Galaxy. La documentation en ligne permettra de résoudre les 2 alertes remontées et d’obtenir un score de 5/5 sur Galaxy !

Quels apports ?

Prenons un exemple avec un outil que nous utilisons souvent en mission : Grafana. Grafana est un outil de monitoring qui permet de créer des dashboards en se basant sur différents backends contenant des métriques. Le produit possède une API assez complète qui permet d’automatiser la création d’éléments dans Grafana (de l’utilisateur au dashboard en passant par les datasources).

A ce jour, Ansible ne possède que 3 modules pour Grafana (grafana_datasource, grafana_dashboard et grafana_plugin) et plusieurs modules mériteraient d’être écrits pour couvrir les besoins d’automatisation. Si nous écrivions ces modules aujourd’hui, nous devrions attendre la prochaine version d’Ansible pour les voir être distribués à la communauté. Cette mise à disposition pourrait donc prendre entre 3 et 4 mois, rythme de publication des versions d’Ansible.

Avec les collections, nous pouvons maintenant créer une collection pour Grafana et y centraliser toutes les contributions autour du produit Grafana. Le cycle de vie des modules, plugins et autre rôles pour Grafana pourra donc être indépendant du cycle de vie du cœur Ansible et des fonctionnalités peuvent apparaître en installant la nouvelle version de la collection. Nos nouveaux modules pourraient donc être disponibles dès demain.

Les collections permettent également de distribuer plus de contenu :

  • Un rôle pour installer Grafana sur un serveur

  • Un rôle pour configurer Grafana :

  • les utilisateurs

  • les équipes

  • les datasources

  • les dashboards

  • ...

  • Un playbook appelant tous les rôles de la collection

  • Les modules pour interagir avec Grafana

  • Les filtres facilitant le traitement de variables

  • etc ...

Un utilisateur d’Ansible pourra donc trouver dans la collection tout ce qui est nécessaire pour interagir avec le produit, de son installation sur un serveur à sa configuration complète. Il ne restera à l’utilisateur qu'à créer les bonnes variables dans les group_vars et à appeler le playbook.

Le mot de la fin

Les collections apportent de la valeur dans Galaxy en permettant de distribuer plus de contenu et la perspective d’avoir des releases plus fréquentes est rassurante vis à vis de la stabilité des modules de la communauté.

Malgré un manque de documentation, l’arrivée de ansible-test permettra aux plus exigeants d’entre nous de satisfaire leur besoin de couverture de tests (Coucou les Octos !). Nous espérons voir naître sur le site docs.ansible.com de belles pages destinées aux utilisateurs de ansible-test en dehors du dépôt ansible/ansible.

Nous nous posons cependant des questions sur l'adhérence des collections avec le serveur Galaxy. Il n’y a pas de mention dans la documentation sur la possibilité d’utiliser l’URL d’un repository Git ou d’un Artefact Manager pour récupérer une collection. L’utilisation en entreprise avec des sources privées devra donc attendre cette fonctionnalité ou vous devrez effectuer quelques manipulations pour récupérer une collection localement (wget ou git submodule FTW).

L’évolution du projet dans les mois à venir va incontestablement tourner autour des collections. Ne ratez pas le train !