This article is available in English

Variables privées avec Ansible

Ansible n’offre pas de syntaxe pour fournir différentes visibilités aux variables exposées à l’utilisateur d’un rôle et il n’est vraisemblablement pas prévu que ça arrive1 un jour. Il est néanmoins possible de simuler cette fonctionnalité avec une convention de nommage et un peu de rigueur.

Le fichier default/main.yml est le principal emplacement où définir les variables d’un rôle, notamment, comme son nom l’indique, celles dotées d’une valeur par défaut. À priori tout le monde considère comme publiques les informations contenues dans ce fichier, voir même que ce fichier est une documentation en soi des options du rôle. Je précise ici que je suis en désaccord avec ce point.

Afin de créer des variables moins facilement accessibles et modifiables certains utilisent le dossier vars dont la préséance est bien plus élevée. Néanmoins une variable surchargée dans extra_vars ou avec l’option en ligne de commande -e aura toujours le dernier mot, rendant cette tentative bien futile. Pour cette raison je ne cherche pas à cacher mes variables « privées » et me contente de les annoter visuellement avec un préfixe: un double caractère souligné « __ ».

Je m’en sers dans deux cas précis: indiquer clairement qu’une variable est obligatoire ou bien indiquer qu’une valeur dépend d’un paramètre non contrôlé par l’utilisateur.

Ces deux utilisations sont assez différentes et seront donc détaillées séparément.

Variables obligatoires

Ansible fournit des filtres permettant de manipuler les variables de différentes façon. Celui qui nous intéresse ici est le filtre mandatory permettant d’indiquer qu’une variable est obligatoire, sans quoi le rôle ne s’exécute pas.

Une façon naïve d’utiliser cette fonctionnalité est de simplement rajouter ce filtre là où la variable est utilisée pour la première fois et de ne pas la rapporter dans defaults/main.yml.

Exemple:

$ cat defaults/main.yml
---
default_groups:
  - staff
  - wheel
$ cat task/main.yml
---
- name: Create user
  user:
    name: "{{ user_name | mandatory }}"
    group: "{{ user_name }}"
    groups: "{{ default_groups }}"

Ici la variable default_groups est bien présente dans le fichier defaults/main.yml car une valeur lui est attribuée mais user_name existe uniquement dans tasks/main.yml. Cette approche est pour moi problématique. Comme indiqué plus haut le fichier defaults/main.yml est souvent utilisé pour documenter les options d’un rôle, avec parfois des exemples de valeur ou de combinaison en commentaire. Une variable qui n’y est pas présente est donc bien plus difficile à repérer et la compréhension du rôle s’en amoindri considérablement.

C’est ici que la variable privée fait son entrée: en définissant sa valeur comme celle de la variable publique accolée du filtre mandatory il est possible de conserver toutes les variables définies par le rôle dans le fichier defaults/main.yml.

En reprenant l’exemple voila ce que cela donne:

$ cat defaults/main.yml
---
default_groups:
  - staff
  - wheel
__user_name: "{{ user_name | mandatory }}"
$ cat task/main.yml
---
- name: Create user
  user:
    name: "{{ __user_name }}"
    group: "{{ __user_name }}"
    groups: "{{ default_groups }}"

Comme je ne suis pas d’accord avec la notion de simplement exposer le fichier defaults/main.yml comme seule documentation je structure mon fichier README.md afin d’être clair à propos des variables privées: elles ne s’y trouvent tout simplement pas.

Voici un fragment de README.md pour ce cas:

## Role Variables

| Variable | Description | Default |
|----------|-------------|---------|
| `default_groups` | List of groups to assign `{{ user_name }}` to | `['staff', 'wheel']` |
| `user_name` | User name | mandatory |

Variable dont la valeur n’est pas fixe mais ne dépend pas de l’utilisateur

La deuxième catégorie d’utilisation de ces variables privées est le cas où la valeur d’une variable dépend d’un paramètre externe: l’installation d’un logiciel plutôt qu’un autre, le support de plusieurs systèmes d’exploitations différents, etc …

Prenons l’exemple d’un rôle chargé de l’installation et de la configuration de l’outil imaginaire catodo, un service hors ligne de gestion de liste de tâche pour les propriétaires de chat.

Un tel rôle s’articule assez simplement2 ainsi:

$ cat defaults/main.yml
---
catodo_user: catodo
catodo_group: catodo
catodo_config_file: /etc/catodo.conf
$ cat tasks/main.yml
---
- name: Ensure catodo is installed
  package:
    name: catodo
    state: present

