Aller au contenu

Conventions Ansible

Règles normatives ordonnées par impact decroissant : le coût d'une correction retroactive multiplie par la surface de maintenance de l'équipe.


Calcul de l'état desire

Le pattern fondamental d'un rôle idempotent repose sur trois phases strictement séparées :

  1. Collecter l'état courant (ansible.builtin.gather_facts, ansible.builtin.stat, ansible.builtin.command + register).
  2. Calculer si une action est nécessaire (ansible.builtin.set_fact avec logique conditionnelle).
  3. Agir uniquement si l'état desire différé de l'état courant (when, changed_when).

Si ces phases sont melangees, l'idempotence est impossible a garantir et le rôle doit être reecrit entièrement.

Exemple : déploiement conditionnel d'un binaire

# Phase 1 — Collecter
- name: Verifier la presence du binaire
  ansible.builtin.stat:
    path: /usr/local/bin/myapp
  register: myapp_binary_stat

- name: Lire la version installee
  ansible.builtin.command: /usr/local/bin/myapp --version
  register: myapp_installed_version
  changed_when: false
  failed_when: false
  when: myapp_binary_stat.stat.exists

# Phase 2 — Calculer
- name: Determiner si une installation est necessaire
  ansible.builtin.set_fact:
    myapp_needs_install: >-
      {{
        not myapp_binary_stat.stat.exists
        or (myapp_installed_version.stdout | default('') != myapp_version)
      }}

# Phase 3 — Agir
- name: Telecharger et installer le binaire
  ansible.builtin.get_url:
    url: "https://releases.example.com/myapp/{{ myapp_version }}/myapp-linux-amd64"
    dest: /usr/local/bin/myapp
    mode: '0755'
  when: myapp_needs_install

changed_when signale un vrai changement d'état, pas une simple exécution. Une tâche qui lit sans modifier doit porter changed_when: false. Une tâche qui modifie conditionnellement doit porter changed_when base sur la sortie enregistree.

Coût d'une erreur sur ce pattern

Si les phases collect / compute / act sont confondues dans un seul bloc, le rôle produit des changed factices à chaque exécution. Les pipelines CI deviennent inutilisables comme detecteurs de drift, et toute la logique de conditionnement doit être recrite.


Structure des fichiers tasks

Un fichier main.yml de 200 lignes melangeant assertions, installation, configuration et nettoyage est impossible a maintenir et a tester independamment. Chaque fichier doit avoir une responsabilité unique.

Découpage standard

Fichier Responsabilité
main.yml Point d'entree, appel des assertions, routage par état
assert.yml Validation des parametres d'entree
present.yml Installation et configuration
absent.yml Desinstallation et nettoyage
validate.yml Vérification post-déploiement
summary.yml Affichage du recapitulatif (optionnel)

main.yml — routage par état

# tasks/main.yml
---
- name: Assertions de pre-condition
  ansible.builtin.import_tasks: assert.yml
  tags: [always]

- name: Installer et configurer le service
  ansible.builtin.import_tasks: present.yml
  when: myapp_state == 'present'
  tags: [install, configure]

- name: Supprimer le service
  ansible.builtin.import_tasks: absent.yml
  when: myapp_state == 'absent'
  tags: [remove]

- name: Valider le deploiement
  ansible.builtin.import_tasks: validate.yml
  when: myapp_state == 'present'
  tags: [validate]

- name: Afficher le recapitulatif
  ansible.builtin.import_tasks: summary.yml
  when: myapp_state == 'present'
  tags: [always]

Le tag always sur les assertions garantit leur exécution même quand des tags spécifiques sont passes en ligne de commande. Renommer ou fusionner des fichiers après les premiers déploiements casse les références include_tasks, les tags dans les pipelines, et les tests unitaires — d'ou la priorité élevée de cette convention.


Prefixage des variables

Toute variable d'un rôle doit être prefixee. Sans prefixage, deux rôles charges dans le même play se polluent mutuellement via les variables globales Ansible.

Règles

Type de variable Emplacement Préfixe Exemple
Variable publique defaults/main.yml nom du rôle time_sync_ntp_servers
Variable interne vars/main.yml _ + nom du rôle _time_sync_packages

Variables publiques (defaults/main.yml)

# roles/time_sync/defaults/main.yml
---
# Serveurs NTP a utiliser (liste ordonnee par priorite)
time_sync_ntp_servers:
  - 0.pool.ntp.org
  - 1.pool.ntp.org

