Aller au contenu principal

Ecrire correctement des roles Ansible

Dans le monde de l'automatisation IT, Ansible se distingue par sa simplicité et sa puissance, permettant une gestion de configurations et un déploiement d'applications efficaces et sans heurts. À travers mon parcours en tant que professionnel DevOps, j'ai adopté Ansible pour sa facilité d'intégration dans divers environnements et sa capacité à simplifier des tâches complexes avec des playbooks clairs et concis. Ce guide vise à partager mon expérience dans l'écriture de rôles Ansible, des blocs essentiels pour réutiliser et organiser le code Ansible. Je vous guiderai à travers les étapes de création, de test, et d'optimisation de rôles, en mettant l'accent sur les meilleures pratiques et en fournissant des exemples concrets pour vous aider à maîtriser cet outil incontournable.

La structure des répertoires de rôles Ansible

La structure d'un rôle Ansible est un élément clé pour organiser et réutiliser le code d'automatisation. Un rôle est divisé en plusieurs dossiers, chacun ayant un objectif spécifique. Les dossiers tasks contiennent les tâches principales à exécuter, handlers sont utilisés pour les tâches qui ne s'exécutent qu'en réponse à un changement, defaults et vars définissent les variables avec des valeurs par défaut et spécifiques au rôle, files et templates pour les fichiers statiques et les modèles Jinja2 et enfin, meta pour décrire le rôle.

Plutôt que de créer la structure d'un role Ansible manuellement, je vous conseille d'utiliser ansible-galaxy :

ansible-galaxy role init stephrobert.users

Vérifions la structure créée :

cd stephrobert.users
tree .
.
├── defaults
│   └── main.yml
├── files
├── handlers
│   └── main.yml
├── meta
│   └── main.yml
├── README.md
├── tasks
│   └── main.yml
├── templates
├── tests
│   ├── inventory
│   └── test.yml
└── vars
    └── main.yml
remarque

Vous avez remarqué il y a deux répertoires pour les variables : vars et defaults. Alors se pose la question que mettre dedans ? :

  • Celles qui sont déclarées dans defaults ont la priorité la plus faible et contiennent généralement des valeurs par défaut définies par les auteurs du rôle. Ce sont celles qui pourront être surchargées lors de l'utilisation de votre rôle dans vos playbooks. Exemple la version du package de cotre serveur http.
  • Celles qui sont dans le répertoire vars sont donc les autres. Celles qui ne peuvent pas être modifié par l'utilisateur de vos rôles.

Le fichier README.md est la documentation du rôle. Il doit indiquer comment installer et utiliser celui-ci. Il doit également décrire toutes les variables permettant de le configurer. Afin de vous simplifier cette tâche, j'ai écrit un outil répondant au nom de ansible-gendoc.

Avant toute chose, il faut renseigner le fichier meta/main.yml qui contient toutes les informations nécessaires à l'enregistrement de votre rôle dans un serveur ansible galaxy :

---
galaxy_info:
  author: Stephane ROBERT
  role_name: users
  namespace: stephrobert
  description: Create new users
  company: none
  license: MLP2
  min_ansible_version: "2.11"
  platforms:
    - name: Debian
      versions:
        - bullseye
        - bookworm
    - name: Ubuntu
      versions:
        - jammy
  galaxy_tags:
    - users
dependencies: []

Les champs les plus importants à renseigner :

  • role_name : le nom du role
  • namespace: le nom de l'espace déclaré dans ansible galaxy.
  • dependencies : des roles et/ou des collections indispensables au fonctionnement du rôle

Utilisation du TDD pour coder notre rôle

L'adoption du Test-Driven Development (TDD) pour le développement de rôles Ansible améliore significativement la qualité et la fiabilité du code. TDD implique d'écrire des tests avant même de développer les fonctionnalités. Dans le contexte d'Ansible, cela signifie utiliser des outils comme Molecule pour créer un environnement de test où l'on définit les comportements attendus de nos rôles. On écrit d'abord des tests qui échouent, puis on développe le rôle jusqu'à ce que ces tests passent. Cette approche encourage une conception plus réfléchie et permet d'identifier rapidement les régressions.

