Ansible Loops, Conditionals, and Handlers

Ansible Loops, Conditionals, and Handlers
In: Ansible-Course
Table of Contents

So far, our playbooks have been fairly simple. Each task runs once, on every device, every time we run the playbook. That's fine for basic tasks, but as your playbooks grow, you'll want more control over how and when tasks run.

In this post, we'll look at three features that give you that control. Loops let you run the same task multiple times, once for each item in a list. Conditionals let you run a task only when a certain condition is true, such as when a variable is defined or a device is running a specific software version. And handlers let you run a task only when another task has made a change, which is perfect for things like saving the config.

We've used VLANs a lot in the previous posts, so for this post, we'll move on to something different and configure NTP and syslog instead. These are things almost every network needs, and they're a good fit for showing how loops, conditionals, and handlers work.

Project Structure

Before we start, let's take a quick look at where our project is at. We've added and changed quite a few files over the last few posts, so it's easy to lose track.

To keep things clean, I've removed all the playbooks from the previous posts, along with the VLAN variables in group_vars/core.yml, group_vars/access.yml, and host_vars/access1.yml. We won't be using them in this post, so feel free to delete them too. Here's what's left.

ansible_v1/
├── venv/
├── ansible.cfg
├── inventory.yml
└── group_vars/
    └── cisco/
        └── vars.yml

ansible.cfg

[defaults]
inventory = inventory.yml
host_key_checking = False

inventory.yml

---
all:
  children:
    cisco:
      children:
        core:
          hosts:
            core1:
              ansible_host: 192.168.202.101
            core2:
              ansible_host: 192.168.202.102
        access:
          hosts:
            access1:
              ansible_host: 192.168.202.103
            access2:
              ansible_host: 192.168.202.104
            access3:
              ansible_host: 192.168.202.105

group_vars/cisco/vars.yml

---
ansible_network_os: cisco.ios.ios
ansible_connection: ansible.netcommon.network_cli
ansible_user: admin
ansible_password: admin

If you've been following along, your project should look the same. If not, now is a good time to update it, so the examples in this chapter work as expected.

Loops

To keep things simple, we'll work with a single device, core1, throughout this chapter. Everything we cover works exactly the same way with more devices, so feel free to use whichever switches you like.

Let's say we want to configure two NTP servers on core1. Since lines in ios_config is a list, we could simply add both commands to a single task.

    - name: Configure NTP servers
      cisco.ios.ios_config:
        lines:
          - ntp server 10.10.10.10
          - ntp server 10.10.10.11

This works fine, but the IP addresses are hardcoded in the playbook. Ideally, we'd keep a list of NTP servers in a variable, just like we did with VLANs. The problem is that a variable would only give us the IP addresses, not the full ntp server commands. This is where loops can help. A loop lets you run the same task multiple times, once for each item in a list, and use that item inside the task.

Let's start with a simple loop, and then we'll move the list into a variable. Create a new playbook called ntp.yml.

---
- name: Configure NTP
  hosts: core1
  gather_facts: false

  tasks:
    - name: Configure NTP servers
      cisco.ios.ios_config:
        lines:
          - "ntp server {{ item }}"
      loop:
        - 10.10.10.10
        - 10.10.10.11

There are two new things here.

  • loop - The list of items we want to loop over. Here, it's our two NTP servers. Notice that loop sits at the same level as the module, not under it, since it's an option for the task, not for ios_config.
  • item - Each time the task runs, Ansible puts the current item from the list into a variable called item. So on the first run, {{ item }} becomes 10.10.10.10, and on the second run, it becomes 10.10.10.11.

Let's run the playbook.

ansible-playbook ntp.yml
PLAY [Configure NTP] **********************************

TASK [Configure NTP servers] **************************
[WARNING]: To ensure idempotency and correct diff the
input configuration lines should be similar to how they
appear if present in the running configuration on device
changed: [core1] => (item=10.10.10.10)
changed: [core1] => (item=10.10.10.11)

PLAY RECAP ********************************************
core1  : ok=1  changed=1  unreachable=0  failed=0  skipped=0  rescued=0  ignored=0

This time, the output shows a result for each item in the loop. Both NTP servers show changed, since neither was configured before. You can ignore the warning here. As we'll see in a moment, running the playbook again shows ok, so ios_config is finding the lines in the running config without any problems.

To see what Ansible actually did on the switch, I ran show history all on core1. This shows every command that was run on the device, including the ones sent by Ansible. I've removed the timestamps and log messages to keep it short.

core1#show history all
CMD: 'terminal length 0'
CMD: 'terminal width 512'
CMD: 'terminal width 0'
CMD: 'show version'
CMD: 'show vlan'
CMD: 'show running-config'
CMD: 'configure terminal'
CMD: 'ntp server 10.10.10.10'
CMD: 'end'
CMD: 'configure terminal'
CMD: 'ntp server 10.10.10.11'
CMD: 'end'

This shows exactly how a loop works. Ansible ran the task twice, once for each NTP server, so we can see two separate configure terminal and end sessions. You can also see that before making any changes, Ansible ran show running-config to check what was already on the device.

If we run the playbook again, both items show ok.

PLAY [Configure NTP] **********************************

TASK [Configure NTP servers] **************************
ok: [core1] => (item=10.10.10.10)
ok: [core1] => (item=10.10.10.11)

PLAY RECAP ********************************************
core1  : ok=1  changed=0  unreachable=0  failed=0  skipped=0  rescued=0  ignored=0
core1#show history all
CMD: 'terminal length 0'
CMD: 'terminal width 512'
CMD: 'terminal width 0'
CMD: 'show version'
CMD: 'show vlan'
CMD: 'show running-config'

