Introduction aux Ansible Content Collections
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 sectiondefaults
- 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 !