Intermediate

Automating Networks with Ansible the Right Way

Ansible is one of the most popular tools for automating network infrastructure. You may have already written a few playbooks to push configurations onto your switches, or perhaps you are...

Table of Contents

  1. Introduction
  2. – Best Practices for Maintainability (26m 53s)
  1. – Robustness with idempotence (12m 52s)
  1. – Error handling (14m 20s)
  1. – Modularity with roles (20m 49s)
  1. Demo files
  2. Summary and key points

1. Introduction

Ansible is one of the most popular tools for automating network infrastructure. You may have already written a few playbooks to push configurations onto your switches, or perhaps you are just starting to wonder how to improve the quality and maintainability of your playbooks.

There’s a big difference between running a playbook and writing a playbook that you will still understand in six months, that your colleagues will be able to understand and work on without problems, and that won’t suddenly break the next time you change something.

This course takes a “lessons learned” approach: the instructor has made all the mistakes the course covers himself. It’s about writing clean, maintainable and robust Ansible code.

Prerequisites

  • Know the basics of running a playbook
  • Be comfortable with editing YAML

Course roadmap

ModuleSubjectDuration
1General best practices: Clean YAML, naming, documentation26m 53s
2Idempotence: the central concept of how Ansible works12m 52s
3Error handling: what to do when things go wrong14m 20s
4Ansible Roles: Organize code into reusable components20m 49s

2. – Best Practices for Maintainability

1.1 YAML Best Practices

Ansible playbooks are written in YAML. Although YAML was designed to be easy to use, in practice it often proves surprisingly difficult. Most people who work a lot with YAML develop a love-hate relationship with it.

Why YAML is difficult

  • Syntax is indentation sensitive
  • Formatting errors can cause unexpected behavior without a clear error message
  • Quotes, commas, hyphens all have specific meanings

Example: bad playbook (don’t do)

Here is an example of a poorly written playbook (base_config_bad.yml) which illustrates all the bad practices to avoid:

- hosts: arista
  gather_facts: no
  tasks:
    - eos_config:
        lines:
          - ip name-server 8.8.8.8
          - ip name-server 8.8.4.4
    - eos_config:
        lines:
          - ntp server pool.ntp.org
    - eos_config:
        lines:
          - logging buffered 10000
          - logging console informational
    - eos_banner:
        banner: motd
        text: "Welcome to the Globomantics Network - Authorized Use Only - This system is monitored and any unauthorized access will be reported to corporate security and prosecuted under applicable laws."
- hosts: nokia
  gather_facts: no
  tasks:
    - srlinux.config:
        update:
          - path: /system/banner
            value:
              login-banner: Globomantics Network - Authorized Only
    - srlinux.config:
        update:
          - path: /system/dns/network-instance
            value: default
          - path: /system/dns/server-list
            value:
              - 8.8.8.8
              - 8.8.4.4
    - srlinux.config:
        update:
          - path: /system/ntp
            value:
              admin-state: enable
              network-instance: mgmt
              server:
                - address: pool.ntp.org

Issues in this playbook:

  1. No name on plays (- hosts: arista without name:)
  2. No names on tasks (modules are used directly without name:)
  3. Unqualified module names: eos_config instead of arista.eos.eos_config, srlinux.config instead of nokia.srlinux.config
  4. Lines too long: the text banner exceeds the recommended limit of 160 characters
  5. gather_facts: no instead of gather_facts: false (non-canonical boolean value)
  6. No YAML document: absence of the three hyphens --- at the start of the file

Example: good playbook (to do)

Here is the same playbook well written (base_config.yml):

---
# Globomantics Base Configuration
# Applies baseline config to all network devices

- name: Configure Arista switches
  hosts: arista
  gather_facts: false

  tasks:
    - name: Set login banner
      arista.eos.eos_banner:
        banner: motd
        text: |
          ******************************************
          * Globomantics Network - Authorized Only *
          ******************************************
        state: present

    - name: Configure DNS servers
      arista.eos.eos_config:
        lines:
          - ip name-server vrf default 8.8.8.8
          - ip name-server vrf default 8.8.4.4

    - name: Configure NTP server
      arista.eos.eos_config:
        lines:
          - ntp server pool.ntp.org

    - name: Enable logging
      arista.eos.eos_config:
        lines:
          - logging buffered 10000
          - logging console informational

- name: Configure Nokia SR Linux routers
  hosts: nokia
  gather_facts: false

  tasks:
    - name: Set system banner
      nokia.srlinux.config:
        update:
          - path: /system/banner
            value:
              login-banner: |
                ******************************************
                * Globomantics Network - Authorized Only *
                ******************************************

    - name: Configure DNS servers
      nokia.srlinux.config:
        update:
          - path: /system/dns/network-instance
            value: default
          - path: /system/dns/server-list
            value:
              - 8.8.8.8
              - 8.8.4.4

    - name: Configure NTP
      nokia.srlinux.config:
        update:
          - path: /system/ntp
            value:
              admin-state: enable
              network-instance: mgmt
              server:
                - address: pool.ntp.org

YAML Best Practices Rules for Ansible

RuleBad practiceGood practice
Start of file(nothing)---
Play names- hosts: arista- name: Configure Arista switches
Task Names- eos_config:- name: Configure DNS servers
Module nameseos_configarista.eos.eos_config (FQCN)
Booleansno, yesfalse, true
Line length> 160 characters≤ 160 characters, use the block scalar |

FQCN – Fully Qualified Collection Name

Since Ansible 2.10+, modules have migrated into collections. It is strongly recommended to use the FQCN to avoid ambiguity:

# Déprécié / ambigu
- eos_config:
    lines: ...

# Correct
- arista.eos.eos_config:
    lines: ...

Long text in YAML: the block scalar

For long strings (banners, multi-line configurations), use the symbol | (pipe):

text: |
  ******************************************
  * Globomantics Network - Authorized Only *
  ******************************************

1.2 Use ansible-lint

You might think that memorizing all these YAML rules is a lot. The good news is that there is a tool that can check your playbooks automatically, and it goes way beyond simple YAML formatting. It’s ansible-lint.

What is ansible-lint?

ansible-lint is a command line tool that:

  • Analyze your Ansible playbooks, your roles, your collections
  • Checks for common errors
  • Imposes best practices
  • Helps write more consistent code

