Comment rendre Ansible statefull ?

Atelier de mise en pratique pour rendre certaines tâches Ansible statefull

La plupart du temps, Ansible parvient à gérer l’idempotence des tâches standards tels que l’installation de paquets standards (via yum ou apt). Néanmoins, dans certains cas, il peut être nécessaire de spécifiquement indiquer à Ansible de ne pas exécuter certaines tâches lorsque celles-ci ont déjà été exécutées par le passé.

Prenons le cas d’une instance dont l’exécution du playbook de configuration est planifiée à chaque démarrage et redémarrage de celle-ci.

Nous allons tenter de répondre à la problématique suivante : “comment s’assurer que tout ou partie de cette configuration ne soit exécutée qu’une seule fois ?” avec pour objectif que cette solution soit la plus générique possible.

Pour l’article, nous allons nous appuyer sur un cas d’usage et prendre pour exemple l’installation de Node.js sur une instance Debian. La documentation fournit par l’éditeur précise les actions suivantes pour l’installation de Node.js en version v20.17.0 via nvm sur une instance Linux :

1
2
3
4
5
# installs nvm (Node Version Manager)
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.0/install.sh | bash

# download and install `Node.js` (you may need to restart the terminal)
nvm install 20

Si nous traduisons cela en playbook Ansible, nous obtenons le résultat suivant :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
---

- name: Install `Node.js` using NVM
  hosts: all
  become: true

  tasks:
  - name: Install dependencies
      ansible.builtin.apt:
        name:
          - curl
          - build-essential
        state: present

  - name: Download and install nvm
      ansible.builtin.get_url:
        url: <https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.1/install.sh>
        dest: /tmp/install.sh
        mode: '0755'

  - name: Execute nvm installation script
      ansible.builtin.command: /tmp/install.sh

  - name: Install `node.js`
      ansible.builtin.shell: |
        . ~/.nvm/nvm.sh && nvm install node        

  - name: Cleanup install.sh
      ansible.builtin.file:
        path: /root/.nvm/install.sh
        state: absent
Remarque
Pour l’exemple, nous utilisons un playbook simplifié. Pour un usage de production, il serait préférable de créer un rôle dédié.

Dans ce playbook, on constate que certaines tâches vont être exécutées à chaque fois (notamment : ansible.builtin.shell) ; que Node.js soit déjà installé ou non.

Pour éviter cela, nous disposons de deux solutions :

  1. Faire un test sur l’existence de la commande node
  2. Implémenter la notion d’état

Dans ce cas d’usage, la première solution serait peut-être la plus simple ; néanmoins, elle ne correspond pas à notre contrainte évoquée plus haut d’être la plus générique et reproductible possible. C’est pourquoi nous allons donc plutôt nous orienter sur la deuxième solution.

Pour implémenter cette notion d’état, nous allons nous appuyer sur une notion de base d’Ansible : les facts et les custom facts.

Les facts sont des variables qu’Ansible va récupérer au début de l’exécution d’un playbook ou d’une tâche. C’est ce qui se cache derrière TASK [Gathering Facts] lors de l’exécution d’un playbook. Durant cette étape, Ansible va charger un ensemble de variables par défaut qui peuvent être utiles lors de l’exécution des tâches Ansible (OS utilisé, version de l’OS, capacité mémoire, etc.). Pour connaître la liste des variables qui sont récupérées par défaut, je vous invite à consulter la documentation Ansible.

En plus de ces facts chargés par défaut, Ansible met à disposition les custom facts. Ceux-ci permettent à l’utilisateur de déterminer des facts personnalisés de manière temporaire ou pérenne. C’est dans ce deuxième cas d’usage que nous allons les utiliser.

Comme évoqué plus haut, la première étape est d’implémenter la notion d’état grâce aux custom facts. Créons donc un custom fact nodejs, avec un paramètre spécifiant que Node.js a été installé ainsi qu’un paramètre spécifiant la date à laquelle il a été installé.