This time, Ansible only read the running config. Since both NTP servers were already there, it didn't send any config commands at all.

Looping Over a Variable

Now, let's move the list of NTP servers out of the playbook. Add a variable called ntp_servers to group_vars/cisco/vars.yml, and add a third NTP server while we're at it.

ntp_servers:
  - 10.10.10.10
  - 10.10.10.11
  - 10.10.10.12

Then update the task to loop over the variable instead.

      loop: "{{ ntp_servers }}"

Let's run the playbook again.

PLAY [Configure NTP] **********************************

TASK [Configure NTP servers] **************************
ok: [core1] => (item=10.10.10.10)
ok: [core1] => (item=10.10.10.11)
[WARNING]: To ensure idempotency and correct diff the
input configuration lines should be similar to how they
appear if present in the running configuration on device
changed: [core1] => (item=10.10.10.12)

PLAY RECAP ********************************************
core1  : ok=1  changed=1  unreachable=0  failed=0  skipped=0  rescued=0  ignored=0

The first two servers show ok, since they're already configured, and only 10.10.10.12 shows changed. The history on the switch confirms that only the new server was added.

core1#show history all
CMD: 'terminal length 0'
CMD: 'terminal width 512'
CMD: 'terminal width 0'
CMD: 'show version'
CMD: 'show vlan'
CMD: 'show running-config'
CMD: 'configure terminal'
CMD: 'ntp server 10.10.10.12'
CMD: 'end'

This is exactly what we were after. The playbook only describes how to configure an NTP server, while the list of servers lives in group_vars. If you need to add another NTP server, you just add it to the variable.

Do We Always Need a Loop?

Not always. You might be wondering why we didn't use a resource module for NTP in the first place. The cisco.ios collection does have one called ios_ntp_global, but I chose ios_config on purpose, so we could look at how loops work. Let's configure two more NTP servers using ios_ntp_global, in a new playbook called ntp_global.yml.

---
- name: Configure NTP
  hosts: core1
  gather_facts: false

  tasks:
    - name: Configure NTP servers
      cisco.ios.ios_ntp_global:
        config:
          servers:
            - server: 10.10.10.13
            - server: 10.10.10.14
ansible-playbook ntp_global.yml
PLAY [Configure NTP] **********************************

TASK [Configure NTP servers] **************************
changed: [core1]

PLAY RECAP ********************************************
core1  : ok=1  changed=1  unreachable=0  failed=0  skipped=0  rescued=0  ignored=0

There's no loop here, since servers is already a list, and the module takes care of configuring all of them. If we look at the history on the switch, we can see a few differences.

core1#show history all
CMD: 'terminal length 0'
CMD: 'terminal width 512'
CMD: 'terminal width 0'
CMD: 'show version'
CMD: 'show vlan'
CMD: 'show running-config | section ^ntp'
CMD: 'configure terminal'
CMD: 'ntp server 10.10.10.13'
CMD: 'ntp server 10.10.10.14'
CMD: 'end'
CMD: 'show running-config | section ^ntp'

First, both NTP servers were configured in a single configure terminal session, rather than one session per server. Second, instead of reading the whole running config, the module only reads the NTP section. And finally, it reads the NTP section again after making the change, which is how it builds the before and after values we saw earlier with ios_vlans.

Another Loop Example

Let's look at another loop example, this time without making any config changes. Let's say we want to check that core1 can reach all of its neighbors. Without a loop, we'd need a separate task for each neighbor, and the only difference between them would be the IP address.

For this, we'll use the ios_ping module. It runs a ping from the device itself, just like typing ping on the CLI, and returns the results, such as packet loss and round-trip times. By default, the task fails if the ping doesn't succeed, which makes it a simple way to check reachability.

Create a new playbook called ping.yml.

---
- name: Ping tests
  hosts: core1
  gather_facts: false

  tasks:
    - name: Ping neighbors
      cisco.ios.ios_ping:
        dest: "{{ item }}"
      loop:
        - 172.16.1.0
        - 172.16.1.2
        - 172.16.1.4
        - 172.16.1.13

These are the IP addresses of core1's neighbors on the other end of each link, access1, access2, access3, and core2. The dest option is the IP address we want to ping, and just like before, {{ item }} is replaced with each IP address from the list.

Let's run the playbook.

ansible-playbook ping.yml
PLAY [Ping tests] *************************************

TASK [Ping neighbors] *********************************
ok: [core1] => (item=172.16.1.0)
ok: [core1] => (item=172.16.1.2)
ok: [core1] => (item=172.16.1.4)
ok: [core1] => (item=172.16.1.13)

PLAY RECAP ********************************************
core1  : ok=1  changed=0  unreachable=0  failed=0  skipped=0  rescued=0  ignored=0

All four pings show ok, which means core1 can reach every neighbor. Since a ping doesn't change anything on the device, we'll never see changed here. If one of the neighbors was unreachable, that item would show failed instead.

The output only tells us whether each ping succeeded, but if we add -v, we can see the full result of each one.

ansible-playbook ping.yml -v
TASK [Ping neighbors] *********************************
ok: [core1] => (item=172.16.1.0) => {
  "ansible_loop_var": "item",
  "changed": false,
  "commands": "ping ip 172.16.1.0",
  "item": "172.16.1.0",
  "packet_loss": "0%",
  "packets_rx": 5,
  "packets_tx": 5,
  "rtt": {"avg": 1, "max": 1, "min": 1}
}
ok: [core1] => (item=172.16.1.2) => {...}
ok: [core1] => (item=172.16.1.4) => {...}
ok: [core1] => (item=172.16.1.13) => {...}

I've only expanded the first item, since the others look the same. We can see the command that was run on the device, the packet loss, the number of packets sent and received, and the round-trip times.

