Nicolas Le Borgne

Développeur

Test Driven Infrastructure avec Ansible et Molecule

Le 3 avril 2021

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

Le cycle 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       │ truetrue
#                 ╵             ╵                  ╵               ╵         ╵

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 :

Sources

© 2021 Nicolas Le Borgne