Guillaume Fenollar DevOps et SysAdmin Freelance

Guillaume FENOLLAR

Ingénieur Linux/DevOps Indépendant

− Montpellier −

Utiliser des commandes shell dans des conditions sous Ansible

Ansible est un configuration manager qu'on ne présente plus. Au même titre que Salt-stack, il permet de faire de nombreuses opérations permettant aussi bien les opérations complexes et unitaires que la maintenance en condition opérationelle. Il y a pourtant une fonctionnalité essentielle que je trouve assez mal documentée sur la doc officielle, c'est la gestion des conditions. La page actuelle n'étant pas assez claire, je me permets de rajouter mon grain de sel.

Pour ce lab, nous n’allons pas avoir besoin d’autre chose qu’une machine de test, avec Ansible installé, un inventory configuré, et c’est tout. Pour ceux qui débarquent sur le sujet, je vous conseille le très concis Guide Ansible pour Administrateurs Systèmes de RedHat.

Sur quoi sont basées les conditions

Allons droit au but. Les conditions se basent sur dans la plupart des cas 2 éléments:

  • Les facts, données fournies par les libraires Ansible (pour remonter par exemple le nom ou la configuration d’un hôte)
  • Les variables, données arbitraires en votre contrôle

Faisons le tour du propriétaire. Pour avoir accès aux facts de notre machine de test, lançons la commande suivante :

# Afficher les facts d'un hôte ou groupe
ansible -l <host> -m setup

Pour avoir accès aux variables, la commande ansible-inventory fera très bien l’affaire

# Afficher les variables assignées à une machine de l'inventory
ansible-inventory --vars --host <host>
# Afficher toutes les variables selon les hôtes de l'inventory
ansible-inventory --vars --list

Très bien, on voit nos data réellement utilisables au sein de nos playbooks.

Conditions sur des facts

C’est la chose qui est le mieux documenté, donc passons vite dessus. Dans le cas où ma machine est une distribution CentOS, je vais avoir ces facts de définis:

os_family: RedHat
distribution: CentOS

Libre à moi d’utiliser le fact que je préfère sur ces deux-là. Je peux donc définir un comportement différent selon mes différentes distributions en charge. Dans tous les cas, j’utilise le mot when qui va servir durant tout ce billet de blog, me permettant de définir une condition à l’exécution d’une tâche purement et simplement. À noter que ce when peut également servir à encadrer l’exécution d’un playbook ou d’un bloc de tâches (block).

tasks:
  - name: Installer l'ancêtre Apache sur RedHat/CentOS
    when: ansible_facts['os_family'] == "RedHat"
    package:
      name: httpd
      state: present
  - name: Installer l'ancêtre Apache sur Debian
    when: ansible_facts['distribution'] == "CentOS"
    package:
      name: apache2
      state: present

Il s’agit d’un exemple pas vraiment optimisé. Dans l’idéal, le mieux pour ce cas précis serait de définir le nom du paquet dans des variables de groupes, si mon inventaire me permet de gérer des groupes sur les distributions.

# dans group_vars/debian.yaml
apache_package: apache2
# dans group_vars/redhat.yaml
apache_package: httpd

Conditions sur les variables

Les variables étant définies par vos soins, vous pouvez approcher le comportement que vous voulez exactement grâce à un jeu de variables bien construites. L’héritage de variables d’Ansible permet de les définir niveau hôtes, groupes, groupes de groupes, à différents endroits (par défaut, override), les possibilités sont très nombreuses et expliquées dans la documentation.

Partons sur cet exemple de variables définies par exemple dans un groupe d’hôtes via group_vars/kube.yaml.

install_docker: true
docker_version: 20.10.1

On peut très bien imaginer le bloc de tâches suivant:

tasks:
- name: Install Docker and configure it
  when: install_docker
  block:
  - name: Package installation
    apt:
      name: docker.io={{ docker_version }}

