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 :
- Collecter l'état courant (
ansible.builtin.gather_facts,ansible.builtin.stat,ansible.builtin.command+register). - Calculer si une action est nécessaire (
ansible.builtin.set_factavec logique conditionnelle). - 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 |