# Fuseau horaire du systeme
time_sync_timezone: UTC

# Activer la synchronisation au demarrage
time_sync_enable_on_boot: true

Variables internes (vars/main.yml)

# roles/time_sync/vars/main.yml
---
# Paquets a installer selon la famille d'OS (non surchargeables)
_time_sync_packages:
  RedHat: chrony
  Debian: chrony

# Chemin du fichier de configuration principal
_time_sync_config_path: /etc/chrony.conf

# Utilisateur systeme du service
_time_sync_service_user: chrony

Contreexemple a éviter

# MAUVAIS — variable non prefixee dans defaults/main.yml
ntp_servers:
  - 0.pool.ntp.org

# Si un autre role definit ntp_servers, l'une des deux valeurs est ecrasee
# silencieusement. Le comportement devient non deterministe.

Priorité Ansible

vars/main.yml a une priorité supérieure à defaults/main.yml. Ce comportement est cohérent avec l'intent : les variables internes prefixees _ ne doivent pas être surchargées par l'utilisateur. Le préfixe _ est un signal visuel qui renforce cette contrainte.


Nommage des rôles

Un rôle nomme d'après un outil crée un couplage implicite entre le nom et l'implémentation. Si l'outil change (passage de chrony a systemd-timesyncd), le nom du rôle devient trompeur.

Principes de nommage

  • Anglais, snake_case
  • Fonctionnel : le nom décrit ce que le rôle accomplit, pas l'outil qu'il utilisé
  • Verifiable par la question : "Ce nom reste-t-il valide si on change d'outil ?"

Tableau de conversion

Mauvais (technique) Bon (fonctionnel)
node_exporter system_metrics
promtail system_logs
firewalld packet_filter
chrony time_sync
docker container_engine
certbot tls_certificates
open_vm_tools guest_agent

FQCN dans les playbooks

# site.yml
---
- name: Configurer les serveurs de monitoring
  hosts: monitoring
  roles:
    - nuevolia.monitoring.system_metrics
    - nuevolia.monitoring.system_logs

- name: Durcir les serveurs
  hosts: all
  roles:
    - nuevolia.system.packet_filter
    - nuevolia.system.time_sync
    - nuevolia.system.tls_certificates

Gestion des erreurs

Ne jamais ignorer silencieusement une erreur. Chaque ignore_errors: true sans vérification du résultat enregistre est un bug latent.

failed_when : personnaliser la condition d'échec

- name: Verifier que le port est en ecoute
  ansible.builtin.command: ss -tlnp
  register: ss_result
  changed_when: false
  failed_when:
    - ss_result.rc != 0
    - "'8080' not in ss_result.stdout"

ignore_errors + register : toujours vérifier après

- name: Tenter l'arret gracieux du service
  ansible.builtin.systemd:
    name: myapp
    state: stopped
  register: stop_result
  ignore_errors: true

- name: Forcer l'arret si l'arret gracieux a echoue
  ansible.builtin.command: kill -9 {{ myapp_pid }}
  when: stop_result is failed

block / rescue / always : pattern try/catch/finally

- name: Deployer myapp avec rollback automatique
  block:
    - name: Arreter l'ancienne version
      ansible.builtin.systemd:
        name: myapp
        state: stopped

    - name: Remplacer le binaire
      ansible.builtin.copy:
        src: "{{ myapp_binary_src }}"
        dest: /usr/local/bin/myapp
        mode: '0755'

    - name: Demarrer la nouvelle version
      ansible.builtin.systemd:
        name: myapp
        state: started

  rescue:
    - name: Restaurer le binaire precedent
      ansible.builtin.copy:
        src: "{{ myapp_binary_backup }}"
        dest: /usr/local/bin/myapp
        mode: '0755'

    - name: Redemarrer l'ancienne version
      ansible.builtin.systemd:
        name: myapp
        state: restarted

    - name: Signaler l'echec du deploiement
      ansible.builtin.fail:
        msg: "Deploiement de myapp {{ myapp_version }} echoue  rollback effectue."

  always:
    - name: Enregistrer le resultat dans le journal d'audit
      ansible.builtin.lineinfile:
        path: /var/log/ansible-deploy.log
        line: "{{ ansible_date_time.iso8601 }} myapp {{ myapp_version }} {{ 'OK' if not ansible_failed_task is defined else 'ECHEC' }}"
        create: true
        mode: '0644'

