Skip to content

Heterogeneous Targets

Objectif

Écrire des playbooks capables de gérer un parc multi-distributions (Debian, Rocky, SUSE, Ubuntu) en utilisant l'exécution conditionnelle (when) et les variables par famille d'OS, puis factoriser avec include_vars et le module générique package.

Durée

~60 minutes (2 playbooks, dont un long, + validation NTP)

Jusqu'à présent, nous avons appelés les mêmes rôles sur tous les hôtes/groupe d'hôtes.

  • Le nom du paquet peut être différent (apache2 vs httpd, cf. Workshop 08).
  • Le nom du service : chrony sous Debian, chronyd sous RedHat/SUSE.
  • Les chemins de configuration : /etc/chrony/chrony.conf sous Debian, /etc/chrony.conf ailleurs.

Un playbook qui part du principe qu'on est sur Debian peut casser sur une autre distribution. L'enjeu du workshop 13 est de gérer ce type d'hétérogénéité sans écrire un playbook par distribution.

Pour cela, deux outils complémentaires :

  1. when : conditionne l'exécution d'une tâche à une expression Jinja2 (sur un fact), ce qui permet d'activer/désactiver un bloc de tâches selon le Target Host courant.
  2. Les variables par famille d'OS (chargées via include_vars) : seules les valeurs (nom du paquet, du service, chemin du fichier de conf…) varient selon le fact ansible_os_family.

Mise en place du lab

cd $HOME/git/gh/formation-ansible/atelier-17
vagrant up
^up^ssh ansible

# sur le control host
cd ansible/projets/ema/playbooks/

when

Le mot-clé when s'applique au niveau d'une tâche et accepte une expression Jinja2 évaluée par hôte. Si l'expression est fausse pour un hôte donné, la tâche est marquée skipped pour cet hôte et ignorée ; les autres hôtes continuent normalement.

- name: install chrony (debian based)
  ansible.builtin.apt:
    name: chrony
    state: present
  when: ansible_os_family == "Debian"

when ne met pas les variables entre {{ }}

L'expression après le when: est déjà évaluée comme du Jinja2. On écrit donc when: ansible_os_family == "Debian" pas when: "{{ ansible_os_family }} == 'Debian'" (qui fonctionne mais meh).

Le fact ansible_os_family est collecté automatiquement au début de chaque playbook (sauf si gather_facts: false). Il regroupe plusieurs distributions, plus stable que ansible_distribution qui change à chaque version... La liste complète des valeurs possibles est maintenue directement dans le code source d'ansible.

Voir Workshop 12 pour l'exploration des facts (ansible -m setup) et le rappel sur INJECT_FACTS_AS_VARS.

À vous de jouer

chrony-01.yml approche naïve (un when par distro)

Première version : duplicat des tâches (install, déploiement de conf, démarrage, vérifications) une fois par famille d'OS, when: conservé en "garde-fou".

  • Modules spécifiques : ansible.builtin.apt pour Debian, ansible.builtin.dnf pour RedHat, community.general.zypper pour SUSE. Le dernier n'est pas dans ansible.builtin, il faut installer la collection community.general (ansible-galaxy collection install community.general).
  • Deux copies de chrony.conf avec un when chacune : Debian stocke la conf dans /etc/chrony/chrony.conf, RedHat et SUSE dans /etc/chrony.conf. Le contenu est identique, seul le dest: change.
  • Deux handlers distincts (restart chrony (debian based) / restart chrony (rocky/suse)) car le nom du service systemd diffère (chrony vs chronyd).
  • meta: flush_handlers : force l'exécution des handlers avant les tâches de vérification (chronyc tracking, chronyc sources). Sinon les handlers tournent après les vérifications, et on inspecterait un daemon qui n'aurait pas encore relu sa conf (cf. Workshop 09 Handlers).
  • pause: 15 avant les chronyc : même une fois le service (re)démarré, chronyd met quelques secondes à ouvrir son socket de ce que j'ai vu. Sans pause, la première commande chronyc peut renvoyer 506 Cannot talk to daemon.