Installation et Configuration de molecule

Commençons par installer molecule :

pip install molecule==5.0.1 --user
pip install molecule-plugins --user
attention

La version 6.x de molecule est encore en cours de développement et possède pas mal de changements plutôt pénibles !

Votre premier scenario molecule

Molecule utilise ce qu'on appelle des scénarios. Les scénarios Molecule sont des configurations définies par l'utilisateur pour tester les rôles Ansible. Ils spécifient comment Molecule doit créer l'environnement de test, exécuter les tests et valider le comportement du rôle. Chaque scénario peut avoir des environnements de test différents, permettant de tester le rôle dans diverses conditions et configurations. Le scenario par défaut s'appelle default. Pour le créer, utilisez cette commande :

molecule init scenario --verifier-name ansible --driver-name vagrant

INFO     Initializing new scenario default...
INFO     Initialized scenario in /home/bob/Projects/perso/stephrobert.users/molecule/default successfully.

Les drivers existants :

  • azure :
  • containers :
  • delegated :
  • docker :
  • ec2 :
  • gce :
  • podman :
  • vagrant :

Dans mon cas, je vais utiliser le plugin vagrant.

Configuration du scenario molecule

Le paramétrage se trouve dans le fichier molecule/default/molecule.yml. Le sous-dossier default se trouvant dans le répertoire molecule est le dossier du scenario par défaut. On peut créer d'autres scenarios, pour cela, il faudra utiliser la même commande indiquée ci-dessus en ajoutant en argument le nom de celui-ci.

Détaillons le contenu du fichier de configuration molecule.yml :

---
dependency:
  name: galaxy
driver:
  name: vagrant
platforms:
  - name: instance
    box: generic/ubuntu2204
    memory: 512
    cpus: 1
provisioner:
  name: ansible
verifier:
  name: ansible
lint: |
  yaml-lint

Tout est documenté ici, mais voyons ensemble chacun des paramètres :

  • dependency : Molecule utilise le repository ansible-galaxy par défaut pour installer les dépendances de rôle.
  • driver : Molecule utilise un driver, docker par défaut, pour déléguer la tâche de création d'instances.
  • platforms : Permet d'indiquer à Molecule quelles instances de linux sont à créer. Pour le driver docker, il faut donner le nom de l'image à utiliser. Le pre_build_image permet de customiser une image en soit un fichier portant le <nom_de_la-plateforme>.dockerfile soit le fichier Dokerfile.j2 se trouvant dans le dossier template.
  • provisioner : On indique à Molecule que le provisioner est Ansible.
  • verifier : Molecule utilise Ansible par défaut pour lancer des tests de vérification d'état sur les instances.
  • test : Lance toutes les séquences de la création à la destruction.

Description du fonctionnement de molecule

Un peu ardu à expliquer ! La CLI de Molecule utilise des sous-commandes pour lancer des séquences (paramétrables). Voici la liste des principales sous-commandes :

  • dependency : installe les dépendances ansible-galaxy nécessaires à l’exécution du rôle.
  • create : création de l'instance avec le driver indiqué. Le rôle ne sera pas exécuté.
  • prepare : exécution du playbook prepare.yml sur l'instance (installation des prérequis)
  • converge : exécution du playbook playbook.yml sur la l'instance (exécution du rôle lui-même)
  • verify : exécution du playbook verify.yml sur l'instance (tests unitaires)
  • destroy : destruction de l’environnement et de la VM

Les autres sont : check, cleanup, idempotence, side-effect, syntax.

C'est là où ça se complique. Chaque sous-commande lance en fait des séquences de sous-commandes. Pour en connaitre la composition, il faut utiliser la commande molecule matrix <sous-commandes> :

molecule matrix converge

INFO     Test matrix
---
default:
  - dependency
  - create
  - prepare
  - converge

On voit donc que la sous-commande converge lance dans l'ordre indiqué dependency, puis create et ainsi de suite.

Paramétrage du scenario

Par défaut lors de l'initialisation, il y a un paramètre qui n'est pas mis dans la configuration, mais qui permet de décrire le scenario qui sera utilisé : scenario.