It’s like a spell checker, but for your playbooks. In the programming world, it’s called a linter.

Note: Ansible itself has a --syntax-check flag to check if your YAML is valid, but ansible-lint goes much further.

What ansible-lint checks

  • Deprecated syntax
  • Tasks without names
  • Incorrect use of modules
  • Unqualified module names (missing FQCN)
  • Excessive line length
  • A range of other best practices agreed by the Ansible community

Installing ansible-lint

There are several ways to install ansible-lint, and not all of them are equally good:

# Option 1 : pip (simple, mais risque de conflits)
pip install ansible-lint

# Option 2 : pipx (recommandé pour les outils CLI autonomes)
pipx install ansible-lint

# Option 3 : uv (alternative moderne à pipx)
uv tool install ansible-lint

Recommendation: For a standalone CLI tool like ansible-lint, prefer pipx or uv. They isolate the tool in its own virtual environment, avoiding conflicts with system packages or other project dependencies.

Using the command line

# Linter un playbook
ansible-lint base_config.yml

# Linter tous les playbooks d'un répertoire
ansible-lint .

# Afficher les règles disponibles
ansible-lint --list-rules

Ansible-lint output example

When you run ansible-lint on the wrong playbook, you get messages like:

[yaml][truthy] Use "true" or "false" instead of "no"
[name][missing] All plays should be named.
[name][missing] All tasks should be named.
[fqcn][action-core] Use FQCN for builtin module actions (eos_config).
[yaml][line-length] Line too long (> 160 chars)

Each violation has a rule identifier (e.g. yaml[truthy], name[missing], fqcn[action-core]) and a descriptive message.

Configuring ansible-lint

You can create a .ansible-lint or .ansible-lint.yml file in the root of your project to customize the behavior:

# .ansible-lint
warn_list:
  - yaml[line-length]

skip_list:
  - yaml[truthy]

1.3 Demo: ansible-lint in VS Code

Running ansible-lint from the command line is great, but there’s a much nicer way to use it in everyday life: letting your editor show you warnings inline as you type.

Prerequisites

  • VS Code installed
  • Red Hat Ansible extension installed

Installing the Red Hat Ansible extension

  1. Open VS Code
  2. Go to the Extensions tab (Ctrl+Shift+X)
  3. Search for “Ansible”
  4. Install the Red Hat Ansible extension

If ansible-lint is already installed on your system, the extension will automatically detect it and start using it to linter every playbook you open.

Operation in practice

  • Red squiggles appear under problematic lines
  • When hovering over a squiggle, you see the exact same rule ID and message as in the terminal
  • Squiggles disappear in real time when you fix a violation

Example: Correcting null to false makes the squiggle associated with this rule immediately disappear.

Productivity advantage

Instead of having this cycle:

  1. Write YAML
  2. Start the linter
  3. Read errors
  4. Return to the correct line in the file

Everything lives in the editor alongside your issues, giving real-time feedback.

Other supported editors

The ansible-lint integration is not exclusive to VS Code. There are also integrations for:

  • Vim / Neovim
  • Emacs
  • JetBrains (IntelliJ IDEA, PyCharm, etc.)

The VS Code extension is the most accomplished and best maintained.


1.4 Documentation and version control

YAML comments

The simplest form of documentation in Ansible is YAML comments. A comment begins with the character #, and anything after that line is ignored by Ansible.

Best practice: Task names already describe what a task does. Comments should explain why.

---
# Globomantics Base Configuration
# Applies baseline config to all network devices
# Author: Network Ops Team
# Depends on: arista.eos collection

- name: Configure Arista switches
  hosts: arista
  gather_facts: false

  tasks:
    # We use pool.ntp.org instead of a corporate NTP because
    # the management network doesn't have internal NTP access yet
    - name: Configure NTP server
      arista.eos.eos_config:
        lines:
          - ntp server pool.ntp.org

What a header comment block should contain

---
# Playbook : base_config.yml
# Objectif  : Appliquer la configuration de base à tous les équipements réseau
# Auteur    : Équipe NetOps
# Date      : 2024-01-15
# Prérequis : 
#   - Collection arista.eos installée (ansible-galaxy collection install arista.eos)
#   - Collection nokia.srlinux installée
#   - Inventaire avec groupes 'arista' et 'nokia'

Variable documentation

Variables are also worth documenting, especially when their purpose is not obvious from their name.

# group_vars/arista.yml

# Serveurs DNS utilisés pour la résolution de noms dans le VRF default
dns_servers:
  - 8.8.8.8
  - 8.8.4.4

# Serveur NTP pour la synchronisation de l'horloge système
ntp_server: pool.ntp.org

# Taille du buffer de logging en octets
logging_buffer_size: 10000

Version control with Git

The other important topic for documentation and maintainability is version control. Your Ansible playbooks are code, and all code should be versioned in a version control tool like Git.

Why version your playbooks:

  1. Change History: You can see who changed what and why
  2. Rollback: If a change breaks something, you can revert to the previous version
  3. Collaboration: Several people can work on the same playbooks without stepping on each other
  4. Code review: Pull requests allow changes to be reread before applying them

Recommended filing structure:

ansible-network-config/
├── .ansible-lint          # Configuration d'ansible-lint
├── .gitignore             # Fichiers à ignorer (vault, secrets, etc.)
├── README.md              # Documentation du projet
├── inventories/
│   ├── production/
│   │   ├── hosts.yml
│   │   └── group_vars/
│   └── staging/
│       ├── hosts.yml
│       └── group_vars/
├── playbooks/
│   ├── base_config.yml
│   └── bgp_config.yml
└── roles/
    ├── arista_base/
    └── nokia_base/

Recommended .gitignore file:

# Fichiers de vault Ansible (ne jamais committer les secrets)
*.vault
vault_password_file

# Fichiers temporaires
*.retry
*.log

# Environnements virtuels Python
.venv/
venv/

3. – Robustness with idempotence

2.1 Understanding idempotence

We looked at the style and structure of our playbooks. Now we’re going a level deeper into how our playbooks actually behave when running against our equipment, and the most important idea to fully understand is idempotence.

Definition

Idempotence is a term that comes from mathematics, but the idea behind it is simple:

A task is idempotent when running it once produces the same result as running it twice, or ten times, or a hundred times.

The first run brings the system to the desired state, and each subsequent run finds that the system is already there and does nothing.