any_errors_fatal : stopper tous les hôtes sur un échec

# site.yml
---
- name: Deployer myapp en production
  hosts: app_servers
  any_errors_fatal: true   # un echec sur un hote arrete tous les autres
  roles:
    - nuevolia.application.myapp

Boucles

loop remplacé with_items depuis Ansible 2.5. with_items est maintenu pour compatibilité mais ne doit plus apparaître dans un code nouveau.

Liste simple

- name: Creer les repertoires applicatifs
  ansible.builtin.file:
    path: "{{ item }}"
    state: directory
    mode: '0755'
    owner: app
    group: app
  loop:
    - /opt/myapp/bin
    - /opt/myapp/conf
    - /opt/myapp/logs
    - /opt/myapp/data

Dictionnaire avec dict2items

- name: Creer les utilisateurs systeme
  ansible.builtin.user:
    name: "{{ item.key }}"
    uid: "{{ item.value.uid }}"
    shell: "{{ item.value.shell }}"
    groups: "{{ item.value.groups }}"
  loop: "{{ myapp_system_users | dict2items }}"

Sous-éléments avec subelements

- name: Ajouter les cles SSH autorisees par utilisateur
  ansible.posix.authorized_key:
    user: "{{ item.0.name }}"
    key: "{{ item.1 }}"
    state: present
  loop: "{{ myapp_users | subelements('authorized_keys') }}"

loop_control : réduire la verbosité des logs

- name: Telecharger les artefacts de release
  ansible.builtin.get_url:
    url: "{{ item.url }}"
    dest: "{{ item.dest }}"
    checksum: "{{ item.checksum }}"
  loop: "{{ myapp_artifacts }}"
  loop_control:
    label: "{{ item.dest | basename }}"   # affiche le nom du fichier, pas l'URL complete
    index_var: artifact_index
    pause: 1                              # delai en secondes entre chaque iteration

FQCN obligatoire

Tout module doit être référence par son FQCN (Fully Qualified Collection Name). Sans FQCN, Ansible résout le module dans l'ordre de chargement des collections, ce qui peut produire des comportements différents selon l'environnement d'exécution.

Exemple

# BON
- name: Installer les paquets requis
  ansible.builtin.apt:
    name: "{{ _myapp_packages }}"
    state: present
    update_cache: true

# MAUVAIS — resolution ambigue si plusieurs collections definissent apt
- name: Installer les paquets requis
  apt:
    name: "{{ _myapp_packages }}"
    state: present

Préfixes courants : ansible.builtin.*, ansible.posix.*, community.general.*, community.crypto.*.

ansible-lint --fix corrige automatiquement les modules sans FQCN — c'est pourquoi cette convention est classee en bas de liste malgre son importance : le coût de correction retroactive est quasi nul.


Nommage des tâches

Chaque tâche DOIT porter un attribut name. Une tâche sans nom est invisible dans les rapports de pipeline et dans la sortie Ansible : impossible de diagnostiquer un échec sans relire le code.

Convention

Infinitif du verbe décrivant l'action, suivi du complement d'objet.

# BON
- name: Deployer le fichier de configuration principal
  ansible.builtin.template:
    src: myapp.conf.j2
    dest: /etc/myapp/myapp.conf
    mode: '0644'

- name: Valider la structure JSON du manifeste
  ansible.builtin.command: python3 -m json.tool /opt/myapp/manifest.json
  changed_when: false

- name: Activer et demarrer le service myapp
  ansible.builtin.systemd:
    name: myapp
    enabled: true
    state: started

# MAUVAIS
- name: Run command
  ansible.builtin.command: /opt/myapp/init.sh

- name: Task 1
  ansible.builtin.copy:
    src: myapp.conf
    dest: /etc/myapp/myapp.conf

Conventions YAML

Booleens : true/false uniquement

La règle yaml[truthy] d'ansible-lint rejette yes, no, on, off. Seuls true et false sont acceptes.

# BON
myapp_enable_tls: true
myapp_manage_firewall: false

# MAUVAIS — rejete par yaml[truthy]
myapp_enable_tls: yes
myapp_manage_firewall: no

Modes de fichiers : toujours entre guillemets

