Aller au contenu principal

Ecriture, Exécution et Debug de playbook Ansible

Dans la précédente partie, vous aviez découvert l’installation et l’exécution de modules Ansible. Aujourd'hui attardons sur l’écriture de playbook Ansible en utilisant toutes les fonctions mises à notre disposition.

Si vous utilisez un environnement virtuel python, avant tout exécution de vos playbooks Ansible ou installation de librairies n’oublier pas d’activer l’environnement virtuel python avec la commande suivante :

. venv/bin/activate

Construction d’un playbook Ansible

Nous avons vu qu’un playbook ansible est une séquence de tâches ou de rôles décrits dans un fichier. Pour le moment, nous n’utiliserons que des tâches (tasks). Un playbook ansible est un fichier au format yaml. Le YAML est un langage de description comme l’est le HTML. En YAML Le plus important est de respecter l’indentation sans quoi la structure n’est plus compréhensible. Le débogage est de ce fait complexe quand le fichier devient volumineux, mais des EDI, vscode par exemple, ont de très nombreux plugins permettant de vous aider à écrire vos fichiers et à détecter les erreurs.

Exemple de playbook ansible permettant d’ajouter le repo EPEL sur Centos :

---
- hosts: localhost
 tasks:
  - name: Install EPEL repo.
   ansible.builtin.package:
    name: epel-release
    state: present
  - name: Install htop (available in epel repo)
   yum:
    name: htop
    state: present

Ce playbook fait appel au module package que j'ai documenté dans ce billet.

Pour lancer un playbook ansible, on utilise la commande ansible-playbook. Pour lancer un playbook sur un inventaire, on ajoute comme pour la commande ansible l’argument -i avec le chemin de celui-ci. On indique ensuite la localisation du ou des playbooks Ansible. Il est possible d’enchaîner plusieurs groupes d’exécution sur différentes cibles. Pour cela il suffit de rajouter des blocs Ansible -hosts. Ce qui donne :

---
- hosts: target1
 tasks:
 - name: Install EPEL repo.
  ansible.builtin.package:
   name: epel-release
   state: present
- hosts: target2
 - name: Install htop
  ansible.builtin.package:
   name: htop
   state: present

Pour vérifier la syntaxe d’un ansible playbook, on peut ajouter --syntax-check et le lancer en mode dry-run avec --check

ansible-playbook -i inventories/production myplaybook.yml --check
# ou
ansible-playbook -i inventories/production myplaybook.yml --dry-run

Si vous voulez écrire du code de qualité je vous recommande vivement d'installer ansible-lint et/ou spotter

Les variables Ansible

Les facts d'Ansible

Les « gather facts », sont des variables qu’Ansible recueille sur les machines cibles : le système d’exploitation, les adresses IP, la mémoire, le disque, etc. Ensuite, il stocke ces informations dans des variables qu’on nomme facts. Le moyen le plus simple est de lancer le module setup sur localhost : ansible -m setup localhost

Ces variables prédéfinies peuvent ensuite être utilisées dans les playbook ansible. Pour cela, il suffit de les mettre entre doubles accolades :

- name: Show ansible_selinux var
 ansible.builtin.debug:
  msg: "{{ ansible_selinux }}"

Ce qui donne :

ok: [localhost] => {
  "msg": {
    "config_mode": "enforcing",
    "mode": "enforcing",
    "policyvers": 28,
    "status": "enabled",
    "type": "targeted"
  }
}

Dans le cas où vous n’utilisez pas ces facts vous pouvez désactiver sa collecte en ajoutant dans l’entête de votre playbook Ansible [gather_facts: no]. Cela va accélérer l’exécution de votre playbook ansible.

Vos variables

Le plus simple pour ajouter une variable à votre playbook ansible est de définir une section vars. Ensuite, vous pouvez les récupérer comme pour les facts avec des doubles accolades.

---
- hosts: all
 vars:
  nginx_ssl: /etc/nginx/ssl
  nginx_conf_file: /etc/nginx/nginx.conf

Il est possible de définir des variables Ansible dans un fichier séparé en ajoutant la section vars_files.


 ---
 hosts: all

 vars_files:
  - myvarsfile.yml

Définir des variables Ansible par group et host