The thermostat analogy

A good analogy is a thermostat:

  • You set it to 21°C → the heating turns on → the room heats up to 21°C
  • You set it to 21°C again → nothing happens, the room is already at 21°C
  • You set it to 21°C ten times in a row → always 21°C

You’ve described what you want, and the thermostat silently determines if anything needs to change.

How Ansible approaches idempotence

Ansible approaches idempotence by describing state, not actions.

- name: Set login banner
  arista.eos.eos_banner:
    banner: motd
    text: |
      ******************************************
      * Globomantics Network - Authorized Only *
      ******************************************
    state: present

When you write state: present on this task, you do not say:

  • ❌ “Write this configuration on the switch”

You say:

  • ✅ “This banner must exist on the switch”

Even though these parts of our playbook are called “tasks”, that’s not really what they describe. They actually describe the desired state in which we want our equipment to be.

Internal mechanism

Ansible will:

  1. Read the current status of the equipment
  2. Compare with declared desired state
  3. Decide what action to take (if necessary)

Result:

  • If the banner is already configured → Ansible does nothing (task in green: ok)
  • If it is absent or different → Ansible configures it (task in yellow: changed)

After execution, the status is as you requested. This is what makes playbooks idempotent.

States in Ansible output

ColorStatusMeaning
GreenokNo changes needed, already good condition
YellowchangedA change was made
RedfailedTask failed
BlueskippedThe task was skipped (when condition not met)

2.2 Idempotence in action

First run

When running for the first time on new equipment, all tasks are displayed in yellow. The summary at the bottom says changes=4 for Arista and changes=3 for Nokia, which makes sense because all the hardware was pristine.

PLAY RECAP ***********************
arista-sw1  : ok=4  changed=4  unreachable=0  failed=0
nokia-rtr1  : ok=3  changed=3  unreachable=0  failed=0

Second run (without changes)

Without changing anything, the same execution gives all tasks green:

PLAY RECAP ***********************
arista-sw1  : ok=4  changed=0  unreachable=0  failed=0
nokia-rtr1  : ok=3  changed=0  unreachable=0  failed=0

Ansible connected to each device, compared the current configuration with what was requested, and concluded that there was nothing to do. It’s idempotence.

Changing a single value

If we change a single DNS server in the playbook (e.g. 10.1.1.10) and restart:

PLAY RECAP ***********************
arista-sw1  : ok=4  changed=1  unreachable=0  failed=0
nokia-rtr1  : ok=3  changed=0  unreachable=0  failed=0

Out of seven tasks, Ansible hit exactly the only thing that needed to be changed.

Check mode: dry run

Ansible offers a very practical mode called check mode (blank execution). Run your playbook with --check, and Ansible will go through each task and report what would change, without actually doing anything to the device.

ansible-playbook base_config.yml --check

Reason this works: Each idempotent task can already give Ansible a clear answer to the question “Would this change anything now?”, so Ansible can simply report that answer without having to do any actual work.

TASK [Set login banner] **********************
ok: [arista-sw1]   # Pas de changement

TASK [Configure DNS servers] *****************
changed: [arista-sw1]   # Serait changé

PLAY RECAP ***********************
arista-sw1  : ok=3  changed=1  unreachable=0  failed=0

2.3 Idempotence and changed_when

The problem with command type modules

We’ve seen how Ansible’s network modules handle idempotency automatically: they read the state of the device, compare it to your declared state, and decide if anything needs to change.

But there is a whole category of modules for which this does not work: modules that execute arbitrary commands:

  • ansible.builtin.shell
  • ansible.builtin.command
  • ansible.builtin.raw
  • arista.eos.eos_command
  • And other control modules in other collections

These modules pass a command to something (a shell or device) and capture the output. Ansible doesn’t know what this command does. Ansible cannot know from the outside.

The result: always changed

Because of this, a shell module or command execution is always marked as changed, even if nothing significant has changed. This pollutes your play summary with fake changed accounts and drowns out tasks that actually do something.

Demonstration of the problem

- name: Write a string to a file
  ansible.builtin.shell: echo "Hello" > /tmp/test.txt
  • First execution: task in yellow (changed=1) → correct, the file did not exist
  • Second execution: task still in yellow (changed=1) → incorrect, nothing has really changed
  • Hundredth execution: always changed=1

Solution: changed_when

The keyword changed_when allows you to tell Ansible: “Forget your definition of change, here is mine.”

Example 1: Saying that a task never changes anything

- name: Gather BGP information
  arista.eos.eos_command:
    commands:
      - show ip bgp summary
  register: bgp_info
  changed_when: false   # Cette commande ne change jamais rien

A classic use case: you simply request information to store it in a variable. This never changes anything, so changed_when: false.

Example 2: Condition based on output

- name: Apply configuration
  ansible.builtin.shell: |
    configure terminal
    router bgp 65001
  register: result
  changed_when: "'% Error' not in result.stdout"

Example 3: Combined condition

- name: Run script if needed
  ansible.builtin.command: /opt/scripts/check_and_apply.sh
  register: script_result
  changed_when: script_result.rc == 0 and "No changes needed" not in script_result.stdout

Summary of changed_when

LocationRecommended value of changed_when
Read-only command (show, gather)false
Script that reports its own status in stdoutCondition on result.stdout
Command always idempotent by naturefalse
Order that always changesDo not use (default behavior)

4. – Error handling

3.1 Check mode with diff

Let’s talk about what to do when things go wrong — and of course they do. A device becomes inaccessible, a show command returns an unexpected output, a configuration fails halfway. When this happens, what Ansible does by default isn’t always what you want.

Fashion check revisited

You have already seen check mode briefly. Running a playbook with --check asks Ansible to step through the steps and report what would change without touching the hardware. For error handling, check mode is really your first line of defense.

Combine --check and --diff

Alone, --check tells you that a task would change something, but not what. If you combine with --diff, Ansible also shows you what would change line by line.

ansible-playbook base_config.yml --check --diff

Without --diff: summary of tasks that would change

TASK [Configure DNS servers] *****
changed: [arista-sw1]

With --diff: detailed diff of each modification

TASK [Configure DNS servers] *****
--- before
+++ after
@@ -1,2 +1,3 @@
-ip name-server vrf default 8.8.8.8
+ip name-server vrf default 10.1.1.53
+ip name-server vrf default 10.1.1.54
 ntp server pool.ntp.org