If we look at show history all on core1, we can see the four ping commands, one after the other.

core1#show history all
CMD: 'terminal length 0'
CMD: 'terminal width 512'
CMD: 'terminal width 0'
CMD: 'show version'
CMD: 'show vlan'
CMD: 'ping ip 172.16.1.0'
CMD: 'ping ip 172.16.1.2'
CMD: 'ping ip 172.16.1.4'
CMD: 'ping ip 172.16.1.13'

Looping Over a List of Dictionaries

In the previous example, each item in the list was just an IP address. That works, but when you're looking at the output, 172.16.1.13 doesn't mean much on its own. It would be nicer to see the name of the neighbor as well.

To do this, we can make each item in the list a dictionary, with both a name and an IP address. Let's add a new variable called ping_targets to group_vars/cisco/vars.yml.

ping_targets:
  - name: access1
    ip: 172.16.1.0
  - name: access2
    ip: 172.16.1.2
  - name: access3
    ip: 172.16.1.4
  - name: core2
    ip: 172.16.1.13

Next, update the task in ping.yml to loop over the new variable.

    - name: Ping neighbors
      cisco.ios.ios_ping:
        dest: "{{ item.ip }}"
      loop: "{{ ping_targets }}"
      loop_control:
        label: "{{ item.name }}"

Since each item is now a dictionary, item on its own would give us the whole dictionary, not just the IP address. To get a specific value, we use a dot followed by the key, so item.ip gives us the IP address, and item.name gives us the name.

We've also added something new called loop_control. By default, Ansible shows the whole item in the output for each loop, which in our case would be the full dictionary, {'name': 'access1', 'ip': '172.16.1.0'}. With a few items that's fine, but it quickly gets messy. The label option lets you choose what's shown instead. Here, we're telling Ansible to only show the name.

Let's run the playbook.

ansible-playbook ping.yml
PLAY [Ping tests] *************************************

TASK [Ping neighbors] *********************************
ok: [core1] => (item=access1)
ok: [core1] => (item=access2)
ok: [core1] => (item=access3)
ok: [core1] => (item=core2)

PLAY RECAP ********************************************
core1  : ok=1  changed=0  unreachable=0  failed=0  skipped=0  rescued=0  ignored=0

The output is much easier to read now. Instead of IP addresses, we can see the name of each neighbor. If you check show history all on core1, you'll see that the same four ping commands were sent as before, so only the output in Ansible has changed.

core1#show history all
CMD: 'terminal length 0'
CMD: 'terminal width 512'
CMD: 'terminal width 0'
CMD: 'show version'
CMD: 'show vlan'
CMD: 'ping ip 172.16.1.0'
CMD: 'ping ip 172.16.1.2'
CMD: 'ping ip 172.16.1.4'
CMD: 'ping ip 172.16.1.13'

Looping Over a Dictionary

So far, we've looped over lists. But what if the data you want to loop over is a dictionary? A good example is the interface facts we gathered in the previous chapter. If you remember, net_interfaces is a dictionary, where each key is an interface name, and each value is another dictionary with the details of that interface.

net_interfaces:
  Ethernet0/0:
    description: clab-mgmt
    operstatus: up
    ...
  Ethernet0/1:
    description: core1 -> access1
    operstatus: up
    ...

The problem is that loop only works with lists, not dictionaries. To get around this, Ansible has a filter called dict2items, which converts a dictionary into a list. Each item in the new list is a small dictionary with two keys, key and value. (We will cover filters in detail later)

- key: Ethernet0/0
  value:
    description: clab-mgmt
    operstatus: up
    ...
- key: Ethernet0/1
  value:
    description: core1 -> access1
    operstatus: up
    ...

So the interface name becomes item.key, and its details become item.value. Let's use this to display the status of every interface on core1. Create a new playbook called interface_status.yml.

---
- name: Interface status
  hosts: core1
  gather_facts: false

  tasks:
    - name: Gather interface facts
      cisco.ios.ios_facts:
        gather_subset: interfaces

    - name: Display interface status
      ansible.builtin.debug:
        msg: "{{ item.key }} is {{ item.value.operstatus }}"
      loop: "{{ ansible_facts['net_interfaces'] | dict2items }}"
      loop_control:
        label: "{{ item.key }}"

The first task gathers the interface facts, just like we did in the previous chapter. In the second task, we pass net_interfaces through the dict2items filter using the | symbol, and loop over the result. For each interface, item.key gives us the interface name, and item.value.operstatus gives us its status. We're also using loop_control again, so the output only shows the interface name.

Let's run the playbook.

ansible-playbook interface_status.yml
PLAY [Interface status] *******************************

TASK [Gather interface facts] *************************
ok: [core1]

TASK [Display interface status] ***********************
ok: [core1] => (item=Ethernet0/0) => {
  "msg": "Ethernet0/0 is up"
}
ok: [core1] => (item=Ethernet0/1) => {
  "msg": "Ethernet0/1 is up"
}
ok: [core1] => (item=Ethernet0/2) => {
  "msg": "Ethernet0/2 is up"
}
ok: [core1] => (item=Ethernet0/3) => {
  "msg": "Ethernet0/3 is up"
}
ok: [core1] => (item=Ethernet1/0) => {
  "msg": "Ethernet1/0 is up"
}
ok: [core1] => (item=Ethernet1/1) => {
  "msg": "Ethernet1/1 is up"
}
ok: [core1] => (item=Ethernet1/2) => {
  "msg": "Ethernet1/2 is up"
}
ok: [core1] => (item=Ethernet1/3) => {
  "msg": "Ethernet1/3 is up"
}
ok: [core1] => (item=Loopback0) => {
  "msg": "Loopback0 is up"
}