Il est possible de créer des variables dans des fichiers séparés qui seront automatiquement chargés par Ansible. Il faut qu’il se nomme host_vars et group_vars et doivent se trouver là où se trouve l’inventaire.

Pour définir une variable à un host ou un groupe spécifique, il suffit de nommer le fichier par le nom du host ou du groupe.

Ensuite leur syntaxe est fonction de l’extension du fichier.

---
test2: test2

Sauvegarde du résultat d’une tache Ansible dans une variable

Pour enregistrer une variable, il suffit d’ajouter à votre tache le mot-clé register. Dans le cas de dictionnaire, il suffit d’ajouter un . entre les champs (test.ping)

---
- hosts: localhost

 tasks:
  - name: Ping localhost
   ansible.builtin.ping:
   register: test
  - name: display var
   ansible.builtin.debug:
    msg: "{{ test.ping }}"

Les boucles Ansible

Dans certains cas, nous avons besoin de réaliser une tâche sur plusieurs cibles, par exemple installer plusieurs paquets et tout cela en une seule opération. Ansible intègre les boucles de différentes manières :

Les boucles standards : with_items ou loop

Le premier type de boucle permet de répéter une action sur une liste de valeurs, par exemple une liste de packages :

...
- name: add several package
  ansible.builtin.package:
   name={{ item }}
   state=present
  with_items:
  - nginx
  - python

Les boucles imbriquées : with_nested

Cet exemple permettra de comprendre le fonctionnement de ce type de boucle :

vars:
 keys:
  - key1
  - key2
  - key3
tasks:
 - name: Distribute SSH keys among multiple users
  ansible.builtin.lineinfile:
   dest: /home/{{ item[0] }}/.ssh/authorized_keys
   line: {{ item[1] }}
   state: present
  with_nested:
   - [ ’calvin’, ’josh’, ’alice’ ]
   - ’{{ keys }}’

Ici Ansible va boucler sur chaque utilisateur et remplira leur fichier authorized_keys avec les 3 clés définies dans la liste.

Les boucles sur dictionnaire: with_dict

Les boucles sur dictionnaires : En terminologie Python, un dictionnaire est un ensemble défini de variables possédant plusieurs valeurs :

vars:
 users:
   alice:
     name: Alice
     telephone: 123-456-7890
   bob:
     name: Bob
     telephone: 987-654-3210
tasks:
 - name: Print phone records
   ansible.builtin.debug:
    msg="User {{ item.key }} is {{ item.value.name }} ({{ item.value.telephone }})"
   with_dict: "{{users}}"

Do until

Do until peut être utilisé pour attendre qu’une condition soit vraie où qu’elle ait atteint le nombre maxi d’itérations pour sortir de la boucle.

- ansible.builtin.shell: /usr/bin/foo
 register: result
 until: result.stdout.find("OK")
 retries: 5
 delay: 10

Le playbook exemple ci-dessus exécute le module shell de manière répétée jusqu’à ce que le résultat du module retourne "OK" dans sa sortie standard ou que la tâche ait été itérée 5 fois avec un délai de 10 secondes. La variable result aura également une nouvelle clé «attemps» qui aura le nombre des tentatives effectuées par la boucle.

Et bien d’autres

Il existe toute une série de type de boucle répondant à des besoins spécifiques. Si vous avez des choses complexes à construire n’hésitez pas à jeter un œil à cette section de la documentation Ansible.

Les conditions d'Ansible: when

Parfois, vous voudrez qu’une tache particulière ne s’exécute ou pas dans certaines conditions. Par exemple ne pas installer un certain paquet si le système d’exploitation correspond à une version particulière, ou encore de procéder à certaines étapes de nettoyage si un système de fichiers est saturé.

tasks:
 - name: "shut down all Debian"
  ansible.builtin.command: /sbin/shutdown -t now
  when: ansible_facts[’os_family’] == "Debian"

La syntaxe des conditions (and or, in, not in, is ... ) reprend celle de Jinja2 qui nous servira à créer par la suite des modèles.

Il est possible de mettre des conditions en fonction de l’exécution ou de l’échec ou du bypass d’une tache précédente. Pour ignorer une erreur d’une tache, il suffit d’ajouter la clé ignore_errors à true (Ne pas utiliser dans tous les cas).