Molecule s'appuie sur cette configuration pour contrôler la composition et l'ordre des séquences du scénario. Plus d'infos ici

Vous pouvez modifier les séquences par défaut. Par exemple pour ne pas lancer prepare dans lors de la création de l'instance :

scenario:
  create_sequence:
    - dependency
    - create

Vérifions avec la sous-commande matrix :

molecule matrix create

INFO     Test matrix
---
default:
  - dependency
  - create

Passons à la pratique

Maintenant commençons à coder notre rôle. Ce rôle créé des users, donc users est une variable et comme il doit pouvoir être modifié lors de l'appel de votre rôle dans un de vos playbooks, nous le plaçons dans defaults/main.yml. Ce qui donne :

---
# defaults file for monrole
users: []

Vous avez remarqué, j'utilise une liste, car oui notre rôle doit pouvoir créer plusieurs utilisateurs. Par défaut cette liste est vide.

Que dois-je faire maintenant ? Écrire un test, vérifier qu'il échoue et ensuite écrire le code permettant de résoudre cet échec. Notre premier test est : l'utilisateur existe-t-il sur notre serveur ?

Éditons le fichier molecule/default/verify.yml dont voici son contenu par défaut :

- name: Verify
  hosts: all
  gather_facts: false
  tasks:
    - name: Example assertion
      ansible.builtin.assert:
        that: true

Pour éviter de définir la variable dans les valeurs par défauts du rôle, j'inclus un fichier de variables :

users:
  - bob

Mais comment écrire ce test avec Ansible ? Eh bien, comme on le ferait normalement sauf que l'on va indiquer que la tache échoue également si son status est changed en plus de celui de failed.

- name: Verify
  hosts: all
  gather_facts: false
  tasks:
    # - name: Example assertion
    #   ansible.builtin.assert:
    #     that: true
    - name: Load vars
      ansible.builtin.include_vars:
        file: vars.yml
    - name: User exist
      ansible.builtin.user:
        name: "{{ item }}"
        state: present
      with_items: "{{ users }}"
      check_mode: true
      register: user
      failed_when: user is changed or user is failed

Pour éviter que le test ne change quoi que ce soit nous utilisons le mode dry_run avec check_mode à true.

Mais on doit indiquer notre liste d'utilisateurs quelque part non ? Oui dans le fichier molecule/default/converge.yml car c'est le playbook qui est exécuté et qui appel votre rôle par molecule :

---
- name: Converge
  hosts: all
  vars:
    users:
      - bob
  tasks:
    - name: Load vars
      ansible.builtin.include_vars:
        file: vars.yml
    - name: "Include stephrobert.users"
      ansible.builtin.include_role:
        name: "stephrobert.users"

Vous voyez l'intérêt de molecule ! On peut tester son rôle et ce même dans un pipeline CI/CD. Tout se passe dans une instance vagrant (ici).

On lance le test :

molecule converge # On créé l'instance et on lance le role
....

molecule verify
TASK [User exist] **************************************************************
failed: [instance] (item=bob) => {"ansible_facts": {"discovered_interpreter_python": "/usr/bin/python3"}, "ansible_loop_var": "item", "changed": true, "failed_when_result": true, "item": "bob"}

PLAY RECAP *********************************************************************
instance                   : ok=1    changed=0    unreachable=0    failed=1    skipped=0    rescued=0    ignored=0

WARNING  Retrying execution failure 2 of: ansible-playbook --inventory /home/bob/.cache/molecule/stephrobert.users/default/inventory --skip-tags molecule-notest,notest /home/bob/Projects/perso/stephrobert.users/molecule/default/verify.yml
CRITICAL Ansible return code was 2, command was: ['ansible-playbook', '--inventory', '/home/bob/.cache/molecule/stephrobert.users/default/inventory', '--skip-tags', 'molecule-notest,notest', '/home/bob/Projects/perso/stephrobert.users/molecule/default/verify.yml']

Notre test en bien sorti en échec. Maintenant, écrivons le code qui permet de résoudre cet échec.