PLAY RECAP ********************************************
core1  : ok=2  changed=0  unreachable=0  failed=0  skipped=0  rescued=0  ignored=0

We get a message for every interface on core1, along with its status. If you add a new interface to the switch, it shows up automatically the next time you run the playbook, since the loop is based on whatever the device has.

If we look at show history all on core1, it's also interesting to see which commands ios_facts runs behind the scenes to collect the interface facts.

core1#show history all
CMD: 'terminal length 0'
CMD: 'terminal width 512'
CMD: 'terminal width 0'
CMD: 'show version'
CMD: 'show vlan'
CMD: 'show version'
CMD: 'show switch virtual'
CMD: 'show inventory'
CMD: 'show interfaces'
CMD: 'show ip interface'
CMD: 'show ipv6 interface'
CMD: 'show lldp'
CMD: 'show cdp'
CMD: 'show lldp neighbors detail'
CMD: 'show cdp neighbors detail'

Notice that there are no commands for the second task. The loop and the debug module run entirely on the control node, using the facts we already collected, so Ansible doesn't need to go back to the device at all.

Understanding Lists and Dictionaries in Loops

We've now looped over three different kinds of data, and it's easy to get them mixed up, so let's take a step back and look at them side by side. Once you can tell which one you're dealing with, it's much easier to work out how to loop over it and how to get the values you need.

A List of Strings

This is the simplest one. It's a list where each item is a single value. Our first ping example used this.

- 172.16.1.0
- 172.16.1.2
- 172.16.1.4
list
├── [0] 172.16.1.0
├── [1] 172.16.1.2
└── [2] 172.16.1.4

When you loop over it, item is the value itself, so you just use {{ item }}.

Loop 1 > item = 172.16.1.0
Loop 2 > item = 172.16.1.2
Loop 3 > item = 172.16.1.4

A List of Dictionaries

This is still a list, so you can loop over it directly. The difference is that each item is a dictionary with its own keys. Our ping_targets variable is a list of dictionaries.

- name: access1
  ip: 172.16.1.0
- name: access2
  ip: 172.16.1.2
list
├── [0]
│   ├── name: access1
│   └── ip: 172.16.1.0
└── [1]
    ├── name: access2
    └── ip: 172.16.1.2

When you loop over it, item is the whole dictionary, so you add a dot and the key to get a specific value.

Loop 1 > item = {name: access1, ip: 172.16.1.0}
         item.name = access1
         item.ip   = 172.16.1.0

Loop 2 > item = {name: access2, ip: 172.16.1.2}
         item.name = access2
         item.ip   = 172.16.1.2
The easy way to spot a list in YAML is the dash. Every item in a list starts with a -.

A Dictionary of Dictionaries

This one is different. There are no dashes, so it's not a list. Instead, each key is a name, and its value is another dictionary. The interface facts are a dictionary like this.

Ethernet0/0:
  description: clab-mgmt
  operstatus: up
Ethernet0/1:
  description: core1 -> access1
  operstatus: up
dictionary
├── Ethernet0/0
│   ├── description: clab-mgmt
│   └── operstatus: up
└── Ethernet0/1
    ├── description: core1 -> access1
    └── operstatus: up

You can get a specific value directly by following the keys, which is what we did in the previous chapter.

ansible_facts['net_interfaces']['Ethernet0/1']['operstatus']  >  up

But you can't loop over it as it is, since loop only works with lists.

How dict2items Converts a Dictionary

The dict2items filter takes a dictionary and turns it into a list of dictionaries. Each key in the original dictionary becomes a list item with two keys of its own, key and value.

Before (dictionary)                After dict2items (list)

Ethernet0/0:                       - key: Ethernet0/0
  description: clab-mgmt             value:
  operstatus: up                       description: clab-mgmt
                                       operstatus: up
Ethernet0/1:
  description: core1 > access1    - key: Ethernet0/1
  operstatus: up                     value:
                                       description: core1 > access1
                                       operstatus: up

The interface name moves into key, and everything that was under it moves into value. Since the result is now a list of dictionaries, we can loop over it, just like we did with ping_targets.

Loop 1 > item.key              = Ethernet0/0
         item.value.operstatus = up

Loop 2 > item.key              = Ethernet0/1
         item.value.operstatus = up

Notice that item.value is itself a dictionary, so we add another dot to go one level deeper and get operstatus.

Seeing dict2items in Action

To see this conversion for yourself, let's create a small playbook called convert.yml. Instead of using the real interface facts, which would make the output very long, we'll define a small dictionary with just two interfaces.

---
- name: dict2items example
  hosts: localhost
  gather_facts: false

  vars:
    interfaces:
      Ethernet0/0:
        description: clab-mgmt
        operstatus: up
      Ethernet0/1:
        description: core1 -> access1
        operstatus: up

  tasks:
    - name: Before dict2items
      ansible.builtin.debug:
        var: interfaces

    - name: After dict2items
      ansible.builtin.debug:
        var: interfaces | dict2items

    - name: Loop over the result
      ansible.builtin.debug:
        msg: "{{ item.key }} is {{ item.value.operstatus }}"
      loop: "{{ interfaces | dict2items }}"
      loop_control:
        label: "{{ item.key }}"

Notice that we're using hosts: localhost here instead of core1. localhost is a built-in host in Ansible that refers to the control node itself, which is the machine you're running Ansible from. You don't need to add it to your inventory, since Ansible creates it automatically. In this playbook, we're only working with data, and we're not talking to any network devices, so there's no need to connect to a switch. Using localhost like this is a handy way to test things like loops, variables, and filters.

The first task prints the dictionary as it is, the second task prints it after passing it through dict2items, and the third task loops over the result.

ansible-playbook convert.yml
PLAY [dict2items example] *****************************