The demo file error_handling_check.yml illustrates this practice:

---
# Error Handling - Check Mode Demo
# Run with: ansible-playbook playbooks/error_handling_check.yml --check --diff

- name: Update base configuration on Arista switches
  hosts: arista
  gather_facts: false

  tasks:
    - name: Update login banner
      arista.eos.eos_banner:
        banner: motd
        text: |
          ******************************************
          * Globomantics Network - Authorized Only *
          *   Contact netops@globomantics.com      *
          ******************************************
        state: present

    - name: Configure DNS servers
      arista.eos.eos_config:
        lines:
          - ip name-server 10.1.1.53
          - ip name-server 10.1.1.54

    - name: Configure NTP server
      arista.eos.eos_config:
        lines:
          - ntp server 10.1.1.123

- name: Update base configuration on Nokia SR Linux routers
  hosts: nokia
  gather_facts: false

  tasks:
    - name: Configure DNS servers
      nokia.srlinux.config:
        update:
          - path: /system/dns/network-instance
            value: default
          - path: /system/dns/server-list
            value:
              - 10.1.1.53
              - 10.1.1.54

Best practice: --check --diff before production

Rule of thumb: Before applying a playbook to production for the first time, or after any significant changes, run it with --check --diff. This helps detect a typo before it is deployed.


3.2 Ignore errors

When a task fails on a host, Ansible stops running any subsequent tasks on that host. The other hosts in the play continue, so if the Arista switch returns an error, the Nokia router will continue processing tasks, but the Arista switch is terminated for this execution.

Most of the time, this is what you want. But there are situations where this is not the case.

Scenario: BGP verification

Here is a playbook that performs checks on Globomantics equipment:

---
# Error Handling - ignore_errors and ignore_unreachable Demo

- name: Pre-flight checks on Globomantics devices
  hosts: all
  gather_facts: false

  tasks:
    - name: Check BGP peer summary
      arista.eos.eos_command:
        commands:
          - show ip bgp summary
      register: bgp_summary
      when: "'arista' in group_names"

    - name: Gather hardware facts
      arista.eos.eos_facts:
        gather_subset: hardware
      when: "'arista' in group_names"

    - name: Report pre-flight result
      ansible.builtin.debug:
        msg: "Finished pre-flight checks on {{ inventory_hostname }}."

Problem: The Arista switch does not have BGP configured. The show ip bgp summary command returns “BGP not configured”, and the eos_command module turns this into a task failure. The following task will not run.

But in this case, “BGP not configured” might be the answer I was looking for, and the task didn’t really fail — it just told me something useful.

Solution: ignore_errors

    - name: Check BGP peer summary
      arista.eos.eos_command:
        commands:
          - show ip bgp summary
      register: bgp_summary
      ignore_errors: true          # <-- Ajout
      when: "'arista' in group_names"

When ignore_errors: true is added, Ansible:

  1. Runs the task anyway
  2. Always marks it as failed if it fails
  3. Does not stop — continues to the next task as if nothing had happened

Use with register

In practice, ignore_errors is often combined with register, to capture the result and then react with a when clause:

    - name: Check BGP peer summary
      arista.eos.eos_command:
        commands:
          - show ip bgp summary
      register: bgp_result
      ignore_errors: true

    - name: Warn if BGP not configured
      ansible.builtin.debug:
        msg: "BGP non configuré sur {{ inventory_hostname }}"
      when: bgp_result is failed

ignore_unreachable

What happens when a host is completely inaccessible? It’s a different type of failure. The module will never be able to run because Ansible will not be able to open a connection.

By default, the host is marked unreachable and ignored for the rest of the play.

    - name: Check BGP peer summary
      arista.eos.eos_command:
        commands:
          - show ip bgp summary
      ignore_unreachable: true     # Traite l'inaccessibilité comme ignore_errors

With ignore_unreachable, connection failure is logged and then ignored. The host is not removed from play, so subsequent tasks can still try to reach it.

⚠️ Important Warning

Don’t overuse ignore_errors! If a task fails and you tell Ansible to ignore it without ever checking the output, you now have a playbook that fails silently. ignore_errors should be used with intent, where failure is a legitimate expected response.


3.3 Demo: Set failure and abort on failure

failed_when: Redefining what failure is

The default definition of a failure by Ansible is quite rudimentary: the module returned a non-zero exit code, raised an error, or crashed. That’s all.

But in real-world network automation, the question of whether something was successful is often more subtle:

  • A show command may run without errors, but the output indicates something is broken
  • An API call may return 200 OK, but the payload contains an error
  • A command may return an error for something that is not really a problem

For all these cases, failed_when allows you to tell Ansible: “Forget your definition of failure, here is mine.”

---
# Error Handling - failed_when Demo

- name: Verify interface state on Arista switches
  hosts: arista
  gather_facts: false

  tasks:
    - name: Verify all interfaces are up
      arista.eos.eos_command:
        commands:
          - show interfaces status
      register: interfaces_output
      # La commande elle-même réussit ; on échoue la tâche si une interface
      # est reportée comme "disabled"
      failed_when: "'disabled' in interfaces_output.stdout[0]"

    - name: Confirm all interfaces are operational
      ansible.builtin.debug:
        msg: "All interfaces on {{ inventory_hostname }} are up."

Other examples of failed_when:

# Échec si le code de retour n'est pas 0 ET si stdout ne contient pas "Success"
failed_when: result.rc != 0 and "Success" not in result.stdout

# Échec si la commande retourne un résultat vide
failed_when: result.stdout[0] | length == 0

# Échec si une interface spécifique est down
failed_when: "'Ethernet1' in result.stdout[0] and 'notconnect' in result.stdout[0]"

any_errors_fatal: Stop the whole play in case of error

By default, if a host fails in a play, the other hosts continue. But sometimes tasks between different hosts are coupled: a failure on one host means we have to interrupt everywhere before the network goes into an inconsistent state.

---
# Error Handling - any_errors_fatal Demo