tasks:
 - ansible.builtin.command: /bin/false
  register: result
  ignore_errors: True

 - ansible.builtin.command: /bin/something
  when: result is failed

 - ansible.builtin.command: /bin/something_else
  when: result is succeeded

 - command: /bin/still/something_else
  when: result is skipped

On peut regrouper des actions utilisant les mêmes conditions en recourant à des blocks Ansible.

Jinja

Jinja est à la base un module python écrit pour Django permettant de produire rapidement du texte dynamique. L’idée principale est de fournir à un module, un modèle et une liste de valeurs pour qu’il construise dynamiquement un résultat. Jinja fournit des possibilités plus avancées, comme appliquer des filtres sur une variable, appliquer des boucles sur des listes et bien d’autre encore. Et cela va nous servir dans les playbook Ansible.

Les filtres Ansible

Les filtres dans Ansible sont utilisés pour transformer des données. Sa syntaxe est "{{ var | filter }}".

---
tasks:
 - ansible.builtin.shell: cat /some/path/to/file.json
  register: result

 - ansible.builtin.set_fact:
   myvar: "{{ result.stdout | from_json }}"

D’autres sont là pour contraindre leur existence, pour assigner une valeur par défaut ou encore leur omission :

---
- name: touch files with an optional mode
 ansible.builtin.file:
  dest: "{{ item.path | mandatory }}"
  state: touch
  mode: "{{ item.mode | default(omit) }}"
 loop:
  - path: /tmp/foo
  - path: /tmp/bar
  - path: /tmp/baz
   mode: "0444"

Il existe toute une série de filtres, des filtres mathématiques, pour des opérations sur des listes, les dictionnaires, les adresses mac ou ip, des expressions régulières, ... Leur documentation se trouve dans ses trois billets : 1, 2 et 3

Si vous ne trouvez pas votre bonheur, vous pouvez développer vos propres filtres.

Les tests Ansible

Les tests en Jinja permettent d’évaluer les expressions de modèle et de renvoyer True ou False. La principale différence entre les tests et les filtres réside dans le fait que les tests Jinja sont utilisés à des fins de comparaison, alors que les filtres sont utilisés pour la manipulation de données.

Ils sont tous écrits de la forme valeur is type_de_test (paramètres)

Tests sur les strings

Ansible propose trois types de recherche sur les strings : is match, is search et is regex

match retourne vrai s'il trouve le motif au début de la chaîne, contrairement à search qui lui réussit s'il trouve le motif n'importe où dans la chaîne. Ces tests acceptent des arguments ignorecase et multiline.

vars:
 url: "http://example.com/users/foo/resources/bar"

tasks:
 - debug:
   msg: "matched pattern 1"
  when: url is match("http://example.com/users/.*/resources/.*")

 - debug:
   msg: "matched pattern 2"
  when: url is search("/users/.*/resources/.*")

 - debug:
   msg: "matched pattern 3"
  when: url is search("/users/")

Les tests sur les booléens

Les tests truthy et falsy acceptent un paramètre facultatif appelé convert_bool qui tentera de convertir les indicateurs en booléens.

- debug:
  msg: "Truthy"
 when: value is truthy
 vars:
  value: "some string"

- debug:
  msg: "Falsy"
 when: value is falsy(convert_bool=True)
 vars:
  value: "off"

Comparer des versions

Pour comparer un numéro de version, il existe un test version, On peut ainsi vérifier que la version trouvée ansible_facts['distribution_version'] version est supérieure ou égale à celle attendue.

tasks:
  - debug:
    msg: "Attention votre distribution est trop vieille"
   when: ansible_facts['distribution_version'] is version('20.04', '>')

Ce test accepte trois paramètres : operator version_type strict

  • operator l'opérateur de comparaison parmi : <, lt, <=, le, >, gt, >=, ge, ==, =, eq, !=, <>, ne
  • version_type défini le type de versioning et accepte les valeurs suivantes: loose, strict, semver, semantic
  • strict ne peut être utilisé avec version_type
tasks:
  - debug:
    msg: "Attention votre distribution est trop vieille"
   when: sample_version_var is version('1.0', operator='lt', strict=True) }}

Tester si une liste contient une valeur