chrony-01.yml
---
# chrony-01.yml
- name: configure chrony ntp sync
  hosts: all
  become: true
  tasks:

    - name: update apt cache (debian based)
      ansible.builtin.apt:
        update_cache: true
        cache_valid_time: 3600
      when: ansible_os_family == "Debian"

    - name: install chrony (debian based)
      ansible.builtin.apt:
        name: chrony
        state: present
      when: ansible_os_family == "Debian"

    - name: install chrony (rocky)
      ansible.builtin.dnf:
        name: chrony
        state: present
      when: ansible_os_family == "RedHat"

    - name: install chrony (suse)
      community.general.zypper:
        name: chrony
        state: present
      when: ansible_os_family == "Suse"

    # debian : /etc/chrony/chrony.conf,
    # rocky + suse : /etc/chrony.conf

    - name: deploy chrony.conf (debian based)
      ansible.builtin.copy:
        dest: /etc/chrony/chrony.conf
        owner: root
        group: root
        mode: "0644"
        content: |
          server 0.fr.pool.ntp.org iburst
          server 1.fr.pool.ntp.org iburst
          server 2.fr.pool.ntp.org iburst
          server 3.fr.pool.ntp.org iburst
          driftfile /var/lib/chrony/drift
          makestep 1.0 3
          rtcsync
          logdir /var/log/chrony
      when: ansible_os_family == "Debian"
      notify: restart chrony (debian based)

    - name: deploy chrony.conf (rocky/suse)
      ansible.builtin.copy:
        dest: /etc/chrony.conf
        owner: root
        group: root
        mode: "0644"
        content: |
          server 0.fr.pool.ntp.org iburst
          server 1.fr.pool.ntp.org iburst
          server 2.fr.pool.ntp.org iburst
          server 3.fr.pool.ntp.org iburst
          driftfile /var/lib/chrony/drift
          makestep 1.0 3
          rtcsync
          logdir /var/log/chrony
      when: ansible_os_family in ["RedHat", "Suse"]
      notify: restart chrony (rocky/suse)

    # debian : chrony
    # rocky + suse : "chronyd"

    - name: start + enable chrony (debian based)
      ansible.builtin.service:
        name: chrony
        state: started
        enabled: true
      when: ansible_os_family == "Debian"

    - name: start + enable chrony (rocky/suse)
      ansible.builtin.service:
        name: chronyd
        state: started
        enabled: true
      when: ansible_os_family in ["RedHat", "Suse"]

    - name: flush handlers to apply restart now
      ansible.builtin.meta: flush_handlers

    - name: read deployed chrony.conf (debian based)
      ansible.builtin.slurp:
        src: /etc/chrony/chrony.conf
      register: chrony_conf_deb
      when: ansible_os_family == "Debian"

    - name: read deployed chrony.conf (rocky/suse)
      ansible.builtin.slurp:
        src: /etc/chrony.conf
      register: chrony_conf_rocky
      when: ansible_os_family in ["RedHat", "Suse"]

    - name: show deployed chrony.conf content (debian based)
      ansible.builtin.debug:
        msg: "{{ (chrony_conf_deb.content | b64decode).split('\n') }}"
      when: ansible_os_family == "Debian"

    - name: show deployed chrony.conf content (rocky/suse)
      ansible.builtin.debug:
        msg: "{{ (chrony_conf_rocky.content | b64decode).split('\n') }}"
      when: ansible_os_family in ["RedHat", "Suse"]

    - name: check chrony service status (debian based)
      ansible.builtin.command: systemctl status chrony --no-pager
      register: chrony_status_deb
      changed_when: false
      when: ansible_os_family == "Debian"

    - name: check chrony service status (rocky/suse)
      ansible.builtin.command: systemctl status chronyd --no-pager
      register: chrony_status_rocky
      changed_when: false
      when: ansible_os_family in ["RedHat", "Suse"]

    - name: show service status (debian based)
      ansible.builtin.debug:
        var: chrony_status_deb.stdout_lines
      when: ansible_os_family == "Debian"

    - name: show service status (rocky/suse)
      ansible.builtin.debug:
        var: chrony_status_rocky.stdout_lines
      when: ansible_os_family in ["RedHat", "Suse"]

    # same chronyc + timedatectl partout

    - name: wait a bit for chrony to reach sources
      ansible.builtin.pause:
        seconds: 15

    - name: check chrony tracking
      ansible.builtin.command: chronyc tracking
      register: chrony_tracking
      changed_when: false

    - name: show chrony tracking
      ansible.builtin.debug:
        var: chrony_tracking.stdout_lines

    - name: check chrony sources
      ansible.builtin.command: chronyc -n sources
      register: chrony_sources
      changed_when: false

    - name: show chrony sources
      ansible.builtin.debug:
        var: chrony_sources.stdout_lines

    - name: check ntp synchronization via timedatectl
      ansible.builtin.command: timedatectl
      register: timedatectl_out
      changed_when: false

    - name: show timedatectl
      ansible.builtin.debug:
        var: timedatectl_out.stdout_lines

  handlers:
    - name: restart chrony (debian based)
      ansible.builtin.service:
        name: chrony
        state: restarted

    - name: restart chrony (rocky/suse)
      ansible.builtin.service:
        name: chronyd
        state: restarted
...
ansible-playbook chrony-01.yml