On copie le code du test pour le mettre dans les taches du rôle. Sauf qu'on retire la ligne failed_when et register. Ca se passe dans le fichier tasks/main.yml :

---
# tasks file for monrole
- name: Create user
  ansible.builtin.user:
    name: "{{ item }}"
    state: present
  with_items: "{{ users }}"

On relance :

molecule converge # On relance le role
....

molecule verify


TASK [User exist] **************************************************************
ok: [instance] => (item=bob)

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

INFO     Verifier completed successfully.

Vous avez compris le principe. Allez à présent, essayons de tester que l'utilisateur a comme shell /bin/zsh. Il faut modifier la structure de l'utilisateur :

users:
  - name: bob
    shell: /bin/zsh

Comment contrôler avec ansible qu'user est configuré avec le shell zsh ? Tout simplement dans le fichier /etc/passwd :

toto:x:1000:1000::/home/toto:/bin/zsh

Nous allons donc utiliser le module lineinfile :

- name: Verify
  hosts: all
  gather_facts: false
  tasks:
    # - name: Example assertion
    #   ansible.builtin.assert:
    #     that: true
    - name: Load vars
      ansible.builtin.include_vars:
        file: vars.yml
    - name: User exist
      ansible.builtin.user:
        name: "{{ item.name }}"
        state: present
      with_items: "{{ users }}"
      check_mode: true
      register: user
      failed_when: user is changed or user is failed
    - name: Check shell
      ansible.builtin.lineinfile:
        path: "/etc/passwd"
        regexp: '^{{ item.name }}(.*){{ item.shell }}$'
        line: '{{ item.name }}\1{{ item.shell }}'
        state: present
      check_mode: true
      register: shell
      failed_when: (shell is changed) or (shell is failed)
      with_items: "{{ users }}"

On relance le test:

molecule verify

TASK [Check shell] *************************************************************
failed: [instance] (item={'name': 'bob', 'shell': '/bin/zsh'}) => {"ansible_loop_var": "item", "backup": "", "changed": true, "failed_when_result": true, "item": {"name": "bob", "shell": "/bin/zsh"}, "msg": "line added"}

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

Il échoue bien. Maintenant corrigeons notre task user dans le fichier tasks/main.yml:

---
# tasks file for monrole
- name: Create user
  become: true
  ansible.builtin.user:
    name: "{{ item.name }}"
    state: present
    shell: "{{ item.shell }}"
  with_items: "{{ users }}"

On relance converge et verify et ça passe.

Je ne vais pas écrire tous les tests possibles, mais sachez que les modules les plus utiles sont services_facts pour charger l'état des services, le module assert et les facts bien sûr (module setup).

Par contre, bonne nouvelle depuis la version 2.11 d'Ansible, nous ne sommes plus obligés de faire des tests sur les variables d'entrée. En effet, il existe un mécanisme automatique. Voyons cela maintenant.

Spécification d'arguments pour les rôles Ansible.

Pour activer ce mécanisme, il faut créer un fichier meta/argument_specs.yml Dans ce fichier, nous allons indiquer pour chaque variable d'entrée de notre role ce qui est attendu. C'est-à-dire le type, la valeur par défaut, sa description, la liste des valeurs possibles…

Reprenons notre exemple ci-dessus. Notre variable d'entrée est une liste de dictionnaires requis :

---
argument_specs:
  main:
    short_description: "Create Users on the servers."
    description:
      - Create an configure a list of users on the servers.
    author: Stephane ROBERT

    options:
      users:
        type: list
        required: true
        description:
          - List of users
        elements: dict
        options:
          name:
            description:
              - The username
            type: str
            required: true
          shell:
            description:
              - The usr shell
            type: str
            default: /bin/bash
            choices:
              - /bin/bash
              - /bin/zsh

Ici, nous décrivons les variables en entrée de la tache main.yml

Si nous lançons molecule converge nous allons voir une tache supplémentaire apparaître :

TASK [stephrobert.users : Validating arguments against arg spec 'main' - Create Users on the servers.] ***
ok: [instance]

Cette tâche vérifie que les données en entrées sont celles attendues. Essayez de modifier les choix possibles de shell en retirant zsh et relancez :