- name: Configure link between Arista and Nokia
  hosts: all
  any_errors_fatal: true        # <-- Si un hôte échoue, TOUT s'arrête
  gather_facts: false

  tasks:
    - name: Configure link interface on Arista
      arista.eos.eos_config:
        lines:
          - description LINK_TO_SRL_RTR1
          - no shutdown
        parents: interface Ethernet1
      when: "'arista' in group_names"

    # Simule un échec sur Nokia pour montrer any_errors_fatal
    - name: Push link configuration to Nokia SR Linux
      ansible.builtin.fail:
        msg: "Simulated SR Linux commit failure on {{ inventory_hostname }}."
      when: "'nokia' in group_names"

    - name: Verify link is up on both sides
      ansible.builtin.debug:
        msg: "Link configured on {{ inventory_hostname }}."

Typical use case: Configure a link between two devices. If the configuration fails on one side, do not apply the configuration on the other side (the link would be down anyway, and a partial configuration is worse than nothing).


3.4 rescue and always

If you’ve written code in just about any programming language, you’re familiar with the try / except / finally pattern. Ansible gives you exactly the same structure with block, rescue and always.

This is Ansible’s most expressive error handling tool, and for non-trivial network workflows it is often the right choice.

General structure

tasks:
  - name: Descriptive block name
    block:
      # Ce que vous voulez accomplir
      - name: Tâche 1
        ...
      - name: Tâche 2
        ...

    rescue:
      # Exécuté SEULEMENT si une tâche dans block échoue
      - name: Gérer l'échec
        ...

    always:
      # Exécuté TOUJOURS, que block ait réussi ou non
      - name: Nettoyage / audit
        ...

Complete example: BGP with rollback

---

- name: Apply BGP configuration with rollback
  hosts: arista
  gather_facts: false

  tasks:
    - name: BGP update with safety net
      block:
        - name: Ensure backup directory exists
          ansible.builtin.file:
            path: ./backups
            state: directory
            mode: '0755'
          delegate_to: localhost

        - name: Fetch current running-config
          arista.eos.eos_command:
            commands:
              - show running-config
          register: running_config

        - name: Save current configuration as backup
          ansible.builtin.copy:
            content: "{{ running_config.stdout[0] }}\n"
            dest: ./backups/pre_change.cfg
            mode: '0755'
          delegate_to: localhost

        - name: Apply new BGP configuration
          arista.eos.eos_config:
            src: bgp_update.j2

      rescue:
        - name: Roll back to previous configuration
          arista.eos.eos_config:
            src: ./backups/pre_change.cfg

        - name: Log the rollback
          ansible.builtin.debug:
            msg: "BGP change failed on {{ inventory_hostname }} - rolled back to previous config."

      always:
        - name: Record outcome in audit log
          ansible.builtin.lineinfile:
            path: /tmp/ansible-bgp.log
            line: "{{ inventory_hostname }} - BGP change attempted at {{ ansible_date_time.iso8601 | default('unknown') }}"
            create: true
            mode: '0644'
          delegate_to: localhost

Workflow explanation

  1. block: Save the current config, then apply the new BGP config
  2. rescue: If any task in the block fails → restores the backup and logs the rollback
  3. always: Whether the block succeeds or not → records the attempt in the audit log

Equivalences with other languages

AnsiblePythonJavaScript
blocktrytry
rescueexceptcatch
alwaysfinallyfinally

Important points about block/rescue/always

  • Once rescue completes, Ansible considers the failure handled and play continues
  • If rescue itself fails, the play stops (unless it is in another block)
  • always always runs, even if rescue fails
  • delegate_to: localhost runs the task on the Ansible control machine rather than on the network equipment

5. – Modularity with roles

4.1 Modularity with roles

The problem of growth

Let’s see what happens when your project grows and how to keep things maintainable when it gets bigger and more complex.

Our base_config.yml has around 50 lines and manages two families of equipment. It’s manageable, but imagine where this leads. Globomantics is not going to stay at two equipment forever. As the company grows, we will add new network infrastructure and more complex configurations.

If we continue to add tasks to this YAML file:

  • In a year, we will have a single playbook of 1000 lines
  • No one will remember which tasks go together
  • Any change to a task may break something else in a different part of the file

The problem of reuse

The other problem is reuse. If a colleague starts a new project tomorrow and also needs to configure these basic settings on different Arista switches, what does he do?

  • Copy and paste the tasks into your playbook?
  • When changing the banner text, do we have to remember to update all the copies scattered everywhere?

This is another maintenance nightmare.

The solution: Ansible roles

The response Ansible gives us is called a role. A role is a reusable, standalone Ansible package. It brings together in a small, well-organized unit:

  • tasks
  • variables and their default values
  • handlers
  • templates
  • Static files
  • metadata

You can drop this package into any playbook and use it. If you change the role, all playbooks that use it automatically benefit from the change.


4.2 Demo: Role structure

Generate the skeleton of a role

There is a builtin command that generates an empty role skeleton for you:

ansible-galaxy init roles/mon_role

Structure of a role on disk

roles/
└── mon_role/
    ├── defaults/
    │   └── main.yml       # Valeurs de variables par défaut (précédence la plus basse)
    ├── files/             # Fichiers statiques à copier sur les hôtes
    ├── handlers/
    │   └── main.yml       # Handlers (tâches déclenchées par notify)
    ├── meta/
    │   └── main.yml       # Métadonnées et dépendances du rôle
    ├── tasks/
    │   └── main.yml       # ❤️ Le cœur du rôle : la liste des tâches
    ├── templates/         # Templates Jinja2
    ├── tests/
    │   ├── inventory
    │   └── test.yml
    └── vars/
        └── main.yml       # Variables internes (précédence haute)

Description of each directory