# BON — mode interprete comme chaine octale
ansible.builtin.file:
  path: /opt/myapp
  mode: '0755'

# MAUVAIS — YAML interprete 0755 comme l'entier decimal 493
ansible.builtin.file:
  path: /opt/myapp
  mode: 0755

Indentation et alignement

# 2 espaces d'indentation, tirets alignes avec la cle du bloc parent
- name: Exemple de structure bien indentee
  ansible.builtin.template:
    src: myapp.conf.j2
    dest: /etc/myapp/myapp.conf
    owner: app
    group: app
    mode: '0640'
  notify: Recharger myapp
  tags:
    - configure
    - myapp

Enforcement automatique

Un fichier .editorconfig avec indent_size = 2 et insert_final_newline = true couvre la majorité des violations de style YAML avant même le lint. Combiner .editorconfig + ansible-lint élimine les révisions de style manuelles.


Tâches asynchrones

Pour les opérations longues (mise à jour de paquets, compilation, sauvegarde), une tâche synchrone bloque la connexion SSH jusqu'à la fin. async lance la tâche en arriere-plan et poll contrôle la fréquence de vérification.

Lancement asynchrone avec attente

- name: Mettre a jour tous les paquets systeme
  ansible.builtin.dnf:
    name: "*"
    state: latest
  async: 600      # timeout maximum en secondes
  poll: 15        # verifier toutes les 15 secondes
  register: dnf_update_job

- name: Attendre la fin de la mise a jour
  ansible.builtin.async_status:
    jid: "{{ dnf_update_job.ansible_job_id }}"
  register: dnf_update_result
  until: dnf_update_result.finished
  retries: 40
  delay: 15

Fire-and-forget avec vérification différée

- name: Lancer la sauvegarde en arriere-plan
  ansible.builtin.command: /opt/backup/run-backup.sh --full
  async: 3600
  poll: 0         # ne pas attendre — continuer le playbook
  register: backup_job

# ... autres taches executees pendant la sauvegarde ...

- name: Verifier que la sauvegarde est terminee
  ansible.builtin.async_status:
    jid: "{{ backup_job.ansible_job_id }}"
  register: backup_result
  until: backup_result.finished
  retries: 60     # 60 tentatives x 60 secondes = 1 heure max
  delay: 60
  failed_when: backup_result.rc is defined and backup_result.rc != 0

Toujours définir retries et delay sur ansible.builtin.async_status pour éviter une boucle infinie si la tâche ne se termine jamais.


Délégation

Par défaut, chaque tâche s'exécuté sur l'hôte cible de l'inventaire. delegate_to redirige l'exécution vers un hôte différent sans changer le contexte des variables (inventory_hostname, facts, etc.).

delegate_to : exécuter sur un hôte différent

- name: Enregistrer l'entree DNS avant le deploiement
  community.general.nsupdate:
    key_name: "{{ dns_key_name }}"
    key_secret: "{{ dns_key_secret }}"
    server: "{{ dns_server }}"
    record: "{{ inventory_hostname }}"
    value: "{{ ansible_host }}"
  delegate_to: localhost   # appel API depuis le poste de controle

- name: Retirer l'hote du load balancer
  community.general.haproxy:
    state: disabled
    host: "{{ inventory_hostname }}"
    backend: myapp_backend
  delegate_to: "{{ haproxy_host }}"   # execute sur le serveur HAProxy

run_once : une seule exécution pour tout le groupe

- name: Appliquer les migrations de base de donnees
  ansible.builtin.command: /opt/myapp/bin/migrate --run
  delegate_to: "{{ groups['app_servers'] | first }}"
  run_once: true   # execute une seule fois meme si le play cible 10 hotes
  register: migration_result
  changed_when: "'Applied' in migration_result.stdout"

local_action : raccourci pour delegate_to localhost

- name: Appeler l'API externe de notification de deploiement
  ansible.builtin.uri:
    url: "https://hooks.example.com/deploy"
    method: POST
    body_format: json
    body:
      host: "{{ inventory_hostname }}"
      version: "{{ myapp_version }}"
      status: started
  local_action: ansible.builtin.uri
  run_once: true

Cas d'usage typiques

Action delegate_to
Appel API REST externe localhost
Mise à jour DNS dynamique localhost
Desactivation dans HAProxy serveur HAProxy
Migration de base de données premier nœud applicatif
Notification Slack / webhook localhost