Les conditions dans les templates Jinja

Une des grosses forces de Ansible tout aussi bien que Salt-Stack est d’emporter le moteur de templating Jinja. Étant un langage de template entier, il a une syntaxe différente et demande un temps d’adaptation et de chute dans certains pièges évidents. Voyons comment ça s’utilise en reprenant l’exemple ci-dessus:

# Permet de vérifier la présence d'une variable et son contenu si elle est booléenne
{% if install_docker is defined and install_docker %}
Selon l'exemple ci-dessus avec la variable install_docker à {{ install_docker }},
la version de Docker étant installée sera la version {{ docker_version | default('18.04') }}.
Un filtre vient d'être utilisé en suffixe de l'appel à une variable pour définir une valeur par défaut au cas où docker_version n'est pas renseigné.
{% endif %}

Spécifier des filtres “default” évite certaines mauvaises surprises mais peut rendre du code complexe compliqué à debugger, à vous de voir comment vous voulez gérer vos cas de variables vides. Vous retrouverez la liste complète des filtres jinja sur sa doc officielle.

Conditions sur des variables temporaires (registers)

Il est possible de renseigner des variables temporaires selon l’exécution d’une commande, par exemple pour exécuter un test dans la pure tradition de la commande du même nom, ou alors pour interroger le résultat d’une commande et utiliser son résultat dans une variable dépendante. Le mot clé ici est register, déclarant une variable sur les informations de la tâche exécutée.

- name: undeploy service
  shell:
    cmd: "test -f /etc/systemd/system/{{ my_custom_service }}.service"
  register: found_service
  failed_when: false
  changed_when: false

- name: undeploy service action block
  when: found_service is succeeded
  block:
  - name: stop and disable service
    service:
      name: "{{ my_custom_service }}"
      state: stopped
      enabled: no
  - name: rm service file
    file:
      path: "/etc/systemd/system/{{ my_custom_service }}.service"
      state: absent

Ici des commandes ont été executées dans le cas où une commande a renvoyé un code d’erreur 0 (succeeded), ce qui revient au même de faire une condition sur found_service.rc == 0, rc signifiant bien sûr return code. Grâce à ce concept, on garantit l’idempotence et on évite d’utiliser des variables long-terme venant alourdir inutilement l’inventory.

EDIT 2022-02-04 : Je recommande l’utilisation de failed_when et changed_when dans le cas où une tâche permet de déclencher l’exécution ou non de tâches sous-jacentes. Au lieu d’utiliser ignore_errors qui est la pratique la plus répandue mais aussi la plus moche, disons-le, avec failed_when on peut spécifier assez finement ce qui est répertorié comme un échec. Ici, il n’y a pas d’échec devant être reporter à l’administrateur, seulement un test permettant de vérifier qu’un service est déployé (ou non).

Il existe d’autres données liées à un register, notamment stdout, results et d’autres dont vous trouverez la liste exhaustive ici.

Voici un exemple qui me sert en production, vérifier que iptables-legacy est utilisé sur mes systèmes Debian plutôt que iptables-nft. Ces deux implémentations d’iptables sont des alernatives dans le jargon Debian, mais nft n’est pas compatible avec la brique Kube-proxy de clusters Kubernetes.

- name: iptables get binary
  command: /usr/bin/update-alternatives --query iptables
  register: iptables_alternatives

- name: iptables set alternative
  command: update-alternatives --set iptables /usr/sbin/iptables-legacy
  when: "not iptables_alternatives.stdout | regex_search('Value: /usr/sbin/iptables-legacy')"

Comme vous l’avez compris, je regarde la sortie d’une commande, puis y applique un filtre (cette fois-ci pas jinja mais Ansible) faisant une recherche regex. Je prends l’inverse de la condition avec le mot clé not et le tour est joué !

En espérant vous avoir aidé à y voir un peu plus clair sur les possibilités d’automatisation Ansible.