TASK [stephrobert.users : Validating arguments against arg spec 'main' - Create Users on the servers.] ***
fatal: [instance]: FAILED! => {"argument_errors": ["value of shell must be one of: /bin/bash, got: /bin/zsh found in users"], "argument_spec_data": {"users": {"description": ["List of users"], "elements": "dict", "options": {"name": {"description": ["The username"], "required": true, "type": "str"}, "shell": {"choices": ["/bin/bash"], "default": "/bin/bash", "description": ["The usr shell"], "type": "str"}}, "required": true, "type": "list"}}, "changed": false, "msg": "Validation of arguments failed:\nvalue of shell must be one of: /bin/bash, got: /bin/zsh found in users", "validate_args_context": {"argument_spec_name": "main", "name": "stephrobert.users", "path": "/home/bob/Projects/perso/stephrobert.users", "type": "role"}}

Trop fort. N'est-ce pas ? Vous imaginez le nombre d'assert à taper pour valider cette structure, alors avec la structure du rôle nginx_config je ne vous raconte pas. Elle fait un millier de lignes.

Autre chose, grâce à ce système la documentation des rôles peut être obtenu avec la commande ansible-doc.

ansible-doc --type role --list

sensu.sensu_go.agent                                  configure    Configure Sensu Go agent
sensu.sensu_go.agent                                  start        Start Sensu Go agent
sensu.sensu_go.agent                                  main         Install, configure, and start Sensu Go agent
sensu.sensu_go.backend                                configure    Configure Sensu Go backend
sensu.sensu_go.backend                                start        Start Sensu Go backend
sensu.sensu_go.backend                                main         Install, configure, and start Sensu Go backend
sensu.sensu_go.install                                repositories Enable Sensu Go repos
sensu.sensu_go.install                                packages     Install selected Sensu Go packages
sensu.sensu_go.install                                main         Enable Sensu Go repos and install selected packages
stephrobert.bootstrap                                 main         Bootsrap a machine for Ansible.
stephrobert.users                                     main         Create Users on the servers.

Si nous voulons afficher la documentation du fichier main de notre role :

ansible-doc --type role --entry-point main stephrobert.users

> STEPHROBERT.USERS    (/home/bob/.ansible/roles/stephrobert.users)

ENTRY POINT: main - Create Users on the servers.

        Create an configure a list of users on the servers.

OPTIONS (= is mandatory):

= users
        List of users
        elements: dict
        type: list

        OPTIONS:

        = name
            The username
            type: str

        - shell
            The usr shell
            choices: [/bin/bash]
            default: /bin/bash
            type: str


AUTHOR: Stephane ROBERT

Ce n'est pas parfait, mais c'est un bon début non ? D'ailleurs ce mécanisme, je vais l'inclure dans mon outil de génération de documentation de rôles. Cela va permettre d'ajouter la description des variables, leur type, valeur par défaut...

Je ne vais pas documenter plus cette partie, car l'exemple que je vous ai écrit permet de construire des spécifications complexes de listes de dictionnaires. Tout est documenté ici. Un exemple de spécifications.

Quelques commandes molecule utiles

Vérifier l'état des scenarios

Molecule possède une commande permettant de vérifier l'état des scenarios :

molecule list
WARNING  Driver vagrant does not provide a schema.
INFO     Running default > list
                ╷             ╷                  ╷               ╷         ╷
  Instance Name │ Driver Name │ Provisioner Name │ Scenario Name │ Created │ Converged
╶───────────────┼─────────────┼──────────────────┼───────────────┼─────────┼───────────╴
  instance      │ vagrant     │ ansible          │ default       │ true    │ true

Ici on voit que l'instance de nom instance a été créé (created) et le role a été appliqué (converged).

Contrôler l'idem potence de vos rôles

La commande idempotence doit être lancé sur une instance ou le rôle a été appliqué. En fait cette commande relance le rôle et vérifie que toutes les tâches ne modifie rien à nouveau :

TASK [Load vars] ***************************************************************
ok: [instance]

TASK [Include stephrobert.users] ***********************************************

