Aller au contenu

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 name descriptif
  • Tous les modules sont références en FQCN
  • command/shell utilisent changed_when ou creates
  • Les secrets sont dans Vault, jamais en clair
  • no_log: true sur les tâches manipulant des secrets
  • ansible-lint passe sans erreur
  • Les tests passent sur au moins une plateforme
  • Les variables sont documentees dans defaults/main.yml avec commentaires