Exécution de chrony-01.yml - déploiement conditionnel par OS family

À l'exécution les tâches affichent ok / changed / skipped selon l'hôte, les lignes skipped sont la trace visible des when: qui ont filtré les tâches non applicables.

À la fin de l'exécution :

TASK [show chrony sources] ***********************************************************************
ok: [rocky] => {
    "chrony_sources.stdout_lines": [
        "MS Name/IP address         Stratum Poll Reach LastRx Last sample               ",
        "===============================================================================",
        "^- 5.196.76.84                   2   6    17    11  -5589us[-5589us] +/-   18ms",
        "^* 51.68.44.27                   3   6    17    12  +1385us[+6364us] +/-   23ms",
        "^? 78.197.169.39                 0   7     0     -     +0ns[   +0ns] +/-    0ns",
        "^- 185.123.84.51                 3   6    17    12   +577us[+5555us] +/-   79ms"
    ]
}
ok: [debian] => {
    "chrony_sources.stdout_lines": [
        "MS Name/IP address         Stratum Poll Reach LastRx Last sample               ",
        "===============================================================================",
        "^- 5.196.76.84                   2   6    17    12  +7683us[+7683us] +/-   24ms",
        "^- 51.68.44.27                   3   6    17    12  +1394us[+1394us] +/-   15ms",
        "^? 78.197.169.39                 0   7     0     -     +0ns[   +0ns] +/-    0ns",
        "^* 185.123.84.51                 3   6    17    12   +388us[-4584us] +/-   71ms"
    ]
}
ok: [suse] => {
    "chrony_sources.stdout_lines": [
        "MS Name/IP address         Stratum Poll Reach LastRx Last sample               ",
        "===============================================================================",
        "^- 5.196.76.84                   2   6    17    11  +2632us[+2632us] +/-   23ms",
        "^* 51.68.44.27                   3   6    17    11   -207us[-3923us] +/-   14ms",
        "^? 78.197.169.39                 0   7     0     -     +0ns[   +0ns] +/-    0ns",
        "^- 185.123.84.51                 3   6    17    11  +3618us[  -98us] +/-   75ms"
    ]
}
ok: [ubuntu] => {
    "chrony_sources.stdout_lines": [
        "MS Name/IP address         Stratum Poll Reach LastRx Last sample               ",
        "===============================================================================",
        "^? 54.38.114.34                  0   6     0     -     +0ns[   +0ns] +/-    0ns",
        "^- 51.68.44.27                   3   6    17    12  +1535us[+1535us] +/-   17ms",
        "^? 78.197.169.39                 2   7   100    19   +325us[-3932us] +/-   67ms",
        "^* 185.123.84.51                 3   6    17    12   +283us[ +275us] +/-   70ms"
    ]
}

Bilan de chrony-01.yml : ça marche, mais le fichier fait plus de 200 lignes pour installer un seul service, et chaque modification (ex. changer un serveur NTP) impose de toucher les deux tâches deploy chrony.conf. Sur un vrai parc avec 5+ services et 3+ familles d'OS, on obtiendrait rapidement des milliers de lignes dupliquées.

chrony-02.yml include_vars et package

Isolation des valeurs qui varient dans des fichiers de variables par famille de distribution. La logique devient identique pour toutes les cibles ; seul le jeu de variables change.

  1. include_vars avec un nom de fichier dynamique : vars/chrony02_{{ ansible_os_family | lower }}.yml. Le fact ansible_os_family passé en minuscules avec le filtre jinja2 | lower, charge alors automatiquement le fichier qui le concerne.
  2. package : abstraction qui délègue à apt, dnf, zypper… selon la distribution cible. Combiné à une variable chrony_package, on écrit name: "{{ chrony_package }}" une seule fois.

Après réinstallation des VMs, on crée les trois fichiers de variables un par famille de distros. Pour chrony, seuls le nom du service (chrony vs chronyd) et le répertoire de configuration (/etc/chrony vs /etc) varient :

mkdir -p vars
for f in debian redhat suse; do
  [ "$f" = "debian" ] && s=chrony c=/etc/chrony || s=chronyd c=/etc
  cat > vars/chrony02_${f}.yml <<EOF
---
chrony_package: chrony
chrony_service: ${s}
chrony_confdir: ${c}
EOF
done

Le playbook chrony-02.yml contient qu'une tâche d'installation, une de déploiement de configuration et un handler. Toute la logique spécifique à la distribution a migré vers les fichiers vars/.