TASK [Before dict2items] ******************************
ok: [localhost] => {
  "interfaces": {
    "Ethernet0/0": {
      "description": "clab-mgmt",
      "operstatus": "up"
    },
    "Ethernet0/1": {
      "description": "core1 -> access1",
      "operstatus": "up"
    }
  }
}

TASK [After dict2items] *******************************
ok: [localhost] => {
  "interfaces | dict2items": [
    {
      "key": "Ethernet0/0",
      "value": {
        "description": "clab-mgmt",
        "operstatus": "up"
      }
    },
    {
      "key": "Ethernet0/1",
      "value": {
        "description": "core1 -> access1",
        "operstatus": "up"
      }
    }
  ]
}

TASK [Loop over the result] ***************************
ok: [localhost] => (item=Ethernet0/0) => {
  "msg": "Ethernet0/0 is up"
}
ok: [localhost] => (item=Ethernet0/1) => {
  "msg": "Ethernet0/1 is up"
}

PLAY RECAP ********************************************
localhost  : ok=3  changed=0  unreachable=0  failed=0  skipped=0  rescued=0  ignored=0
Ansible displays the output in JSON format, so here's a handy tip for reading it. Curly brackets {} mean a dictionary, and square brackets [] mean a list.

In the first task, interfaces starts with {, so it's a dictionary, with the interface names as keys. In the second task, the output starts with [, so it's now a list. Each item in the list has a key, which holds the interface name, and a value, which holds the description and status. This is exactly the conversion we looked at in the diagram.

Finally, the third task loops over the new list, using item.key for the interface name and item.value.operstatus for the status.

You might be wondering what happens if we set hosts to one of our switches instead. If you set it to core1, the playbook works exactly the same, and the output just shows core1 instead of localhost. Since debug runs on the control node, Ansible doesn't actually log in to the switch to run these tasks.

If you set hosts to a group, such as access, things get a bit more interesting. Remember, Ansible runs every task once for each device in the play. So with three access switches, each task runs three times.

TASK [Loop over the result] ***************************
ok: [access1] => (item=Ethernet0/0) => {
  "msg": "Ethernet0/0 is up"
}
ok: [access2] => (item=Ethernet0/0) => {
  "msg": "Ethernet0/0 is up"
}
ok: [access1] => (item=Ethernet0/1) => {
  "msg": "Ethernet0/1 is up"
}
ok: [access3] => (item=Ethernet0/0) => {
  "msg": "Ethernet0/0 is up"
}
ok: [access2] => (item=Ethernet0/1) => {
  "msg": "Ethernet0/1 is up"
}
ok: [access3] => (item=Ethernet0/1) => {
  "msg": "Ethernet0/1 is up"
}

PLAY RECAP ********************************************
access1  : ok=3  changed=0  unreachable=0  failed=0  skipped=0  rescued=0  ignored=0
access2  : ok=3  changed=0  unreachable=0  failed=0  skipped=0  rescued=0  ignored=0
access3  : ok=3  changed=0  unreachable=0  failed=0  skipped=0  rescued=0  ignored=0

Each switch gets exactly the same result, because the data comes from the playbook, not from the switches. Running it against three devices doesn't give us anything new, it just repeats the same work three times. You can also see the results from the three switches mixed together, since Ansible runs them at the same time. With localhost, the playbook only runs once, which is all we need when we're only working with data.

Conditionals

So far, every task in our playbooks has run every time, on every device. But sometimes, you only want a task to run when a certain condition is true. For example, you might only want to configure something if a device is running a specific software version, or only display something if an interface has no description.

In Ansible, you do this using when. You add it to a task along with a condition, and Ansible only runs the task if the condition is true. If it's not, the task is skipped.

A Condition Based on a Fact

Let's start with a simple example. In the previous chapter, we saw that core1 is running version 17.12.1. Let's say this is the version we expect all our switches to run, and we want to check it. Create a new playbook called conditionals.yml.

---
- name: Conditionals
  hosts: core1
  gather_facts: true

  tasks:
    - name: Check software version
      ansible.builtin.debug:
        msg: "{{ inventory_hostname }} is running the expected version"
      when: ansible_facts['net_version'] == "17.12.1"

    - name: Warn about other versions
      ansible.builtin.debug:
        msg: "{{ inventory_hostname }} is NOT running the expected version"
      when: ansible_facts['net_version'] != "17.12.1"

We've set gather_facts to true, so Ansible collects the version before running our tasks. Both tasks have a when condition, and just like loop, it sits at the same level as the module, since it's an option for the task.

  • == - Means "is equal to". The first task only runs if the version is 17.12.1.
  • != - Means "is not equal to". The second task only runs if the version is anything else.

You'll also notice that the condition doesn't use double curly brackets. when already expects a variable or expression, so Ansible manages it for you.

Let's run the playbook.

ansible-playbook conditionals.yml
PLAY [Conditionals] ***********************************

TASK [Gathering Facts] ********************************
ok: [core1]

TASK [Check software version] *************************
ok: [core1] => {
  "msg": "core1 is running the expected version"
}

TASK [Warn about other versions] **********************
skipping: [core1]

PLAY RECAP ********************************************
core1  : ok=2  changed=0  unreachable=0  failed=0  skipped=1  rescued=0  ignored=0

Since core1 is running 17.12.1, the first task ran, and the second task shows skipping. The PLAY RECAP also shows skipped=1. If you had switches running different versions, the first task would run on some of them and the second task on the others.

Combining Loops and Conditions

You can also use when together with loop. When you do, the condition is checked for each item in the loop, rather than once for the whole task. This is really useful for filtering through a list and only acting on the items you care about.

For example, let's find all the interfaces on core1 that don't have a description. This is something I've done many times manually, and it's a lot faster with Ansible. Create a new playbook called no_description.yml.

---
- name: Find interfaces without a description
  hosts: core1
  gather_facts: false

  tasks:
    - name: Gather interface facts
      cisco.ios.ios_facts:
        gather_subset: interfaces

    - name: Display interfaces without a description
      ansible.builtin.debug:
        msg: "{{ item.key }} has no description"
      loop: "{{ ansible_facts['net_interfaces'] | dict2items }}"
      loop_control:
        label: "{{ item.key }}"
      when: item.value.description is none

This is the same loop we used earlier to display the status of every interface. The only difference is the when condition. If you remember from the interface facts, interfaces without a description have "description": null. In Ansible, null is called none, so item.value.description is none checks whether the interface has no description.

Let's run the playbook.

ansible-playbook no_description.yml
PLAY [Find interfaces without a description] **********

TASK [Gather interface facts] *************************
ok: [core1]

TASK [Display interfaces without a description] *******
skipping: [core1] => (item=Ethernet0/0)
skipping: [core1] => (item=Ethernet0/1)
skipping: [core1] => (item=Ethernet0/2)
skipping: [core1] => (item=Ethernet0/3)
skipping: [core1] => (item=Ethernet1/0)
ok: [core1] => (item=Ethernet1/1) => {
  "msg": "Ethernet1/1 has no description"
}
ok: [core1] => (item=Ethernet1/2) => {
  "msg": "Ethernet1/2 has no description"
}
ok: [core1] => (item=Ethernet1/3) => {
  "msg": "Ethernet1/3 has no description"
}
ok: [core1] => (item=Loopback0) => {
  "msg": "Loopback0 has no description"
}

PLAY RECAP ********************************************
core1  : ok=2  changed=0  unreachable=0  failed=0  skipped=0  rescued=0  ignored=0

The interfaces that already have a description are skipped, and we only get a message for the four interfaces without one.

You might notice that the PLAY RECAP shows skipped=0, even though some items were skipped. This is because the PLAY RECAP counts tasks, not loop items. Since at least one item in the loop ran, the task as a whole counts as ok. The task would only count as skipped if every item in the loop was skipped.

A Condition Based on a Variable

Another common use for when is to check whether a variable has been defined. This is useful when some devices need a certain config and others don't. Instead of creating separate playbooks, you can define the variable only for the devices that need it, and the task is skipped for the rest.

Let's use syslog as an example. First, add a list of syslog servers to group_vars/cisco/vars.yml.

syslog_servers:
  - host: 10.10.10.50
  - host: 10.10.10.51

We're going to use the ios_logging_global resource module, and if you check its documentation, you'll see that each syslog server in the hosts option is a dictionary with a key called host. That's why our variable is a list of dictionaries, rather than just a list of IP addresses.

Next, create a new playbook called syslog.yml.

---
- name: Configure syslog
  hosts: core1
  gather_facts: false

  tasks:
    - name: Configure syslog servers
      cisco.ios.ios_logging_global:
        config:
          hosts: "{{ syslog_servers }}"
      when: syslog_servers is defined

The condition syslog_servers is defined checks whether the variable exists for the device. If it does, the task runs. If it doesn't, the task is skipped.

Let's run the playbook.

ansible-playbook syslog.yml
PLAY [Configure syslog] *******************************

TASK [Configure syslog servers] ***********************
changed: [core1]

PLAY RECAP ********************************************
core1  : ok=1  changed=1  unreachable=0  failed=0  skipped=0  rescued=0  ignored=0

Since syslog_servers is defined, the task ran and configured both syslog servers on core1. If we run it again with -v, we can see that both servers are already there, so there's nothing to change.

ansible-playbook syslog.yml -v
TASK [Configure syslog servers] ***********************
ok: [core1] => {
  "before": {
    "buffered": {"size": 256000},
    "hosts": [
      {"host": "10.10.10.50"},
      {"host": "10.10.10.51"}
    ],
    "trap": "informational"
  },
  "changed": false,
  "commands": []
}

Now, let's comment out the syslog_servers variable in group_vars/cisco/vars.yml and run the playbook again.

# syslog_servers:
#   - host: 10.10.10.50
#   - host: 10.10.10.51
ansible-playbook syslog.yml -v
PLAY [Configure syslog] *******************************

TASK [Configure syslog servers] ***********************
skipping: [core1] => {
  "changed": false,
  "false_condition": "syslog_servers is defined",
  "skip_reason": "Conditional result was False"
}

PLAY RECAP ********************************************
core1  : ok=0  changed=0  unreachable=0  failed=0  skipped=1  rescued=0  ignored=0

This time, the task was skipped. With -v, Ansible also tells us exactly why. The false_condition shows the condition that wasn't met, and the skip_reason confirms the condition was false.

Without the when condition, the task would have failed with an error saying syslog_servers is undefined. So is defined is a simple way to make your playbooks safe to run, even when not every device has every variable.

Before moving on, uncomment syslog_servers again, since we'll use it in the next section.

Handlers

In an earlier chapter, we saved the config using a separate task with save_when: modified. That works, but there's a better way to do it.

Ideally, we only want to save the config when something has actually changed. If nothing changed, there's nothing to save. Handlers let us do exactly that. A handler is a task that only runs when another task tells it to, and a task only does that when it reports changed.

Let's create a new playbook called handlers.yml, which configures both NTP and syslog, and saves the config if either of them changes.

---
- name: Configure NTP and syslog
  hosts: core1
  gather_facts: false

  tasks:
    - name: Configure NTP servers
      cisco.ios.ios_ntp_global:
        config:
          servers:
            - server: 10.10.10.13
            - server: 10.10.10.14
      notify: Save config

    - name: Configure syslog servers
      cisco.ios.ios_logging_global:
        config:
          hosts: "{{ syslog_servers }}"
      notify: Save config

  handlers:
    - name: Save config
      cisco.ios.ios_config:
        save_when: always

There are two new things here.

  • notify - Added to a task, and tells Ansible which handler to run if the task makes a change. The name must match the name of the handler exactly.
  • handlers - A separate section in the play, at the same level as tasks. Handlers are written just like normal tasks, but they only run when they're notified.

We've set save_when to always here, since the handler itself already makes sure the config is only saved when something has changed.

Before running the playbook, I removed the NTP servers we added with ios_ntp_global and all the syslog servers from core1, so we're starting fresh. The syslog_servers variable in group_vars/cisco/vars.yml has two servers.

syslog_servers:
  - host: 10.10.10.50
  - host: 10.10.10.51

When Both Tasks Change

Let's run the playbook.

ansible-playbook handlers.yml
PLAY [Configure NTP and syslog] ***********************

TASK [Configure NTP servers] **************************
changed: [core1]

TASK [Configure syslog servers] ***********************
changed: [core1]

RUNNING HANDLER [Save config] *************************
changed: [core1]

PLAY RECAP ********************************************
core1  : ok=3  changed=3  unreachable=0  failed=0  skipped=0  rescued=0  ignored=0

Both tasks show changed, so both of them notified the Save config handler. You can see the handler running in its own section at the end, RUNNING HANDLER [Save config]. But even though it was notified twice, it only ran once. The history on core1 confirms this.

core1#show history all
CMD: 'terminal length 0'
CMD: 'terminal width 512'
CMD: 'terminal width 0'
CMD: 'show version'
CMD: 'show vlan'
CMD: 'show running-config | section ^ntp'
CMD: 'configure terminal'
CMD: 'ntp server 10.10.10.14'
CMD: 'ntp server 10.10.10.13'
CMD: 'end'
CMD: 'show running-config | section ^ntp'
CMD: 'show running-config | include logging'
CMD: 'show logging | include Trap'
CMD: 'configure terminal'
CMD: 'logging host 10.10.10.50'
CMD: 'logging host 10.10.10.51'
CMD: 'end'
CMD: 'show running-config | include logging'
CMD: 'show logging | include Trap'
CMD: 'copy running-config startup-config'

First, the NTP servers are added, then the syslog servers, and finally, there's a single copy running-config startup-config at the very end. Notice that the handler didn't run straight after the NTP task. Handlers always run at the end of the play, after all the tasks have finished. And no matter how many tasks notify the same handler, it only runs once.

When Nothing Changes

Now, let's run the playbook again without changing anything.

ansible-playbook handlers.yml
PLAY [Configure NTP and syslog] ***********************

TASK [Configure NTP servers] **************************
ok: [core1]

TASK [Configure syslog servers] ***********************
ok: [core1]

PLAY RECAP ********************************************
core1  : ok=2  changed=0  unreachable=0  failed=0  skipped=0  rescued=0  ignored=0

This time, both tasks show ok, so neither of them notified the handler, and there's no RUNNING HANDLER section at all. The history confirms that Ansible only read the config, and didn't save anything.

core1#show history all
CMD: 'terminal length 0'
CMD: 'terminal width 512'
CMD: 'terminal width 0'
CMD: 'show version'
CMD: 'show vlan'
CMD: 'show running-config | section ^ntp'
CMD: 'show running-config | include logging'
CMD: 'show logging | include Trap'

When One Task Changes

Finally, let's add a third syslog server to group_vars/cisco/vars.yml, and leave NTP as it is.

syslog_servers:
  - host: 10.10.10.50
  - host: 10.10.10.51
  - host: 10.10.10.52
ansible-playbook handlers.yml
PLAY [Configure NTP and syslog] ***********************

TASK [Configure NTP servers] **************************
ok: [core1]

TASK [Configure syslog servers] ***********************
changed: [core1]

RUNNING HANDLER [Save config] *************************
changed: [core1]

PLAY RECAP ********************************************
core1  : ok=3  changed=2  unreachable=0  failed=0  skipped=0  rescued=0  ignored=0

The NTP task shows ok, while the syslog task shows changed. Since one of the tasks made a change, the handler ran and saved the config.

core1#show history all
CMD: 'terminal length 0'
CMD: 'terminal width 512'
CMD: 'terminal width 0'
CMD: 'show version'
CMD: 'show vlan'
CMD: 'show running-config | section ^ntp'
CMD: 'show running-config | include logging'
CMD: 'show logging | include Trap'
CMD: 'configure terminal'
CMD: 'logging host 10.10.10.52'
CMD: 'end'
CMD: 'show running-config | include logging'
CMD: 'show logging | include Trap'
CMD: 'copy running-config startup-config'

Only the new syslog server was added, followed by the save.

This is the main benefit of handlers. The config is saved whenever something changes, no matter which task made the change, and it's only saved once. If nothing changes, nothing is saved. Without handlers, you'd either save the config after every task, or add a save task at the end that runs every time, even when there's nothing to save.

Putting It All Together

Let's finish this chapter with a more practical example that brings together everything we've covered so far. One thing I've done many times over the years is log into a switch, run show cdp neighbors, and then manually update the description on each uplink, so it says what's connected on the other end. It's not difficult, but it's tedious, and the descriptions quickly go out of date when things change.

Since ios_facts already gives us the CDP neighbors for each interface, we can use Ansible to do this for us. Here's what the descriptions on core1 look like before we start. These were set by Netlab when the lab was created.

core1#show interfaces description
Interface   Status   Protocol Description
Et0/0       up       up       clab-mgmt
Et0/1       up       up       core1 -> access1
Et0/2       up       up       core1 -> access2
Et0/3       up       up       core1 -> access3
Et1/0       up       up       core1 -> core2
Et1/1       up       up
Et1/2       up       up
Et1/3       up       up
Lo0         up       up
core1#show cdp neighbors
Device ID     Local Intrfce  Holdtme  Platform   Port ID
core2.lab     Eth 1/0        156      Linux Uni  Eth 1/0
core2.lab     Eth 0/0        152      Linux Uni  Eth 0/0
access1.lab   Eth 0/1        129      Linux Uni  Eth 0/1
access1.lab   Eth 0/0        157      Linux Uni  Eth 0/0
access3.lab   Eth 0/3        164      Linux Uni  Eth 0/1
access3.lab   Eth 0/0        145      Linux Uni  Eth 0/0
access2.lab   Eth 0/2        164      Linux Uni  Eth 0/1
access2.lab   Eth 0/0        143      Linux Uni  Eth 0/0

Create a new playbook called cdp_descriptions.yml.

---
- name: Set interface descriptions from CDP
  hosts: core1
  gather_facts: false

  tasks:
    - name: Gather interface facts
      cisco.ios.ios_facts:
        gather_subset: interfaces

    - name: Set interface descriptions
      cisco.ios.ios_interfaces:
        config:
          - name: "{{ item.key }}"
            description: "to {{ item.value[0].host }} {{ item.value[0].port }}"
      loop: "{{ ansible_facts['net_neighbors'] | dict2items }}"
      loop_control:
        label: "{{ item.key }}"
      when: item.key != "Ethernet0/0"
      notify: Save config

  handlers:
    - name: Save config
      cisco.ios.ios_config:
        save_when: always

There's a lot going on here, so let's break it down.

  • Gather interface facts - The first task collects the interface facts, which include net_neighbors. Just like net_interfaces, this is a dictionary, where each key is an interface name, and each value is a list of the CDP neighbors on that interface.
  • loop - We use dict2items to turn net_neighbors into a list, so we can loop over it. For each item, item.key is the interface name on core1.
  • item.value[0] - For each interface, the value is a list of neighbors. We use [0] to get the first item in the list, and then .host and .port to get the neighbor's hostname and the interface on its side.
  • when - Ethernet0/0 is the management interface, and since all the switches share the same management network, it sees every switch as a neighbor. We don't want to change its description, so we skip it.
  • ios_interfaces - The resource module for interfaces. We use it to set the description, and it only makes a change if the description is different.
  • notify - If any of the descriptions change, the handler saves the config at the end.

Let's run the playbook.

ansible-playbook cdp_descriptions.yml
PLAY [Set interface descriptions from CDP] ************

TASK [Gather interface facts] *************************
ok: [core1]

TASK [Set interface descriptions] *********************
changed: [core1] => (item=Ethernet0/1)
changed: [core1] => (item=Ethernet0/2)
changed: [core1] => (item=Ethernet0/3)
changed: [core1] => (item=Ethernet1/0)
skipping: [core1] => (item=Ethernet0/0)

RUNNING HANDLER [Save config] *************************
changed: [core1]

PLAY RECAP ********************************************
core1  : ok=3  changed=2  unreachable=0  failed=0  skipped=0  rescued=0  ignored=0

The descriptions on the four uplinks were changed, Ethernet0/0 was skipped, and since at least one item changed, the handler saved the config at the end. Let's check the descriptions on core1 again.

core1#show interfaces description
Interface   Status   Protocol Description
Et0/0       up       up       clab-mgmt
Et0/1       up       up       to access1.lab Ethernet0/1
Et0/2       up       up       to access2.lab Ethernet0/1
Et0/3       up       up       to access3.lab Ethernet0/1
Et1/0       up       up       to core2.lab Ethernet1/0
Et1/1       up       up
Et1/2       up       up
Et1/3       up       up
Lo0         up       up

Each uplink now describes exactly what's connected on the other end. If you run the playbook against all your switches, every uplink in the network gets a consistent description, and if you move a cable, simply run the playbook again to update it.

If we look at show history all, we can see what happened behind the scenes. I've trimmed it to show the first interface and the end of the run.

core1#show history all
...
CMD: 'show cdp neighbors detail'
CMD: 'show running-config | section ^interface'
CMD: 'configure terminal'
CMD: 'interface Ethernet0/1'
CMD: 'description to access1.lab Ethernet0/1'
CMD: 'end'
CMD: 'show running-config | section ^interface'
...
CMD: 'configure terminal'
CMD: 'interface Ethernet1/0'
CMD: 'description to core2.lab Ethernet1/0'
CMD: 'end'
CMD: 'show running-config | section ^interface'
CMD: 'copy running-config startup-config'

Since we're using a loop, ios_interfaces runs once for each interface, so each description is set in its own configure terminal session. At the end, the handler saves the config once, even though four items in the loop reported changed.

Wrapping Up

In this post, we looked at three features that give you more control over how and when your tasks run. We used loops to run the same task for each item in a list, including lists of dictionaries and dictionaries converted with dict2items. We used conditionals to only run tasks when a condition is true, and handlers to save the config only when something has actually changed. Finally, we brought everything together to set interface descriptions automatically based on CDP neighbors.

Written by
Suresh Vinasiththamby
Tech enthusiast sharing Networking, Cloud & Automation insights. Join me in a welcoming space to learn & grow with simplicity and practicality.
Comments
More from Packetswitch
Great! You’ve successfully signed up.
Welcome back! You've successfully signed in.
You've successfully subscribed to Packetswitch.
Your link has expired.
Success! Check your email for magic link to sign-in.
Success! Your billing info has been updated.
Your billing was not updated.