Pour cela, il suffit d’ajouter une tâche ansible.builtin.set_fact :

1
2
3
4
5
- name: Set fact for installation status
  ansible.builtin.set_fact:
    nodejs:
      installed: true
      installation_date: "{{ ansible_date_time.date }}"

Cependant, ce custom fact nodejs nouvellement créé ne sera accessible que durant l’exécution courante et ne sera donc pas interrogeable lors de l’exécution suivante.

Pour le rendre persistant pour les prochaines exécutions, nous allons utiliser un répertoire spécifique d’Ansible : /etc/ansible/facts.d. Ce répertoire est scanné à chaque démarrage et l’ensemble des fichiers *.fact sont chargés en plus des facts par défaut.

Pour cela, il suffit d’ajouter les deux tâches suivantes :

  1. Création du répertoire /etc/ansible/facts.d (s’il n’existe pas déjà) :
1
2
3
4
5
6
7
- name: Create facts directory
  ansible.builtin.file:
    path: /etc/ansible/facts.d
    state: directory
    owner: root
    group: root
    mode: '0755'
  1. Création du fichier /etc/ansible/facts.d/nodejs.fact au format JSON contenant le custom fact précédemment créé :
1
2
3
4
5
6
7
- name: Save fact to file
  ansible.builtin.copy:
    content: "{{ nodejs }}"
    dest: /etc/ansible/facts.d/nodejs.fact
    owner: root
    group: root
    mode: '0644'

Nous disposons maintenant d’une variable ansible_local['nodejs'] qui sera chargé lors de la prochaine exécution d’Ansible qui ciblera ce serveur.

Il suffit maintenant de spécifier à Ansible les tâches que nous ne souhaitons exécuter qu’une seule fois grâce à l’option when :

1
when: not ansible_local['nodejs']['installed'] | default(false)

Nous obtenons ainsi le résultat suivant :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
---

- name: Install `Node.js` using NVM
  hosts: all
  become: true

  tasks:
  - name: Install dependencies
      ansible.builtin.apt:
        name:
          - curl
          - build-essential
        state: present

  - name: Download and install nvm
      ansible.builtin.get_url:
        url: <https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.1/install.sh>
        dest: /tmp/install.sh
        mode: '0755'
      when: not ansible_local['nodejs']['installed'] | default(false)

  - name: Execute nvm installation script
      ansible.builtin.command: /tmp/install.sh
      changed_when: not ansible_local['nodejs']['installed'] | default(false)
      when: not ansible_local['nodejs']['installed'] | default(false)

  - name: Install `node.js`
      ansible.builtin.shell: |
        . ~/.nvm/nvm.sh && nvm install node        
      changed_when: not ansible_local['nodejs']['installed'] | default(false)
      when: not ansible_local['nodejs']['installed'] | default(false)

  - name: Set fact for installation status
      ansible.builtin.set_fact:
        nodejs:
          installed: true
          installation_date: "{{ ansible_date_time.date }}"

  - name: Create facts directory
      ansible.builtin.file:
        path: /etc/ansible/facts.d
        state: directory
        owner: root
        group: root
        mode: '0755'

  - name: Save fact to file
      ansible.builtin.copy:
        content: "{{ nodejs }}"
        dest: /etc/ansible/facts.d/nodejs.fact
        owner: root
        group: root
        mode: '0644'

  - name: Cleanup install.sh
      ansible.builtin.file:
        path: /tmp/install.sh
        state: absent

Lors de la première exécution sur un serveur, Ansible va réaliser 9 tâches dont 5 avec changement :

1
2
PLAY RECAP *********************************************************************************************************************************************************
127.0.0.1                  : ok=9    changed=5    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

Lors de la deuxième exécution sur le même serveur, nous obtenons le résultat suivant :

1
2
PLAY RECAP *********************************************************************************************************************************************************
127.0.0.1                  : ok=6    changed=0    unreachable=0    failed=0    skipped=3    rescued=0    ignored=0

Les tâches ont donc bien été ignorées du fait de la présence du custom fact.