Bonnes pratiques Ansible¶
Écrire des playbooks fiables et maintenables avec idempotence, assertions, validation, lint et tests automatises.
Idempotence¶
Un playbook idempotent peut être exécuté plusieurs fois sans effet de bord : le résultat final est identique après la première exécution. C'est le principe fondamental d'Ansible. Un module qui détecté que l'état desire est déjà atteint ne modifie rien et retourné changed: false.
Bon exemple¶
# Le module apt verifie si nginx est deja installe avant d'agir.
# Deuxieme execution : changed=false, aucune action.
- name: Installer nginx
ansible.builtin.apt:
name: nginx
state: present
Mauvais exemple¶
# command n'a pas de logique d'idempotence integree.
# La deuxieme execution echoue car le dossier existe deja.
- name: Creer le dossier applicatif
ansible.builtin.command: mkdir /var/app/data
Version correcte¶
# Le module file verifie l'etat et ne cree que si absent.
- name: Creer le dossier applicatif
ansible.builtin.file:
path: /var/app/data
state: directory
mode: '0755'
owner: app
group: app
Commandes et shells incontournables¶
Quand un module natif n'existe pas, ansible.builtin.command et ansible.builtin.shell doivent être accompagnes de directives d'idempotence :
- name: Initialiser la base de donnees (une seule fois)
ansible.builtin.command: /opt/app/bin/init-db --silent
args:
creates: /var/lib/app/.db_initialized # skip si le marqueur existe
- name: Recharger systemd si le fichier a change
ansible.builtin.command: systemctl daemon-reload
changed_when: false # toujours considered not-changed
- name: Compacter les journaux anciens
ansible.builtin.shell: |
set -o pipefail
journalctl --vacuum-time=30d 2>&1 | grep -q "Deleted archived"
register: vacuum_result
changed_when: "'Deleted archived' in vacuum_result.stdout"
failed_when: vacuum_result.rc not in [0, 1]
Privilegier les modules aux commandes
Chaque module Ansible intégré une logique de détection d'état. Recourir a ansible.builtin.command ou ansible.builtin.shell doit rester exceptionnel. Avant d'utiliser une commande brute, vérifier si un module de collection existe : ansible.builtin.file, ansible.builtin.copy, ansible.builtin.template, community.general.*, etc.
Assertions avant action¶
Valider les entrees avant de toucher les hôtes evite de laisser un système dans un état intermédiaire incorrect. Le pattern consiste à exécuter un fichier assert.yml au début de chaque rôle ou playbook.
Fichier assert.yml¶
# tasks/assert.yml
---
- name: Valider app_state
ansible.builtin.assert:
that:
- app_state is defined
- app_state in ['present', 'absent', 'noop']
fail_msg: >
app_state='{{ app_state | default("undefined") }}' invalide.
Valeurs acceptees : present, absent, noop.
- name: Valider app_install_dir (chemin absolu obligatoire)
ansible.builtin.assert:
that:
- app_install_dir is defined
- app_install_dir is match('^/')
fail_msg: >
app_install_dir doit etre un chemin absolu.
Valeur recue : '{{ app_install_dir | default("") }}'.
- name: Valider app_fact_file (extension .fact obligatoire)
ansible.builtin.assert:
that:
- app_fact_file is defined
- app_fact_file is match('.*\.fact$')
fail_msg: >
app_fact_file doit se terminer par .fact.
Valeur recue : '{{ app_fact_file | default("") }}'.
- name: Valider les booleen de configuration
ansible.builtin.assert:
that:
- app_enable_tls is sameas true or app_enable_tls is sameas false
- app_manage_user is sameas true or app_manage_user is sameas false
fail_msg: >
app_enable_tls et app_manage_user doivent etre des booleens
(true/false), pas des chaines.
- name: Valider la liste des ports autorises
ansible.builtin.assert:
that:
- app_allowed_ports | length > 0
- app_allowed_ports | map('int') | list == app_allowed_ports
fail_msg: >
app_allowed_ports doit etre une liste non vide d'entiers.
Intégration dans main.yml¶
# tasks/main.yml
---
- name: Assertions de pre-condition
ansible.builtin.import_tasks: assert.yml
tags: [always]
- name: Deployer l'application
ansible.builtin.import_tasks: install.yml
when: app_state == 'present'
tags: [install]
Le tag always garantit que les assertions s'executent même quand des tags spécifiques sont passes en ligne de commande. Échouer tôt evite de corrompre l'hôte cible.
Validation post-deploy¶
Après l'application d'un rôle, vérifier que le déploiement est fonctionnel. Le pattern consiste à exécuter un fichier validate.yml à la fin de la sequence de tâches.
Fichier validate.yml¶
# tasks/validate.yml
---
- name: Executer le script de sante applicative
ansible.builtin.command: /opt/app/bin/healthcheck --json
register: healthcheck_result
changed_when: false
failed_when: healthcheck_result.rc != 0
- name: Parser la sortie JSON du healthcheck
ansible.builtin.set_fact:
health_data: "{{ healthcheck_result.stdout | from_json }}"
- name: Verifier les cles obligatoires dans la reponse
ansible.builtin.assert:
that:
- "'status' in health_data"
- "'version' in health_data"
- "'checks' in health_data"
fail_msg: >
La reponse du healthcheck ne contient pas toutes les cles attendues.
Reponse : {{ healthcheck_result.stdout }}
- name: Verifier le statut global
ansible.builtin.assert:
that:
- health_data.status == 'ok'
fail_msg: >
L'application rapporte un statut non nominal : {{ health_data.status }}.
- name: Verifier la coherence des donnees (total == somme des parties)
ansible.builtin.assert:
that:
- health_data.checks | map(attribute='score') | sum == health_data.total_score
fail_msg: >
Incoherence : total_score={{ health_data.total_score }} ne correspond
pas a la somme des scores individuels.
- name: Verifier que le fact est charge dans ansible_local
ansible.builtin.assert:
that:
- ansible_local.app is defined
- ansible_local.app.version is defined
fail_msg: >
Le fact ansible_local.app n'est pas disponible.
Verifier que {{ app_fact_file }} est bien deploye dans /etc/ansible/facts.d/.
Intégration dans main.yml (validation)¶
# tasks/main.yml (extrait)
---
- name: Assertions de pre-condition
ansible.builtin.import_tasks: assert.yml
tags: [always]
- name: Installer l'application
ansible.builtin.import_tasks: install.yml
when: app_state == 'present'
- name: Validation post-deploiement
ansible.builtin.import_tasks: validate.yml
when: app_state == 'present'
tags: [validate]
Debugging¶
Comprendre pourquoi une tâche échoué ou produit un résultat inattendu nécessité des outils de diagnostic adaptés.
Niveaux de verbosité¶
| Option | Informations affichees |
|---|---|
-v | Résultats des tâches (stdout, stderr, return code) |
-vv | Entrees des tâches (arguments passes au module) |
-vvv | Connexion SSH : commandes, clees utilisées |
-vvvv | Plugins de connexion, chargement des collections |
# Lancer un playbook en mode verbeux niveau 2
ansible-playbook -i inventory/production site.yml -vv
# Cibler une tache precise avec un tag
ansible-playbook -i inventory/staging site.yml --tags validate -vv
Module debug¶
# Afficher un message libre
- name: Afficher la version detectee
ansible.builtin.debug:
msg: "Version installee : {{ app_version }}"
# Inspecter une variable complete
- name: Inspecter le resultat du healthcheck
ansible.builtin.debug:
var: healthcheck_result
# Conditionner l'affichage au niveau de verbosité
- name: Detail interne (visible en -vv uniquement)
ansible.builtin.debug:
msg: "Payload complet : {{ health_data | to_nice_json }}"
verbosity: 2
Stratégie de debug interactif¶
# site.yml — activer le mode interactif pour une session de diagnostic
---
- name: Deployer l'application
hosts: app_servers
strategy: debug # remplacer linear par debug
tasks:
- ansible.builtin.import_role:
name: myapp
Avec strategy: debug, Ansible ouvre un prompt interactif ((debug)) à chaque échec. Les commandes disponibles sont redo (rejouer la tâche), continue (ignorer et passer à la suite) et quit (arrêter le playbook).
Ansible Lint¶
ansible-lint analyse statiquement les playbooks et les rôles pour détecter mauvaises pratiques, erreurs courantes et violations de style.
Installation et usage¶
# Installer ansible-lint dans l'environnement virtuel du projet
pip install ansible-lint
# Analyser tout le projet depuis la racine
ansible-lint
# Analyser un fichier specifique
ansible-lint playbooks/site.yml
# Corriger automatiquement les problemes simples (FQCN, yaml[truthy]...)
ansible-lint --fix
Règles importantes¶
| Règle | Problème détecté |
|---|---|
fqcn | Module sans FQCN (apt au lieu de ansible.builtin.apt) |
no-changed-when | command/shell sans changed_when |
yaml[truthy] | Utilisation de yes/no au lieu de true/false |
name[play] | Play ou tâche sans attribut name |
risky-shell-pipe | Pipe shell sans set -o pipefail |
Configuration .ansible-lint¶
# .ansible-lint
---
profile: production
exclude_paths:
- .git/
- molecule/
skip_list:
- yaml[line-length] # desactiver la limite de longueur de ligne
warn_list:
- experimental
use_default_rules: true
Le profil production active toutes les règles, y compris celles liees à la sécurité et à la conformité FQCN. C'est le profil recommande pour les rôles publies ou utilisés en production.
Tests avec Docker¶
Tester un rôle sur plusieurs distributions sans Molecule nécessité une approche pilotee par la matrice déclarée dans meta/main.yml.
Principe¶
Le fichier meta/main.yml liste les plateformes supportees. Un script Python lit ces plateformes et généré une matrice JSON. Le Makefile utilise cette matrice pour construire des images Docker et exécuter le rôle à l'intérieur.
meta/main.yml
└─► build_matrix.py ──► matrix.json
└─► Makefile targets
├─► Dockerfile.el (dnf)
└─► Dockerfile.deb (apt)
└─► docker build + run
└─► test_playbook.yml
Script de génération de matrice¶
#!/usr/bin/env python3
# build_matrix.py — lit meta/main.yml et produit matrix.json
import json
import yaml
from pathlib import Path
meta = yaml.safe_load(Path("meta/main.yml").read_text())
platforms = meta.get("galaxy_info", {}).get("platforms", [])
matrix = []
for platform in platforms:
name = platform["name"]
for version in platform.get("versions", []):
matrix.append({"os": name.lower(), "version": str(version)})
print(json.dumps({"include": matrix}, indent=2))
Dockerfiles par famille d'OS¶
# Dockerfile.el — famille Enterprise Linux (dnf)
ARG BASE_IMAGE
FROM ${BASE_IMAGE}
RUN dnf install -y python3 python3-pip sudo && \
dnf clean all
RUN useradd -m -s /bin/bash ansible && \
echo 'ansible ALL=(ALL) NOPASSWD:ALL' >> /etc/sudoers
CMD ["/sbin/init"]
# Dockerfile.deb — famille Debian/Ubuntu (apt)
ARG BASE_IMAGE
FROM ${BASE_IMAGE}
RUN apt-get update && \
apt-get install -y python3 python3-pip sudo && \
rm -rf /var/lib/apt/lists/*
RUN useradd -m -s /bin/bash ansible && \
echo 'ansible ALL=(ALL) NOPASSWD:ALL' >> /etc/sudoers
CMD ["/bin/bash"]
Makefile¶
# Makefile — cibles de test du role
DOCKER_REGISTRY ?=
DOCKER_REGISTRY_PREFIX ?=
IMAGE_PREFIX = $(if $(DOCKER_REGISTRY_PREFIX),$(DOCKER_REGISTRY_PREFIX)/,)
.PHONY: help matrix lint test clean
help: ## Afficher l'aide
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | \
awk 'BEGIN {FS = ":.*?## "}; {printf " %-20s %s\n", $$1, $$2}'
matrix: ## Afficher la matrice de test
@python3 build_matrix.py
lint: ## Lancer ansible-lint
ansible-lint
test: ## Lancer les tests sur toutes les plateformes
@python3 build_matrix.py | \
jq -r '.include[] | "\(.os)-\(.version)"' | \
xargs -I{} $(MAKE) test-{}
test-%: ## Tester sur une plateforme specifique (ex: make test-rockylinux-9)
$(eval OS_VER := $(subst test-,,$@))
$(eval OS := $(shell echo $(OS_VER) | cut -d- -f1))
docker build \
--build-arg BASE_IMAGE=$(IMAGE_PREFIX)$(OS_VER) \
-f Dockerfile.$(shell python3 -c "print('el' if '$(OS)' in ['rockylinux','almalinux','centos'] else 'deb')") \
-t role-test-$(OS_VER) .
docker run --rm \
-v $(PWD):/role:ro \
role-test-$(OS_VER) \
ansible-playbook -i localhost, -c local /role/test_playbook.yml
clean: ## Supprimer les images de test
docker images 'role-test-*' -q | xargs -r docker rmi -f
Support du registre prive¶
# Utiliser un registre prive pour les images de base
export DOCKER_REGISTRY=registry.example.com
export DOCKER_REGISTRY_PREFIX=library
make test-rockylinux-9
# construit depuis registry.example.com/library/rockylinux-9
Molecule comme alternative¶
Molecule offre une intégration plus poussee (scenarios multiples, drivers VM et cloud, vérifier Testinfra). Pour les rôles simples testés sur Docker uniquement, l'approche Makefile ci-dessus suffit et ajoute moins de dépendances. Molecule reste le choix naturel pour les rôles complexes avec plusieurs scenarios d'installation.
Pipelines CI¶
Intégrer les tests dans un pipeline garantit que chaque modification est vérifiée avant fusion.
Gitea Actions — matrice parallèle dynamique¶
# .gitea/workflows/test.yml
---
name: Test role
on: [push, pull_request]
jobs:
matrix:
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.build.outputs.matrix }}
steps:
- uses: actions/checkout@v4
- id: build
run: echo "matrix=$(python3 build_matrix.py)" >> $GITHUB_OUTPUT
test:
needs: matrix
runs-on: ubuntu-latest
strategy:
matrix: ${{ fromJson(needs.matrix.outputs.matrix) }}
fail-fast: false
steps:
- uses: actions/checkout@v4
- name: Test ${{ matrix.os }}-${{ matrix.version }}
env:
DOCKER_REGISTRY: ${{ secrets.DOCKER_REGISTRY }}
DOCKER_REGISTRY_PREFIX: ${{ secrets.DOCKER_REGISTRY_PREFIX }}
run: make test-${{ matrix.os }}-${{ matrix.version }}
GitLab CI — stages séquentiels¶
# .gitlab-ci.yml
---
stages: [lint, test]
lint:
stage: lint
image: python:3.11
script:
- pip install ansible-lint
- ansible-lint
.test_template: &test_template
stage: test
script:
- make test-${OS_VER}
variables:
DOCKER_REGISTRY: $CI_REGISTRY
DOCKER_REGISTRY_PREFIX: $CI_REGISTRY_IMAGE
test-rockylinux-9:
<<: *test_template
variables:
OS_VER: rockylinux-9
test-ubuntu-2204:
<<: *test_template
variables:
OS_VER: ubuntu-2204
Jenkins — stages parallèles¶
// Jenkinsfile
pipeline {
agent any
stages {
stage('Lint') {
steps { sh 'ansible-lint' }
}
stage('Test') {
parallel {
stage('rockylinux-9') {
steps {
withEnv(["DOCKER_REGISTRY=${env.DOCKER_REGISTRY}"]) {
sh 'make test-rockylinux-9'
}
}
}
stage('ubuntu-2204') {
steps {
withEnv(["DOCKER_REGISTRY=${env.DOCKER_REGISTRY}"]) {
sh 'make test-ubuntu-2204'
}
}
}
}
}
}
}
Flux complet¶
flowchart LR
A[meta/main.yml] --> B[build_matrix.py]
B --> C[matrix.json]
C --> D[Makefile targets]
D --> E[Dockerfile.el\nDockerfile.deb]
E --> F[docker build + run]
F --> G[test_playbook.yml]
G --> H{Resultat}
H -->|OK| I[Pipeline vert]
H -->|Echec| J[Pipeline rouge] Les identifiants de registre (DOCKER_REGISTRY, DOCKER_REGISTRY_PREFIX) doivent être stockes dans les secrets du pipeline et ne jamais apparaître dans les fichiers versionnes.
Checklist qualité
Avant de merger un rôle ou un playbook :
- Toutes les tâches ont un attribut
namedescriptif - Tous les modules sont références en FQCN
-
command/shellutilisentchanged_whenoucreates - Les secrets sont dans Vault, jamais en clair
-
no_log: truesur les tâches manipulant des secrets -
ansible-lintpasse sans erreur - Les tests passent sur au moins une plateforme
- Les variables sont documentees dans
defaults/main.ymlavec commentaires