contains permet de vérifier l'existence d'une valeur dans une liste. Il accepte les filtres select, reject, selectattr et rejectattr.

vars:
 lacp_groups:
  - master: lacp0
   network: 10.65.100.0/24
   gateway: 10.65.100.1
   dns4:
    - 10.65.100.10
    - 10.65.100.11
   interfaces:
    - em1
    - em2

  - master: lacp1
   network: 10.65.120.0/24
   gateway: 10.65.120.1
   dns4:
    - 10.65.100.10
    - 10.65.100.11
   interfaces:
     - em3
     - em4

tasks:
 - debug:
   msg: "{{ (lacp_groups|selectattr('interfaces', 'contains', 'em1')|first).master }}"

Tests sur les ensembles

Pour voir si une liste inclut ou est incluse dans une autre liste, vous pouvez utiliser subset et superset :

vars:
  a: [1,2,3,4,5]
  b: [2,3]
tasks:
  - debug:
    msg: "A includes B"
   when: a is superset(b)

  - debug:
    msg: "B is included in A"
   when: b is subset(a)

Tests sur les fichiers

Ces tests permettent de tester le type de fichiers ou son état :

- debug:
  msg: "path is a directory"
 when: mypath is directory

- debug:
  msg: "path is a file"
 when: mypath is file

- debug:
  msg: "path is a symlink"
 when: mypath is link

- debug:
  msg: "path already exists"
 when: mypath is exists

- debug:
  msg: "path is {{ (mypath is abs)|ternary(’absolute’,’relative’)}}"

- debug:
  msg: "path is the same file as path2"
 when: mypath is same_file(path2)

- debug:
  msg: "path is a mount"
 when: mypath is mount

Tests sur les résultats de taches

On peut controler le statut d'une tache parmi : failed, changed, succeeded et skipped

tasks:

 - shell: /usr/bin/foo
  register: result
  ignore_errors: True

 - debug:
   msg: "it failed"
  when: result is failed

Les handlers Ansible

Les handlers d'Ansible permettent de déclencher des événements après qu'une tâche soit passé au status changed. En outre, bien que plusieurs tâches puissent nécessiter une même action, l’action en question ne sera lancée qu’après l’exécution de tous les blocs tâches.

Pour cela, il suffit d’ajouter un notify à votre tache Ansible avec le nom du handler et de définir le bloc handlers avec le même nom à la fin du fichier.

  - name: enable vhost
   command: a2ensite test_site
   notify:
    - restart apache

  handlers:
  - name: restart apache
   service:
    name: apache2
    state: restarted

Si vous souhaitez forcer le déclenchement d’un handler après une tache ajouter la ligne suivante :

  - name: enable vhost
   command: a2ensite test_site
   notify:
    - restart apache
  # Force restart handlers
  - meta: flush_handlers

Escalation de privilèges

Avant tout, il faut bien se rappeler qu'Ansible utilise principalement le protocole ssh pour se connecter à vos machines hôtes. La bonne pratique est de créer sur les machines hôtes un user ansible ne possédant pas les droits root, mais lui donner les droits sudo.

Dans ce cas, il faudra peut-être demander à Ansible de faire ce qu'on appelle de l'escalade de privilèges.

Pour cela, il est possible d'utiliser les paramètres Ansible.

become : mis à true pour activer l'escalade de privilèges become_user : défini l'utilisateur souhaité par défaut, il s'agit de root become_method: la méthode d'escalade de privilège. Par défaut sudo, mais on peut aussi utiliser su ou doas. On peut aussi développer ses propres plugins de connexion.

Exemples

Démarrer le service apache avec sudo

- name: Ensure the httpd service is running
 ansible.builtin.service:
  name: httpd
  state: started
 become: true

Utiliser le compte apache

- name: Run a command as the apache user
 ansible.builtin.command: somecommand
 become: true
 become_user: apache