- name: Ensure catodo is properly configured
  template:
    src: catodo.conf.j2
    dest: "{{ catodo_config_file }}"
    owner: "{{ catodo_user }}"
    group: "{{ catodo_group }}"
  notify: Restart catodo

Le rôle est simple, clair et sans fioritures, mais il y a un problème: s’il fonctionne bien sous Debian ce n’est pas le cas sous OpenBSD où, pour une raison ou une autre, le paquet logiciel se nomme ecatodo, empêchant le rôle de fonctionner.

Une solution naïve est de créer deux tâches différentes conditionnées au système d’exploitation:

$ cat tasks/main.yml
---
- name: Linux - Ensure catodo is installed
  package:
    name: catodo
    state: present
  when: ansible_system == 'Linux'

- name: OpenBSD - Ensure ecatodo is installed
  package:
    name: ecatodo
    state: present
  when: ansible_system == 'OpenBSD'

Mais que faire s’il faut rajouter le support de FreeBSD où le nom peut être encore différent, sans parler du répertoire de configuration qui changerait pour /usr/local/etc/catodo.conf ? Cette méthode ne permet pas une maintenance apaisée du rôle.

Une solution plus souple d’utilisation et relativement simple se base sur deux astuces: le module include_vars et les variables privées.

Premièrement le module include_vars rend possible de charger dynamiquement des fichiers de variables depuis le répertoire vars, situés là afin qu’ils ne soient pas pris en compte automatiquement par Ansible. Secondement les variables privées peuvent servir ici d’indirection entre la valeur par défaut pour une situation donnée et le comportement attendu par l’utilisateur.

Ainsi en se basant sur les facts il est possible de charger un fichier spécifique à Linux ou à OpenBSD suivant le système d’exploitation installé sur le serveur cible.

Exemple amélioré:

$ cat defaults/main.yml
---
catodo_user: catodo
catodo_group: catodo
catodo_config_file: /etc/catodo.conf
catodo_package_name: "{{ __catodo_package_name }}"
$ cat tasks/main.yml
---
- name: Import OS-specific variables
  include_vars: "{{ ansible_system }}.yml"

- name: Ensure catodo is installed
  package:
    name: "{{ catodo_package_name }}"
    state: present

- name: Ensure catodo is properly configured
  template:
    src: catodo.conf.j2
    dest: "{{ catodo_config_file }}"
    owner: "{{ catodo_user }}"
    group: "{{ catodo_group }}"
  notify: Restart catodo
$ cat vars/OpenBSD.yml
---
__catodo_package_name: ecatodo
$ cat vars/Linux.yml
---
__catodo_package_name: catodo

Il est possible d’obtenir une granularité encore plus forte en créant des fichiers spécifiques à une distribution Linux afin de cibler différemment Debian de RedHat mais ce n’est pas le sujet de cette article.

Ma gestion de la documentation de cette variable dans le README.md change car la variable privée y est bien exposée, puisqu’après tout elle ne contient que la valeur par défaut pour un contexte spécifique.

## Role Variables

| Variable | Description | Default |
|----------|-------------|---------|
| `catodo_user` | CaTodo system user name | `catodo` |
| `catodo_group` | CaTodo system group | `catodo` |
| `catodo_config_file` | Path to the CaTodo configuration file | `/etc/catodo.conf` |
| `catodo_package_name` | Package name | `{{ __catodo_package_name }}` |

### Linux

| Variable | Default |
|----------|---------|
| `__catodo_package_name` | `catodo` |

### OpenBSD

| Variable | Default |
|----------|---------|
| `__catodo_package_name` | `ecatodo` |

Avec cette méthode des valeurs par défaut différentes suivant le contexte sont possibles tout en permettant à l’utilisateur de surcharger la variable si ce comportement ne plait pas.

Conclusion

J’utilise la même convention de nommage pour deux utilisations différentes du concept de variable privée, l’une pour définir les variables obligatoires de façon clair et l’autre pour gérer la compatibilité avec les interactions externes.

Il serait possible de pousser le vice plus loin en combinant les deux méthodes afin de s’assurer qu’Ansible détecte bien si le rôle est appliqué à un système d’exploitation non supporté, mais n’allons peut être pas aussi loin:

$ cat defaults/main.yml
---
catodo_package_name: "{{ __catodo_package_name | mandatory }}"
__catodo_package_name: "{{ ____catodo_package_name }}"

  1. https://github.com/ansible/proposals/issues/109#issuecomment-384075214 

  2. Je n’ai pas inclus le template catodo.conf.j2 ni le handler Restart catodo afin de maintenir l’exemple le plus simple possible et ainsi se concentrer sur le sujet de l’article. Ce n’est pas un oubli.