Test Driven Infrastructure avec Ansible et Molecule
Jusqu'à présent, j'écrivais mes rôles Ansible en les testant sur une box Vagrant ce qui m'évitait de lancer un rôle potentiellement bogué sur une machine distante. Récemment, en faisant un peu de veille sur le sujet, je suis tombé sur un outil permettant de jouer un rôle Ansible sur un conteneur Docker, puis de lancer des tests : Molecule 😍.
Je vous propose de tester tout ça ensemble ! On va écrire un rôle Ansible un peu bêbête qui installera htop
et vim
, sur une machine Ubuntu, tout en le testant. On veillera à respecter quelques pratiques de l'écosystème Python, qui nous permettrons de garder une consistance dans l'environnement entre les différents usagers du projet.
Les principes de Molecule
Pendant la phase de développement, Molecule va venir envelopper Ansible et fournir une interface pour proposer un flux en 3 étapes :
- Create : création de l'environnement cible, par défaut un conteneur Docker.
- Converge : lancement du rôle Ansible sur l'environnement cible.
- Verify : lancement de la suite de tests sur l'environnement cible.
Préparation de l'environnement de développement
Avant toute chose, on doit préparer notre environnement de développement. Ansible étant écrit en Python, je préfère l'installer via Pip, le package manager de Python, dans un environnement virtuel Python. Ainsi, tous les développeurs intervenant sur le projet utiliseront la même version d'Ansible, et on évitera les surprises.
Initialisation d'un virtualenv Python
Initialisation d'un virtualenv :
virtualenv venv
Activation de l'environnement virtuel :
source ./venv/bin/activate
Nous sommes maintenant dans un environnement isolé du reste du système.
Installation d'Ansible
Une fois dans l'environnement virtuel, on peut installer Ansible via Pip :
pip install ansible
On peut désormais utiliser Ansible :
ansible-playbook --version
# ansible-playbook 2.10.7
ansible-galaxy --version
# ansible-galaxy 2.10.7
ansible-lint --version
# ansible-lint 5.0.6 using ansible 2.10.7
Puis on freeze tout cela dans un fichier ./requirements.txt
, il nous servira pour réinstaller notre environnement :
pip freeze > requirements.txt
Installation de molecule
Même principe ici, on installe Molecule via Pip :
pip install "molecule[ansible,lint,docker]" && pip freeze > requirements.txt
On check notre installation :
molecule --version
# molecule 3.3.0 using python 3.8
# ansible:2.10.7
# delegated:3.3.0 from molecule
# docker:0.2.4 from molecule_docker
Création du rôle
Sans Molecule, la bonne pratique pour créer un rôle serait de le faire via ansible-galaxy
. Molecule propose une alternative, qui rajoutera en plus des fichiers qui lui sont propres.
molecule init role --driver-name docker --verifier-name=testinfra ansible-role-utils
Si vous me suivez, vous vous retrouvez avec votre rôle dans un dossier ... On corrige tout ça :
mv ansible-role-utils/* ./ && rm -rf ansible-role-utils
Comparé à ansible-galaxy
, nous obtenons un dossier supplémentaire :
├── molecule
│ └── default
│ ├── converge.yml # Configuration du provisionning, basiquement un import du rôle ansible
│ ├── molecule.yml # Configuration de Molecule
│ └── tests
│ ├── conftest.py # Initialisation de Testinfra
│ └── test_default.py # L'emplacement des tests par défaut
Le rôle est généré avec des valeurs par défaut, on remplit un peu :
# meta/main.yml
galaxy_info:
role_name: utils
author: nicolasleborgne
description: This role aim to install utils package such as htop or vim.
# issue_tracker_url: http://example.com/issue/tracker
license: MIT
min_ansible_version: 2.10
platforms:
- name: Ubuntu
versions:
- 20.04
galaxy_tags: []
dependencies: []
Testinfra
Vous noterez qu'on spécifie un verifier-name
lors de la création du rôle. Par défaut, et depuis la version 3, Molecule utilise des assertions made in Ansible. Cela ressemble à ça :
# https://www.ansible.com/blog/developing-and-testing-ansible-roles-with-molecule-and-podman-part-2
- name: Verify
hosts: all
vars:
expected_content: "There's a web server here"
tasks:
- name: Get index.html
uri:
url: http://localhost
return_content: yes
register: this
failed_when: "expected_content not in this.content"
Cette manière de fonctionner me dérange sur plusieurs points :
- Je trouve ça peu lisible.
- Ce n'est pas réutilisable avec un autre outil d'infrastructure as code.
- Un biais : j'ai découvert molecule avec Testinfra.
À l'instar, via Testinfra :
# https://testinfra.readthedocs.io/en/latest/
def test_nginx_is_installed(host):
nginx = host.package("nginx")
assert nginx.is_installed
assert nginx.version.startswith("1.2")
En tant que développeur, c'est bien plus proche de nos habitudes. On poursuit donc avec l'installation de Testinfra :
pip install pytest-testinfra && pip freeze > requirements.txt
Une dernière petite chose, les tests sont actuellement écrits dans ./molecule/tests
or, le rôle généré contient déjà un dossier ./tests
, on va donc modifier notre configuration pour l'utiliser :
# molecule/default/molecule.yml
verifier:
name: testinfra
directory: ../../tests
On peut ensuite bouger tout ça :
rm -rf ./tests && mv ./molecule/default/tests ./tests
🔴 Red
Rappel de notre cahier des charges :
- Sur une machine Ubuntu
- Installer les packages suivants :
- htop
- vim
On commence par modifier la configuration de Molecule, qui par défaut, utilise un conteneur Centos :
# molecule/default/molecule.yml
platforms:
# @see all options here : https://github.com/ansible-community/molecule-docker/blob/6fa2b4252db82523d254df00c9d10d2c3eb3eef5/molecule_docker/driver.py#L55
- name: instance
image: docker.io/pycontribs/ubuntu:latest
pre_build_image: true
On lance notre conteneur :
molecule create
On vérifie qu'il est bien là :
molecule list
# INFO Running default > list
# ╷ ╷ ╷ ╷ ╷
# Instance Name │ Driver Name │ Provisioner Name │ Scenario Name │ Created │ Converged
# ╶───────────────┼─────────────┼──────────────────┼───────────────┼─────────┼───────────╴
# instance │ docker │ ansible │ default │ true │ true
# ╵ ╵ ╵ ╵ ╵
On écrit notre premier test :
# ./tests/test_default.py
def test_htop_is_installed(host):
htop = host.package('htop')
assert htop.is_installed
On provisionne :
molecule converge
On lance les tests :
molecule verify
# # > assert htop.is_installed
# E assert False
# E + where False = <package htop>.is_installed
#
# tests/test_default.py:7: AssertionError
🟢 Green
Maintenant que nous sommes dans la zone rouge, on va pouvoir écrire notre code :
# ./tasks/main.yml
---
# tasks file for ansible-role-utils
- name: Install htop
apt:
name: htop
state: present
update_cache: true
On relance les tests, Molecule nous propose une commande permettant de faire un tour de boucle complet :
molecule test
# ============================== 1 passed in 1.91s ===============================
# INFO Verifier completed successfully.
Refactor
Par souci de concision, je vous passe la répétition des deux précédentes étapes pour rajouter l'installation de vim
, notez que comme sur du code, on peut passer par une étape de refacto, tant dans le rôle que dans les tests :
# ./tasks/main.yaml
---
# tasks file for ansible-role-ufw
- name: Install packages
apt:
name: "{{ '{{ item }}' }}"
state: present
update_cache: true
loop:
- htop
- vim
# ./tests/test_default.py
import pytest
@pytest.mark.parametrize("name", [
("htop"),
("vim"),
-])
def test_package_are_installed(host, name):
pkg = host.package(name)
assert pkg.is_installed
Intégration continue avec Giltab
Après avoir écrit nos tests, on a envie de les faire tourner sur notre serveur de CI, dans mon cas un gitlab-ci en SAAS, mais la documentation de Molecule fournit des exemples pour d'autres fournisseurs.
Je vous propose de partir d'une base légèrement différente de celle de la documentation de Molecule, adaptée à notre contexte (Pip, requirements.txt ...)
# .gitlab-ci.yml
---
image: docker:dind
services:
- docker:dind
before_script:
- apk add --no-cache
python3 python3-dev py3-pip gcc git curl build-base
autoconf automake py3-cryptography linux-headers
musl-dev libffi-dev openssl-dev openssh rust cargo
- python3 -m pip install --upgrade pip
- python3 -m pip install -rrequirements.txt
molecule:
stage: test
script:
- molecule test
Améliorations
Jusqu'ici, ça fonctionne, mais c'est long. Nous allons apporter des améliorations dans le but de :
- Réduire les écarts entre un run local et un run Gitlab. On évitera les "ça marche sur mon poste".
- Réduire le temps d'exécution, 10 minutes, c'est trop long pour si peu.
Tox
Tox est un gestionnaire de virtualenv. Il va nous permettre de lancer nos tests automatiquement dans un environnement virtuel. Il peut également permettre de tester le projet avec des versions de Python ou d'Ansible différentes. Le principal avantage dans notre situation, c'est que les environnements virtuels cachent les dépendances. Nous n'aurons donc pas à les récupérer de zéro à chaque job.
On le configure via le fichier ./tox.ini
:
# tox.ini
[tox]
envlist = py38
skipsdist=True
[testenv]
passenv = DOCKER_HOST
deps = -rrequirements.txt
commands = molecule test
Ici rien de bien particulier :
- On utilise python3.8.
- On laisse passer la variable d'env
DOCKER_HOST
, que Molecule utilisera. - On installe les dépendances via de notre
./requirements.txt
. - On lance la commande
molecule test
.
Pour lancer nos tests, il suffit maintenant de lancer tox :
tox
On adapte notre fichier ./.gitlab-ci
en conséquence :
# .gitlab-ci.yml
---
image: docker:dind
services:
- docker:dind
before_script:
- apk add --no-cache
python3 python3-dev py3-pip gcc git curl build-base
autoconf automake py3-cryptography linux-headers
musl-dev libffi-dev openssl-dev openssh rust cargo
- python3 -m pip install --upgrade pip
- python3 -m pip install tox
molecule:
stage: test
script:
- tox
Cache
Tox va désormais cacher les dépendances dans le répertoire ./.tox
, on souhaite donc que Gitlab le garde pour tous les jobs d'une même branche :
# .gitlab-ci.yml
---
image: docker:dind
services:
- docker:dind
before_script:
- apk add --no-cache
python3 python3-dev py3-pip gcc git curl build-base
autoconf automake py3-cryptography linux-headers
musl-dev libffi-dev openssl-dev openssh rust cargo
- python3 -m pip install --upgrade pip
- python3 -m pip install tox
cache:
key: ${CI_COMMIT_REF_NAME}
paths:
- .tox/
molecule:
stage: test
script:
- tox
Build de l'image docker en amont
La partie before_script
est jouée à chaque job alors que cela n'a pas trop de sens. Plutôt que de tout réinstaller à chaque fois, nous allons builder une image docker en amont.
FROM docker:dind
RUN apk add --no-cache python3 && \
apk add --no-cache python3-dev && \
apk add --no-cache py3-pip && \
apk add --no-cache gcc && \
apk add --no-cache git && \
apk add --no-cache curl && \
apk add --no-cache build-base && \
apk add --no-cache autoconf && \
apk add --no-cache automake && \
apk add --no-cache py3-cryptography && \
apk add --no-cache linux-headers && \
apk add --no-cache musl-dev && \
apk add --no-cache libffi-dev && \
apk add --no-cache openssl-dev && \
apk add --no-cache openssh && \
apk add --no-cache rust && \
apk add --no-cache cargo && \
python3 -m pip install --upgrade pi && \
python3 -m pip install tox
Ici, je build mon image dans un autre projet Gitlab et la stocke directement dans le container registry de Gitlab. Cela se fait assez facilement avec le ./.gitlab-ci.yml
suivant :
# .gitlab-ci.yml
---
image: docker:dind
services:
- docker:dind
before_script:
- docker info
build_image:
script:
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
- docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_BRANCH .
- docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_BRANCH
Et enfin notre ./.gitlab-ci.yml
pour notre rôle Ansible :
# .gitlab-ci.yml
---
image: registry.gitlab.com/kokua1/docker-dind-python:master
services:
- name: registry.gitlab.com/kokua1/docker-dind-python:master
alias: docker
cache:
key: ${CI_COMMIT_REF_NAME}
paths:
- .tox/
molecule:
stage: test
script:
- tox
Notre pipeline d'intégration continue est désormais plus concis, carré et plus rapide (4 minutes).
Conclusion
Molecule permet d'adopter la même rigueur que nous pouvons avoir avec notre code dans nos provisionings. Parfois la documentation n'est pas super super à jour, et en jouant avec la configuration on trouve des combinaisons un peu obscures qui fonctionnent plus ou moins bien, néanmoins il reste assez simple à mettre en oeuvre.
Le choix de tester sur un conteneur Docker est discutable, mais Molecule semble permettre d'utiliser d'autres drivers.
S'il facilite la vie, en soi ce n'est pas la pièce maitresse du processus. La grosse découverte pour moi réside plus à travers Testinfra que Molecule. En effet, Testinfra peut très bien être utilisé sans Molecule, des exemples d'intégration sont fournis pour Vagrant et Docker.
On peut également imaginer lancer une suite de tests après un provisioning, directement sur notre environnement cible. En effet, si Ansible nous garantit qu'une tâche s'est bien exécutée, rien ne nous assure que la tâche suivante n'y apporte pas une régression.
Si vous souhaitez pousser plus loin, notez que :
- Votre rôle peut dépendre d'autres rôles, Molecule les installera pour vous.
- Vous pouvez tester votre rôle sur plusieurs cibles, cela permet de tester une compatibilité sur plusieurs OS.
- Vous pouvez utiliser molecule non pas uniquement sur un rôle mais également un playbook en modifiant votre converge.yml.
- Molecule peut également lancer une étape de lint, désactivée par défaut.