Il est possible de définir ces paramètres directement dans les variables de groupes. Vous pouvez aussi les définir au niveau le plus haut de votre playbook ou dans la commande de lancement de votre playbook (j'aime moins) :

---
- hosts: all
 become: true
 become_method: enable

 tasks:
 - name: Install EPEL repo.
  ansible.builtin.package:
   name: epel-release
   state: present

ou dans la ligne de commande :

ansible-playbook -i staging --become --become-method=su --ask-become-pass

Plus d'infos sur become

Debug des playbooks Ansible

Vous avez plusieurs outils à votre disposition pour débuger vos playbooks.

Utilisons ce playbook comme exemple qui ne fonctionne pas bien sur :

---
- hosts: all
 gather_facts: true
 become: true

 vars:
  pkg_name: not_exist
 tasks:
  - name: Install a package
   ansible.builtin.package:
    name: "{{ pkg_name }}"

Le mode verbose

Un premier moyen de voir ce qui se passe lorsqu'on lance un playbook ansible, avec d'activer le mode verbose -v. Chaque v supplémentaire permet à d'augmenter la verbosité de la sortie.

ansible-playbook -vvv -i inventory --limit host1 test.yml
ansible-playbook 2.9.15
<host1> SSH: EXEC ssh -C -o ControlMaster=auto -o ControlPersist=60s -o StrictHostKeyChecking=no -o KbdInteractiveAuthentication=no -o PreferredAuthentications=gssapi-with-mic,gssapi-keyex,hostbased,publickey -o PasswordAuthentication=no -o ConnectTimeout=10 -o ControlPath=/home/vagrant/.ansible/cp/5401241998 host1 '/bin/sh -c '"'"'rm -f -r /home/vagrant/.ansible/tmp/ansible-tmp-1614782435.453363-165214-175234282313188/ > /dev/null 2>&1 && sleep 0'"'"''
<host1> (0, b'', b'')
fatal: [host1]: FAILED! => {
  "changed": false,
  "failures": [
    "No package not_exist available."
  ],
  "invocation": {
    "module_args": {
      "allow_downgrade": false,
      "autoremove": false,
      "bugfix": false,
      "conf_file": null,
      "disable_excludes": null,
      "disable_gpg_check": false,
      "disable_plugin": [],
      "disablerepo": [],
      "download_dir": null,
      "download_only": false,
      "enable_plugin": [],
      "enablerepo": [],
      "exclude": [],
      "install_repoquery": true,
      "install_weak_deps": true,
      "installroot": "/",
      "list": null,
      "lock_timeout": 30,
      "name": [
        "not_exist"
      ],
      "releasever": null,
      "security": false,
      "skip_broken": false,
      "state": null,
      "update_cache": false,
      "update_only": false,
      "validate_certs": true
    }
  },
  "msg": "Failed to install some of the specified packages",
  "rc": 1,
  "results": []
}

PLAY RECAP *************************************************************************************
host1           : ok=1  changed=0  unreachable=0  failed=1  skipped=0  resc

Avec ce niveau, vous obtenez déja bcp d'informations.

Check_mode

Le second moyen est de lancer votre playbook ansible en mode check. En fait, dans ce mode :

  • Les modules qui effectuent une action de modification, d’ajout ou de suppression vont uniquement vérifier que la source et la destination sont présentes, l’action ne sera pas effectuée.
  • Les modules qui effectuent peut-être des actions de modifications, mais sans possibilité de contrôle, comme modules command et shell, seront ignorés.
  • Ceux qui ne font que rendre une information comme les modules find et stat seront tout de même exécutés.
ansible-playbook main.yml --check

Pour contrôler, vous pouvez activer le mode diff et vous ne verrez aucune modification (sauf pour les tasks utilisant shell ou command):

ansible-playbook main.yml --check --diff

Si vous voulez désactiver le mode check sur une tache, il suffit de rajouter ceci à votre task :

 check_mode: no

Si certaines de vos taches retourne des erreurs, vous pouvez ajouter ceci à ces tasks pour poursuivre le playbook:

  ignore_errors: '{{ ansible_check_mode }}'

Le mode debugger

Vous pouvez activer le mode débogage pour chaque exécution de vos playbooks en mettant la variable enable_task_debugger à True dans le fichier ansible.cfg:

[defaults]
enable_task_debugger = True

Un autre moyen est de le rajouter dans votre playbook :

---
- hosts: all
 gather_facts: true
 become: true
 debugger: on_failed

 vars:
  pkg_name: not_exist
 tasks:
  - name: Install a package
   ansible.builtin.package:
    name: "{{ pkg_name }}"

Lorsqu'on lance ce playbook ansible, qui plante, car le package n'existe pas, on voit apparaître cette ligne :

TASK [Install a package] ***********************************************************************
fatal: [host2]: FAILED! => {"changed": false, "failures": ["No package not_exist available."], "msg": "Failed to install some of the specified packages", "rc": 1, "results": []}
[host2] TASK: Install a package (debug)>

A partir de là, nous pouvons utiliser les commandes suivantes pour debugger :

p : affiche des informations utilisées pendant l'exécution par votre tâche module task.args[key] = value: met à jour l'argument du module. task_vars[key] = value: met à jour les variables de votre playbook. update_task: si vous modifiez les task_vars alors utilisez cette commande pour recréer la tâche à partir de votre nouvelle structure de données. redo: exécutez à nouveau la tâche. continue: passe à la tâche suivante. quit: quitte le débogueur. L'exécution du playbook est abandonnée.

Ici modifions le user le nom du package avant de relancer :

TASK [Install a package] ***********************************************************************
fatal: [host2]: FAILED! => {"changed": false, "failures": ["No package not_exist available."], "msg": "Failed to install some of the specified packages", "rc": 1, "results": []}
[host2] TASK: Install a package (debug)> p task.args
{'name': 'not_exist'}
[host2] TASK: Install a package (debug)> task.args['name'] = 'bash'
[host2] TASK: Install a package (debug)> redo
ok: [host2]

Si vous avez plusieurs hosts, il faudra le répéter autant de fois.

Relancer depuis une étape particulière

Parfois si vous voulez relancer un playbook à partir d'une étape particulière et non depuis le début, il suffit d'utiliser l'option --start-at-task.

ansible-playbook playbook.yml --start-at-task="install packages"

Jouer un playbook de manière interactive

Avec cette option, Ansible s'arrête à chaque tâche et demande s'il doit l'exécuter ou pas. Par exemple, si vous avez une tâche appelée configure ssh, l'exécution du playbook s'arrêtera et demandera :

ansible-playbook playbook.yml --step

...

Perform task: configure ssh (y/n/c):

c permet de quitter le mode interactif !

L'utilitaire ansible-console

Je l'ai documenté dans ce billet. Cet outil permet de lancer les modules ansible de manière interactifs.

Changer la sortie d'Ansible

Si comme moi vous trouvez que la sortie standard de la commande ansible-playbook pas très pratique, vous pouvez en changer.

On va prendre cet exemple pour montrer les différences.

- name: Show ansible_selinux var
 ansible.builtin.debug:
  msg: "{{ ansible_selinux }}"

donne :

PLAY [Test stdout] *************************************************************

TASK [Gathering Facts] *********************************************************
ok: [localhost]

TASK [Show ansible_selinux var] ************************************************
ok: [localhost] => {
  "msg": {
    "status": "Missing selinux Python library"
  }
}

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

Sortie au format YAML

Il suffit dans le fichier ansible.cfg

[defaults]
stdout_callback = yaml
PLAY [Test stdout] *************************************************************

TASK [Gathering Facts] *********************************************************
ok: [localhost]

TASK [Show ansible_selinux var] ************************************************
ok: [localhost] =>
 msg:
  status: Missing selinux Python library

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

Sortie au format JSON

[defaults]
stdout_callback = json
{
  "custom_stats": {},
  "global_custom_stats": {},
  "plays": [
    {
      "play": {
        "duration": {
          "end": "2021-04-10T12:36:31.761991Z",
          "start": "2021-04-10T12:36:30.529488Z"
        },
        "id": "7d9a4545-499f-5cc6-45f2-000000000005",
        "name": "Test stdout"
      },
...

D'autres formats de sortie

La liste complète de la communauté des stdout_callback Ansible

D'autres qu'il faut placer dans le répertoire plugins/callback :

Utilisation de vagrant pour provisionner des machines Ansible de test

J'utilise beaucoup vagrant pour mettre au point mes playbooks Ansible. Même si le nombre de serveurs est restreint par les ressources de votre machine hôte, c'est un excellent moyen pour finaliser vos playbooks avant de les lancer sur des machines réelles. Je vois propose de lire mon billet sur vagrant