chrony-02.yml
---
# chrony-02.yml
- name: configure chrony ntp sync
  hosts: all
  become: true
  tasks:

    - name: load distribution-specific vars
      ansible.builtin.include_vars: "vars/chrony02_{{ ansible_os_family | lower }}.yml"

    - name: update apt cache (debian based)
      ansible.builtin.apt:
        update_cache: true
        cache_valid_time: 3600
      when: ansible_os_family == "Debian"

    - name: install chrony
      ansible.builtin.package:
        name: "{{ chrony_package }}"
        state: present

    - name: deploy chrony.conf
      ansible.builtin.copy:
        dest: "{{ chrony_confdir }}/chrony.conf"
        owner: root
        group: root
        mode: "0644"
        content: |
          server 0.fr.pool.ntp.org iburst
          server 1.fr.pool.ntp.org iburst
          server 2.fr.pool.ntp.org iburst
          server 3.fr.pool.ntp.org iburst
          driftfile /var/lib/chrony/drift
          makestep 1.0 3
          rtcsync
          logdir /var/log/chrony
      notify: restart chrony

    - name: start + enable chrony
      ansible.builtin.service:
        name: "{{ chrony_service }}"
        state: started
        enabled: true

    - name: flush handlers to apply restart now
      ansible.builtin.meta: flush_handlers

    - name: read deployed chrony.conf
      ansible.builtin.slurp:
        src: "{{ chrony_confdir }}/chrony.conf"
      register: chrony_conf_file

    - name: show deployed chrony.conf content
      ansible.builtin.debug:
        msg: "{{ (chrony_conf_file.content | b64decode).split('\n') }}"

    - name: check chrony service status
      ansible.builtin.command: "systemctl status {{ chrony_service }} --no-pager"
      register: chrony_status
      changed_when: false

    - name: show service status
      ansible.builtin.debug:
        var: chrony_status.stdout_lines

    # same chronyc + timedatectl partout

    - name: wait a bit for chrony to reach sources
      ansible.builtin.pause:
        seconds: 5

    - name: check chrony tracking
      ansible.builtin.command: chronyc tracking
      register: chrony_tracking
      changed_when: false

    - name: show chrony tracking
      ansible.builtin.debug:
        var: chrony_tracking.stdout_lines

    - name: check chrony sources
      ansible.builtin.command: chronyc -n sources
      register: chrony_sources
      changed_when: false

    - name: show chrony sources
      ansible.builtin.debug:
        var: chrony_sources.stdout_lines

    - name: check ntp synchronization via timedatectl
      ansible.builtin.command: timedatectl
      register: timedatectl_out
      changed_when: false

    - name: show timedatectl
      ansible.builtin.debug:
        var: timedatectl_out.stdout_lines

  handlers:
    - name: restart chrony
      ansible.builtin.service:
        name: "{{ chrony_service }}"
        state: restarted
...

Exécution de chrony-02.yml - version factorisée avec include_vars

Même résultat fonctionnel que chrony-01.yml. Mais approche à privilégier pour un parc hétérogène. Le Workshop 14 Jinja + Templates va plus loin en déplaçant le contenu du fichier chrony.conf dans un template jinja2 réutilisable.

Je retiens

  • when: ansible_os_family == "Debian" conditionne l'exécution d'une tâche à la famille de distribution (cf. Workshop 12 pour les facts).
  • L'approche chrony-01.yml (un bloc when par distro) fonctionne mais duplique beaucoup de code.
  • L'approche chrony-02.yml avec include_vars et le module générique package est plus maintenable, les différences sont isolées dans des fichiers de variables par famille de distribution.
  • Les handlers utilisent des variables pour s'adapter au nom du service (chrony ou chronyd), comme vu dans Workshop 09.

Cheatsheet

Symptôme Cause probable Correction
include_vars: "vars/chrony02_{{ ansible_os_family \| lower }}.yml"Could not find or access sur Rocky ansible_os_family vaut RedHatredhat (ok ici) mais sur Ubuntu c'est Debian → attention aux mappings Aligner les noms de fichiers sur les valeurs réelles de ansible_os_family (liste)
community.general.zyppercouldn't resolve module La collection community.general n'est pas installée ansible-galaxy collection install community.general
when: ansible_os_family in ["RedHat", "Suse"] ignoré ansible_os_family absent (pas de gather_facts) S'assurer de gather_facts: true (par défaut) ou inclure setup au début
Le handler restart chrony ne tourne pas avant les tâches de vérification Les handlers s'exécutent en fin de playbook (cf. Workshop 09) Ajouter ansible.builtin.meta: flush_handlers avant les tâches de vérification
chronyc tracking506 Cannot talk to daemon après le démarrage Le service chronyd / chrony vient de démarrer et n'est pas encore prêt à répondre Conserver la pause: seconds: 5..15 ou utiliser wait_for sur le port/socket

Sources (principales)


Précédent : Facts + Implicit Vars Suite : Jinja + Templates