DirectoryKey fileRole
tasks/main.ymlThe heart of the role. When you include a role in a play, Ansible automatically runs the tasks in tasks/main.yml
defaults/main.ymlDefault variable values. Lowest precedence → very easy to overload
vars/main.ymlVariables internal to the role logic, not intended to be overloaded
handlers/main.ymlHandlers (tasks that only run when something notifies them, typically to restart a service)
templates/*.j2If you use the template module to generate a configuration, the Jinja2 template files go here
files/*Static files that the role will copy to hosts
meta/main.ymlRole metadata: author, license, dependencies on other roles

You don’t have to use all of these directories. Most roles only need two or three of them. You can delete the ones you don’t need.

The tasks/main.yml file

Important: In a role, tasks/main.yml is just a list of tasks. It’s not like a playbook where you say tasks: and put it all under a list. No, the file itself is the task list.

# tasks/main.yml d'un rôle
---
- name: Tâche 1
  module:
    paramètre: valeur

- name: Tâche 2
  autre_module:
    paramètre: valeur

Difference defaults/ vs vars/

defaults/main.ymlvars/main.yml
PrecedenceThe lowest (almost anything can overload it)High (hard to overload)
UsageValues ​​that role users can customizeValues ​​internal to the logic of the role
Exampledns_server:8.8.8.8Role constants

4.3 Demo: An example role

Here is a small role called MOTD that configures the message of the day banner on a Linux host.

MOTD role structure

roles/
└── motd/
    ├── defaults/
    │   └── main.yml
    └── tasks/
        └── main.yml

tasks/main.yml

- name: Write the MOTD file
  ansible.builtin.copy:
    content: "{{ motd_text }}\n"
    dest: /etc/motd

The text comes from a variable motd_text, whose default value is in defaults/main.yml.

defaults/main.yml

motd_text: "Welcome to this server."

This is already a complete reusable role! Any playbook that wants can include MOTD and configure motd_text as needed.

Using the role in a playbook

---
- name: Configure servers
  hosts: linux_servers
  gather_facts: false

  roles:
    # Option 1 : Utiliser la valeur par défaut
    - motd

    # Option 2 : Passer une valeur personnalisée
    - role: motd
      vars:
        motd_text: "Welcome to the Globomantics server. Authorized access only."

4.4 Include a role in a playbook

Now that we know what a role is, how do we actually use it? There are several ways to integrate a role into a play, and each has its place.

The standard and most common way. At the play level, you add a roles section and list the roles you want to apply:

---
- name: Configure all network devices
  hosts: all
  gather_facts: false

  roles:
    - arista_base
    - nokia_base

Execution order with pre_tasks, roles, tasks, post_tasks

---
- name: Playbook complet avec toutes les sections
  hosts: all
  gather_facts: false

  pre_tasks:
    - name: Vérification pré-déploiement
      # Exécuté EN PREMIER

  roles:
    - mon_role_a
    - mon_role_b
    # Exécuté EN DEUXIÈME

  tasks:
    - name: Tâche ponctuelle
      # Exécuté EN TROISIÈME

  post_tasks:
    - name: Notification post-déploiement
      # Exécuté EN DERNIER

Note: The order of sections in the YAML file does not matter. Ansible will always execute them in this order: pre_tasksrolestaskspost_tasks.

Method 2: import_role (in the task list)

Allows you to integrate a role from the task list (instead of the play level):

tasks:
  - name: Apply base config
    ansible.builtin.import_role:
      name: arista_base
  • import_role integrates the role’s tasks at parsing time (static)
  • It’s like you wrote them directly into the playbook

Method 3: include_role (dynamic)

tasks:
  - name: Apply role based on device type
    ansible.builtin.include_role:
      name: "{{ device_type }}_base"   # Variable calculée au runtime
  • include_role includes tasks at runtime (dynamic)
  • More flexible: allows you to apply a role based on a variable calculated earlier in the play
  • Can be used in a loop

General rule

Use casesRecommended method
Normal useSection roles: in the play
Mix tasks and roles, static useimport_role
Dynamic role (variable, loop)include_role

Search for roles by Ansible

When you reference a role by name in a playbook, Ansible looks for it in this order:

  1. Directory roles/ in the same directory as the playbook
  2. Directory roles/ in the current directory
  3. Locations configured in roles_path in ansible.cfg
  4. ~/.ansible/roles
  5. /usr/share/ansible/roles

4.5 Demo: A custom role

Refactoring the base playbook into roles

Our existing playbook has two plays: one for Arista and one for Nokia, each handling banner, DNS, NTP, logging for its device family. We will refactor these two sets of tasks into two clean roles: arista_base and nokia_base.

The goal: The playbook itself becomes a short, high-level description of what to configure, and the how will live in those roles.

Step 1: Create the skeleton with ansible-galaxy

ansible-galaxy init roles/arista_base

This generates the standard structure under roles/arista_base. We can then delete the directories we don’t need and keep only tasks/ and defaults/.

Step 2: Move tasks to tasks/main.yml

# roles/arista_base/tasks/main.yml
---
- name: Set login banner
  arista.eos.eos_banner:
    banner: motd
    text: "{{ arista_base_banner_text }}"
    state: present

- name: Configure DNS servers
  arista.eos.eos_config:
    lines: "{{ arista_base_dns_lines }}"

- name: Configure NTP server
  arista.eos.eos_config:
    lines:
      - "ntp server {{ arista_base_ntp_server }}"

- name: Enable logging
  arista.eos.eos_config:
    lines: "{{ arista_base_logging_lines }}"

Hardcoded values ​​are replaced with variable references prefixed with the role name (arista_base_).

Important convention: Prefix your variables with the role name. If you call your variable simply dns_servers and two roles used in the same playbook both have a variable with that name, this can cause problems that are difficult to debug.

Step 3: Set defaults in defaults/main.yml

# roles/arista_base/defaults/main.yml
---
arista_base_banner_text: |
  ******************************************
  * Globomantics Network - Authorized Only *
  ******************************************

arista_base_dns_lines:
  - ip name-server vrf default 8.8.8.8
  - ip name-server vrf default 8.8.4.4

arista_base_ntp_server: pool.ntp.org

arista_base_logging_lines:
  - logging buffered 10000
  - logging console informational

Step 4: The Simplified Playbook

After refactoring, the main playbook becomes very short:

---
# Globomantics Base Configuration
# Applies baseline config to all network devices using roles

- name: Configure Arista switches
  hosts: arista
  gather_facts: false

  roles:
    - arista_base

- name: Configure Nokia SR Linux routers
  hosts: nokia
  gather_facts: false

  roles:
    - nokia_base

Override default values

The advantage of roles is that you can customize the default values ​​without changing the role itself. For example, to use internal DNS servers instead of Google DNS:

# group_vars/arista.yml
arista_base_dns_lines:
  - ip name-server vrf default 10.1.1.53
  - ip name-server vrf default 10.1.1.54

arista_base_ntp_server: 10.1.1.123

Or directly in the playbook:

- name: Configure Arista switches
  hosts: arista
  gather_facts: false

  roles:
    - role: arista_base
      vars:
        arista_base_dns_lines:
          - ip name-server vrf default 10.1.1.53
          - ip name-server vrf default 10.1.1.54

4.6 Third-party roles with Ansible Galaxy

Writing your own roles is great, but there’s an even better option when someone else has already done the work: use theirs.

Ansible Galaxy

The Ansible community is very large, and for almost any common task you can imagine, someone has already written a role. Relying on an established role has the same advantages as relying on a library in any other language:

  • You get features battle-tested by hundreds of users
  • If the role has an active maintainer, you get fixes and improvements for free

Best practice: Always ask yourself “Has anyone already solved this problem?” before writing your own role. For many common tasks, the answer will be yes.

Access Ansible Galaxy

The public catalog of all public Ansible roles and collections is available at:

https://galaxy.ansible.com

You can search by name, author, category, etc.

Install a role from Galaxy

# Installer un rôle spécifique
ansible-galaxy role install geerlingguy.ntp

# Voir les rôles installés
ansible-galaxy role list

Once installed, the role is available on your system and you can use it in your playbooks.

The requirements.yml file

Rather than installing dependencies manually, the best practice is to declare them in a requirements.yml file:

# requirements.yml
---
roles:
  - name: geerlingguy.ntp
    version: "2.3.2"   # Épingler la version !

  - name: geerlingguy.git
    version: "3.0.0"

collections:
  - name: arista.eos
    version: ">=5.0.0"

  - name: nokia.srlinux
    version: ">=0.5.0"

Then install all the dependencies in one command:

ansible-galaxy install -r requirements.yml

Why pin versions?

The downside of third-party roles is that you depend on someone else’s code. If you do not specify a version:

  • A role update could break your playbook
  • It is difficult to reproduce an exact environment

By pinning releases you get reproducible deployments.

# Mauvais : peut se casser à la prochaine mise à jour
- name: geerlingguy.ntp

# Bon : version fixée
- name: geerlingguy.ntp
  version: "2.3.2"

Update dependencies

# Mettre à jour une dépendance spécifique
ansible-galaxy role install geerlingguy.ntp --force

# Mettre à jour toutes les dépendances
ansible-galaxy install -r requirements.yml --force

Collections vs Roles

RoleCollection
ContainsTasks, defaults, handlers, templatesModules, plugins, roles, playbooks
Examplegeerlingguy.ntparista.eos, nokia.srlinux
Installansible-galaxy role installansible-galaxy collection install
Usageroles: - my_roleModules: arista.eos.eos_config

6. Demo files

Module 1: Basic Playbooks

base_config_bad.yml — Bad practices

- hosts: arista
  gather_facts: no
  tasks:
    - eos_config:
        lines:
          - ip name-server 8.8.8.8
          - ip name-server 8.8.4.4
    - eos_config:
        lines:
          - ntp server pool.ntp.org
    - eos_config:
        lines:
          - logging buffered 10000
          - logging console informational
    - eos_banner:
        banner: motd
        text: "Welcome to the Globomantics Network - Authorized Use Only - This system is monitored and any unauthorized access will be reported to corporate security and prosecuted under applicable laws."
- hosts: nokia
  gather_facts: no
  tasks:
    - srlinux.config:
        update:
          - path: /system/banner
            value:
              login-banner: Globomantics Network - Authorized Only
    - srlinux.config:
        update:
          - path: /system/dns/network-instance
            value: default
          - path: /system/dns/server-list
            value:
              - 8.8.8.8
              - 8.8.4.4
    - srlinux.config:
        update:
          - path: /system/ntp
            value:
              admin-state: enable
              network-instance: mgmt
              server:
                - address: pool.ntp.org

base_config.yml — Best practices

---
# Globomantics Base Configuration
# Applies baseline config to all network devices

- name: Configure Arista switches
  hosts: arista
  gather_facts: false

  tasks:
    - name: Set login banner
      arista.eos.eos_banner:
        banner: motd
        text: |
          ******************************************
          * Globomantics Network - Authorized Only *
          ******************************************
        state: present

    - name: Configure DNS servers
      arista.eos.eos_config:
        lines:
          - ip name-server vrf default 8.8.8.8
          - ip name-server vrf default 8.8.4.4

    - name: Configure NTP server
      arista.eos.eos_config:
        lines:
          - ntp server pool.ntp.org

    - name: Enable logging
      arista.eos.eos_config:
        lines:
          - logging buffered 10000
          - logging console informational

- name: Configure Nokia SR Linux routers
  hosts: nokia
  gather_facts: false

  tasks:
    - name: Set system banner
      nokia.srlinux.config:
        update:
          - path: /system/banner
            value:
              login-banner: |
                ******************************************
                * Globomantics Network - Authorized Only *
                ******************************************

    - name: Configure DNS servers
      nokia.srlinux.config:
        update:
          - path: /system/dns/network-instance
            value: default
          - path: /system/dns/server-list
            value:
              - 8.8.8.8
              - 8.8.4.4

    - name: Configure NTP
      nokia.srlinux.config:
        update:
          - path: /system/ntp
            value:
              admin-state: enable
              network-instance: mgmt
              server:
                - address: pool.ntp.org

Module 3: Error handling

error_handling_check.yml

---
# Error Handling - Check Mode Demo
# Run with: ansible-playbook error_handling_check.yml --check --diff

- name: Update base configuration on Arista switches
  hosts: arista
  gather_facts: false

  tasks:
    - name: Update login banner
      arista.eos.eos_banner:
        banner: motd
        text: |
          ******************************************
          * Globomantics Network - Authorized Only *
          *   Contact netops@globomantics.com      *
          ******************************************
        state: present

    - name: Configure DNS servers
      arista.eos.eos_config:
        lines:
          - ip name-server 10.1.1.53
          - ip name-server 10.1.1.54

    - name: Configure NTP server
      arista.eos.eos_config:
        lines:
          - ntp server 10.1.1.123

- name: Update base configuration on Nokia SR Linux routers
  hosts: nokia
  gather_facts: false

  tasks:
    - name: Configure DNS servers
      nokia.srlinux.config:
        update:
          - path: /system/dns/network-instance
            value: default
          - path: /system/dns/server-list
            value:
              - 10.1.1.53
              - 10.1.1.54

error_handling_failed_when.yml

---
# Error Handling - failed_when Demo

- name: Verify interface state on Arista switches
  hosts: arista
  gather_facts: false

  tasks:
    - name: Verify all interfaces are up
      arista.eos.eos_command:
        commands:
          - show interfaces status
      register: interfaces_output
      failed_when: "'disabled' in interfaces_output.stdout[0]"

    - name: Confirm all interfaces are operational
      ansible.builtin.debug:
        msg: "All interfaces on {{ inventory_hostname }} are up."

error_handling_fatal.yml

---
# Error Handling - any_errors_fatal Demo

- name: Configure link between Arista and Nokia
  hosts: all
  any_errors_fatal: true
  gather_facts: false

  tasks:
    - name: Configure link interface on Arista
      arista.eos.eos_config:
        lines:
          - description LINK_TO_SRL_RTR1
          - no shutdown
        parents: interface Ethernet1
      when: "'arista' in group_names"

    - name: Push link configuration to Nokia SR Linux
      ansible.builtin.fail:
        msg: "Simulated SR Linux commit failure on {{ inventory_hostname }}."
      when: "'nokia' in group_names"

    - name: Verify link is up on both sides
      ansible.builtin.debug:
        msg: "Link configured on {{ inventory_hostname }}."

error_handling_ignore.yml

---
# Error Handling - ignore_errors and ignore_unreachable Demo

- name: Pre-flight checks on Globomantics devices
  hosts: all
  gather_facts: false

  tasks:
    - name: Check BGP peer summary
      arista.eos.eos_command:
        commands:
          - show ip bgp summary
      register: bgp_summary
      when: "'arista' in group_names"

    - name: Gather hardware facts
      arista.eos.eos_facts:
        gather_subset: hardware
      when: "'arista' in group_names"

    - name: Report pre-flight result
      ansible.builtin.debug:
        msg: "Finished pre-flight checks on {{ inventory_hostname }}."

error_handling_rescue.yml

---

- name: Apply BGP configuration with rollback
  hosts: arista
  gather_facts: false

  tasks:
    - name: BGP update with safety net
      block:
        - name: Ensure backup directory exists
          ansible.builtin.file:
            path: ./backups
            state: directory
            mode: '0755'
          delegate_to: localhost

        - name: Fetch current running-config
          arista.eos.eos_command:
            commands:
              - show running-config
          register: running_config

        - name: Save current configuration as backup
          ansible.builtin.copy:
            content: "{{ running_config.stdout[0] }}\n"
            dest: ./backups/pre_change.cfg
            mode: '0755'
          delegate_to: localhost

        - name: Apply new BGP configuration
          arista.eos.eos_config:
            src: bgp_update.j2

      rescue:
        - name: Roll back to previous configuration
          arista.eos.eos_config:
            src: ./backups/pre_change.cfg

        - name: Log the rollback
          ansible.builtin.debug:
            msg: "BGP change failed on {{ inventory_hostname }} - rolled back."

      always:
        - name: Record outcome in audit log
          ansible.builtin.lineinfile:
            path: /tmp/ansible-bgp.log
            line: "{{ inventory_hostname }} - BGP change attempted at {{ ansible_date_time.iso8601 | default('unknown') }}"
            create: true
            mode: '0644'
          delegate_to: localhost

Module 4: Roles

roles/motd/tasks/main.yml

- name: Write the MOTD file
  ansible.builtin.copy:
    content: "{{ motd_text }}\n"
    dest: /etc/motd

roles/motd/defaults/main.yml

motd_text: "Welcome to this server."

7. Summary and key points

Module 1: Maintainability Best Practices

PracticalDescription
--- at start of fileMarks the start of a YAML document
Name all playsname: ​​on each play
Name all tasksname: ​​on each task
FQCN for modulesarista.eos.eos_config instead of eos_config
Canonical Booleanstrue/false instead of yes/no
Lines ≤ 160 charactersUse | for long texts
Use ansible-lintAutomatic rule checking
VS Code extensionReal-time feedback in the editor
Header commentsPurpose, Author, Playbook Prerequisites
Comment on the “why”Not the “what” (task names take care of that)
Git version controlVersion all Ansible code

Module 2: Idempotence

ConceptDescription
IdempotenceRun N times = run 1 time
Report stateNot the actions, but the desired state
state:presentThis element must exist
ok (green)Nothing to change, already in the desired state
changed (yellow)A change was made
--checkDry run: signals without touching
changed_when: falseFor read-only tasks
changed_when: conditionRedefine when a task is “changed”

Module 3: Error handling

ToolUsage
--check --diffPreview changes before production
ignore_errors: trueContinue despite task failure
ignore_unreachable: trueContinue if host is unreachable
failed_when: conditionRedefining failure
any_errors_fatal: trueStop all play if a host fails
block / rescue / alwaysPattern try/except/finally for Ansible
register + whenCapture the result and react conditionally

Module 4: Roles

ConceptDescription
RoleStandalone and reusable Ansible package
ansible-galaxy initGenerate the skeleton of a role
tasks/main.ymlRole Task List
defaults/main.ymlDefault values ​​(low precedence)
vars/main.ymlInternal variables (high precedence)
Variable prefixvariable_role_name to avoid conflicts
Section roles:Include a role in a play (standard method)
import_roleInclude a role statically in tasks
include_roleInclude a role dynamically in tasks
Ansible GalaxyCatalog of public roles and collections
requirements.ymlDeclare and pin dependencies
Pin VersionsRepeatable deployments

Quick Reference Commands

# Vérification de syntaxe
ansible-playbook playbook.yml --syntax-check

# Linting
ansible-lint playbook.yml

# Dry run
ansible-playbook playbook.yml --check

# Dry run avec diff
ansible-playbook playbook.yml --check --diff

# Exécution normale
ansible-playbook playbook.yml

# Créer un squelette de rôle
ansible-galaxy init roles/mon_role

# Installer des dépendances
ansible-galaxy install -r requirements.yml

# Installer une collection
ansible-galaxy collection install arista.eos

# Installer un rôle
ansible-galaxy role install geerlingguy.ntp

# Lister les rôles installés
ansible-galaxy role list

# Lister les collections installées
ansible-galaxy collection list

Search Terms

automating · networks · ansible · right · networking · web · servers · systems · security · roles · role · ansible-lint · main.yml · idempotence · playbook · check · tasks · yaml · defaults · error · galaxy · always · block · changedwhen

Interested in this course?

Contact us to book it or get a custom training plan for your team.