TASK [stephrobert.users : Validating arguments against arg spec 'main' - Create Users on the servers.] ***
ok: [instance]

TASK [stephrobert.users : Create user] *****************************************
ok: [instance] => (item={'name': 'bob', 'shell': '/bin/zsh'})

PLAY RECAP *********************************************************************
instance                   : ok=4    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

INFO     Idempotence completed successfully.

Très pratique !

Publier vos rôles sur Ansible Galaxy

Ansible Galaxy est une registry où les développeurs de rôles, collections, plugins, modules peuvent partager leurs productions. C'est dans cette base que la commande ansible-galaxy va puiser lors de l'installation des rôles et collections.

Avant de publier un rôle sur Ansible Galaxy, il est important de compléter les informations du fichier meta/main.yml. C'est en sorte sa carte d'identité.

Le fichier meta/main.yml.

Voici le contenu minimal à fournir :

galaxy_info:
  author: Stephane ROBERT
  namespace: steph
  description: Mon premier role ansible
  company: steprobert
  license: MLP2
  min_ansible_version: 2.11
  platforms:
    - name: Debian
      versions:
        - 11
    - name: Ubuntu
      versions:
        - 22.04
  galaxy_tags:
    - user
    - system
dependencies: []

Certains champs sont explicites, donc je vais me limiter à l'explication de :

  • dependencies: Ce sont des dépendances d'autres rôles
  • platforms: la liste des plateformes prises en charge par votre role. Elles doivent correspondre à celles que vous avez déclarées dans vos tests molecule.
  • galaxy_tags: la liste des mots clés qui permettront de retrouver votre rôle sur le site Ansible-Galaxy.

La publication

Il est indispensable que le code source de votre rôle soit stocké dans un repository github. Une fois déposé, il suffit de se rendre sur le site Ansible-Galaxy, de vous connecter avec votre compte Github et d'autoriser ansible-galaxy à accéder à votre compte en lecture seul. Ensuite aller dans la section [My content] et cliquer sur [Add Content]. Puis d'importer votre role avec le bouton [import Role from Github].

Utilisation de vos rôles dans vos playbooks

Il est possible d'intégrer vos rôles de 3 façons différentes :

  • Le classique roles qui est prioritaire sur les tasks du playbook :
---
- hosts: webservers
  roles:
    - common
    - webservers
  tasks:

Pour ordonner l'exécution des rôles, vous pouvez utiliser :

  • Un appel dynamique de role : include_role.
---
- hosts: webservers
  tasks:
    - name: Print a message
      ansible.builtin.debug:
        msg: "this task runs before the example role"

    - name: Include the example role
      include_role:
        name: example
      vars:
        dir: '/opt/a'
        app_port: 5000
  • Un appel statique de rôle : import_role.
---
- hosts: webservers
  tasks:
    - name: Print a message
      ansible.builtin.debug:
        msg: "this task runs before the example role"
    - name: Import the example role
      import_role:
        name: example

La différence entre les deux est que pour import_role le(s) role(s) sont parsés au démarrage du playbook alors que pour include_role cela est fait durant son exécution.

Quelques recommandations sur l'écriture des rôles

attention

Pour rappel, seuls les playbooks peuvent être exécuté avec la commande ansible-playbook!

  • Un rôle doit être limité à une tâche précise. Par exemple l'installation de votre serveur http.
  • Un rôle doit pouvoir être exécuté de manière autonome.
  • Même si on peut mettre des rôles en dépendances d'un autre rôle, éviter de faire des rôles, de rôles, de rôles. À déboguer c'est l'horreur.
  • Tous les résultats de vos tâches de vos rôles doivent être testé.
  • Pensez à décrire les variables dans le fichier meta/argument_specs.yml. Gain ansible se charge de lancer les asserts correspondants.
  • Tous les packages et librairies que vous installez ou que vous utilisez dans votre role doivent être versionné. Utilisez pour cela des variables. Exemple : nginx_version: 1.18.0.
  • Éviter de créer un role base ou common dans lequel on retrouve un gros mélange de taches qui devraient être plutôt découpé en plusieurs rôles.