Table of Contents
- 1.1 YAML Best Practices
- 1.2 Use ansible-lint
- 1.3 Demo: ansible-lint in VS Code
- 1.4 Documentation and version control
- 3.1 Check mode with diff
- 3.2 Ignore errors
- 3.3 Demo: Set failure and interrupt on failure
- 3.4
rescueandalways
- 4.1 Modularity with roles
- 4.2 Demo: Role structure
- 4.3 Demo: An example role
- 4.4 Include a role in a playbook
- 4.5 Demo: A custom role
- 4.6 Third-party roles with Ansible Galaxy
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
| Module | Subject | Duration |
|---|---|---|
| 1 | General best practices: Clean YAML, naming, documentation | 26m 53s |
| 2 | Idempotence: the central concept of how Ansible works | 12m 52s |
| 3 | Error handling: what to do when things go wrong | 14m 20s |
| 4 | Ansible Roles: Organize code into reusable components | 20m 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:
- No name on plays (
- hosts: aristawithoutname:) - No names on tasks (modules are used directly without
name:) - Unqualified module names:
eos_configinstead ofarista.eos.eos_config,srlinux.configinstead ofnokia.srlinux.config - Lines too long: the text banner exceeds the recommended limit of 160 characters
gather_facts: noinstead ofgather_facts: false(non-canonical boolean value)- 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
| Rule | Bad practice | Good practice |
|---|---|---|
| Start of file | (nothing) | --- |
| Play names | - hosts: arista | - name: Configure Arista switches |
| Task Names | - eos_config: | - name: Configure DNS servers |
| Module names | eos_config | arista.eos.eos_config (FQCN) |
| Booleans | no, yes | false, 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-checkflag to check if your YAML is valid, butansible-lintgoes 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
- Open VS Code
- Go to the Extensions tab (Ctrl+Shift+X)
- Search for “Ansible”
- 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:
- Write YAML
- Start the linter
- Read errors
- 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:
- Change History: You can see who changed what and why
- Rollback: If a change breaks something, you can revert to the previous version
- Collaboration: Several people can work on the same playbooks without stepping on each other
- 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:
- Read the current status of the equipment
- Compare with declared desired state
- 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
| Color | Status | Meaning |
|---|---|---|
| Green | ok | No changes needed, already good condition |
| Yellow | changed | A change was made |
| Red | failed | Task failed |
| Blue | skipped | The 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.shellansible.builtin.commandansible.builtin.rawarista.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
| Location | Recommended value of changed_when |
|---|---|
| Read-only command (show, gather) | false |
| Script that reports its own status in stdout | Condition on result.stdout |
| Command always idempotent by nature | false |
| Order that always changes | Do 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:
- Runs the task anyway
- Always marks it as failed if it fails
- 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_errorsshould 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
showcommand 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
- block: Save the current config, then apply the new BGP config
- rescue: If any task in the block fails → restores the backup and logs the rollback
- always: Whether the block succeeds or not → records the attempt in the audit log
Equivalences with other languages
| Ansible | Python | JavaScript |
|---|---|---|
block | try | try |
rescue | except | catch |
always | finally | finally |
Important points about block/rescue/always
- Once
rescuecompletes, Ansible considers the failure handled and play continues - If
rescueitself fails, the play stops (unless it is in another block) alwaysalways runs, even ifrescuefailsdelegate_to: localhostruns 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
| Directory | Key file | Role |
|---|---|---|
tasks/ | main.yml | The heart of the role. When you include a role in a play, Ansible automatically runs the tasks in tasks/main.yml |
defaults/ | main.yml | Default variable values. Lowest precedence → very easy to overload |
vars/ | main.yml | Variables internal to the role logic, not intended to be overloaded |
handlers/ | main.yml | Handlers (tasks that only run when something notifies them, typically to restart a service) |
templates/ | *.j2 | If 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.yml | Role 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.ymlis just a list of tasks. It’s not like a playbook where you saytasks: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.yml | vars/main.yml | |
|---|---|---|
| Precedence | The lowest (almost anything can overload it) | High (hard to overload) |
| Usage | Values that role users can customize | Values internal to the logic of the role |
| Example | dns_server:8.8.8.8 | Role 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.
Method 1: roles section (recommended)
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_tasks→roles→tasks→post_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_roleintegrates 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_roleincludes 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 cases | Recommended method |
|---|---|
| Normal use | Section roles: in the play |
| Mix tasks and roles, static use | import_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:
- Directory
roles/in the same directory as the playbook - Directory
roles/in the current directory - Locations configured in
roles_pathinansible.cfg ~/.ansible/roles/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_serversand 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
| Role | Collection | |
|---|---|---|
| Contains | Tasks, defaults, handlers, templates | Modules, plugins, roles, playbooks |
| Example | geerlingguy.ntp | arista.eos, nokia.srlinux |
| Install | ansible-galaxy role install | ansible-galaxy collection install |
| Usage | roles: - my_role | Modules: 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
| Practical | Description |
|---|---|
--- at start of file | Marks the start of a YAML document |
| Name all plays | name: on each play |
| Name all tasks | name: on each task |
| FQCN for modules | arista.eos.eos_config instead of eos_config |
| Canonical Booleans | true/false instead of yes/no |
| Lines ≤ 160 characters | Use | for long texts |
| Use ansible-lint | Automatic rule checking |
| VS Code extension | Real-time feedback in the editor |
| Header comments | Purpose, Author, Playbook Prerequisites |
| Comment on the “why” | Not the “what” (task names take care of that) |
| Git version control | Version all Ansible code |
Module 2: Idempotence
| Concept | Description |
|---|---|
| Idempotence | Run N times = run 1 time |
| Report state | Not the actions, but the desired state |
state:present | This element must exist |
ok (green) | Nothing to change, already in the desired state |
changed (yellow) | A change was made |
--check | Dry run: signals without touching |
changed_when: false | For read-only tasks |
changed_when: condition | Redefine when a task is “changed” |
Module 3: Error handling
| Tool | Usage |
|---|---|
--check --diff | Preview changes before production |
ignore_errors: true | Continue despite task failure |
ignore_unreachable: true | Continue if host is unreachable |
failed_when: condition | Redefining failure |
any_errors_fatal: true | Stop all play if a host fails |
block / rescue / always | Pattern try/except/finally for Ansible |
register + when | Capture the result and react conditionally |
Module 4: Roles
| Concept | Description |
|---|---|
| Role | Standalone and reusable Ansible package |
ansible-galaxy init | Generate the skeleton of a role |
tasks/main.yml | Role Task List |
defaults/main.yml | Default values (low precedence) |
vars/main.yml | Internal variables (high precedence) |
| Variable prefix | variable_role_name to avoid conflicts |
Section roles: | Include a role in a play (standard method) |
import_role | Include a role statically in tasks |
include_role | Include a role dynamically in tasks |
| Ansible Galaxy | Catalog of public roles and collections |
requirements.yml | Declare and pin dependencies |
| Pin Versions | Repeatable 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