Pushing Configuration with Ansible

Pushing Configuration with Ansible
Table of Contents

So far, we've only used Ansible to run show commands, which is a great way to get started because there's no risk of breaking anything. But at some point, you'll want to use Ansible to actually make changes, and that's what this chapter is all about.

We'll start small by creating a single VLAN on a single switch. From there, we'll create multiple VLANs, then run the same playbook against more devices, and slowly build on it until we're managing different VLANs on different switches. Each step introduces something new, so by the end of the chapter, you'll have covered a lot of the core Ansible concepts without even noticing.

Since we're working with VLANs, you'll need switches in your lab. If you're using Cisco IOS like me, make sure you're using the Layer 2 image rather than the router image. To keep things simple, I'll start with a single switch called s1, and we'll add more devices later in the chapter.

As always, please make sure you're running this in a lab and not on production devices. We're about to start making changes, so it's a good time to be extra careful.

Lab Setup

Since we're working with VLANs, you'll need switches in your lab. If you're using Cisco IOL like me, make sure you're using the Layer 2 image rather than the router image.

For this chapter, I'm using a lab with five switches, two core switches (core1 and core2) and three access switches (access1, access2, and access3). Each access switch connects to both core switches, and the two core switches are also connected to each other. You don't need to build the exact same topology to follow along. Any number of switches connected in any way will work just fine. The goal here is to learn Ansible, not to build the perfect lab, so don't spend too much time on it. Even a single switch is enough for most of this chapter.

We'll start by configuring core1 only, and then add the other switches as we go through the chapter.

Creating a Single VLAN

Before making any changes, let's check what VLANs we currently have on core1.

core1#show vlan

VLAN Name                             Status    Ports
---- -------------------------------- --------- -------------------------------
1    default                          active    Et1/1, Et1/2, Et1/3
1002 fddi-default                     act/unsup 
1003 token-ring-default               act/unsup 
1004 fddinet-default                  act/unsup 
1005 trnet-default                    act/unsup 

As you can see, there's nothing here apart from the default VLANs, so let's create our first one.

Inventory

First, let's update the inventory to point to core1 instead of r1.

---
all:
  hosts:
    core1:
      ansible_host: 192.168.202.101
      ansible_network_os: cisco.ios.ios
      ansible_connection: ansible.netcommon.network_cli
      ansible_user: admin
      ansible_password: admin
Please note that in production, you should never store passwords in plain text. We'll look at how to encrypt them using Ansible Vault in the upcoming chapters.

Playbook

Next, create a new playbook called create_vlan.yml. We're going to create VLAN 10 and name it users.

---
- name: Configure VLANs
  hosts: all
  gather_facts: false

  tasks:
    - name: Create VLAN 10
      cisco.ios.ios_config:
        parents: vlan 10
        lines:
          - name users

This time, instead of ios_command, we're using the ios_config module. As the name suggests, ios_command is for running show commands, and ios_config is for making configuration changes.

There's actually a better way to create VLANs using a dedicated module called ios_vlans, which we'll look at later. But I want to start with ios_config first because it works just like typing commands into the CLI, which makes it easier to understand what's happening. Once you've seen how it works, the benefits of the dedicated module will make a lot more sense.

The ios_config module takes two options here.

  • parents - The parent command that the lines go under. If you were configuring this manually, this is the command you'd type to enter the VLAN config mode.
  • lines - The commands to run under the parent. Just like commands in ios_command, this is a list, so you can add as many lines as you need.

If you were doing this by hand, it would look like this.

core1(config)#vlan 10
core1(config-vlan)#name users

So parents is vlan 10, and lines is name users. Ansible takes care of entering config mode, running the commands, and exiting again.

Running the Playbook

Let's run the playbook.

ansible-playbook create_vlan.yml

PLAY [Configure VLANs] ********************************

TASK [Create VLAN 10] *********************************
[WARNING]: To ensure idempotency and correct diff the input
configuration lines should be similar to how theyappear if
present in the running configuration on device
changed: [core1]

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

Notice that this time, the task shows changed instead of ok, and the PLAY RECAP shows changed=1. This tells us that Ansible made a change on the device.

If we log in to core1 and check the VLANs again, we can see VLAN 10 has been created with the name users.

core1#show vlan

VLAN Name                             Status    Ports
---- -------------------------------- --------- -------------------------------
1    default                          active    Et1/1, Et1/2, Et1/3
10   users                            active    
1002 fddi-default                     act/unsup 
1003 token-ring-default               act/unsup 
1004 fddinet-default                  act/unsup 
1005 trnet-default                    act/unsup 

The playbook worked, and VLAN 10 was created. But, there is a warning that says 'WARNING: To ensure idempotency and correct diff the input configuration lines should be similar to how theyappear if present in the running configuration on device’

Before making a change, ios_config reads the running config and compares it with the lines in your task. If the lines are already there, it does nothing. If they aren't, it pushes them. The warning appears when, after the change, it still can't find your lines in the running config in the same format.

The most likely reason here is VTP. By default, Cisco switches run in VTP server mode, and in that mode, VLANs are stored in vlan.dat rather than the running config. So vlan 10 and name users never show up in show running-config, and Ansible can't see them. You can confirm this on core1:

core1#show running-config | section vlan

If VLAN 10 doesn't appear, that's the cause. It also means that if you run the playbook again, it will show changed every time instead of ok, because Ansible never finds the VLAN in the running config.

To fix it, set VTP to transparent mode (or off) on the switch, and the VLANs will show up in the running config.

core1(config)#vtp mode transparent
core1#show run | sec vlan
vlan 10
 name users
core1#
Just to be clear, I'm not asking you to change the VTP mode on your production switches. Changing VTP settings in a live network can have side effects, so this is only to make sure you can follow along in your lab.

Running It Again

Now, what happens if we run the same playbook again?

ansible-playbook create_vlan.yml

PLAY [Configure VLANs] ********************************

TASK [Create VLAN 10] *********************************
ok: [core1]

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

This time, the task shows ok instead of changed, and the PLAY RECAP shows changed=0. Before making any changes, ios_config checks the running config on the device. Since VLAN 10 with the name users is already there, Ansible knows there's nothing to do and doesn't send any commands to the device.

You can run this playbook as many times as you like, and the result will be the same. Ansible will only make a change if the config on the device is different from what's in the playbook. This is called idempotency, and it's one of the most useful things about Ansible. It means you can safely run the same playbook over and over without worrying about it making the same change twice.

This becomes really useful when you have lots of devices. If someone removes VLAN 10 from a switch by mistake, you can simply run the playbook again, and Ansible will only add it back on the switch where it's missing. Every other switch will show ok.

If you see changed every time you run the playbook, check whether your switch is running in VTP server mode. In this mode, VLANs aren't shown in the running config, so Ansible can't find them and thinks it needs to add them every time. Setting VTP to transparent mode fixes this.

A Better Way with ios_vlans

Using ios_config works, but as we saw earlier, it has a few limitations.

The main one is that ios_config doesn't actually understand what a VLAN is. It simply takes the lines you give it and compares them, as text, with the running config on the device. This means your lines have to look exactly like they do in the running config, otherwise you'll see warnings, or Ansible will make the change every time you run the playbook.

For a simple VLAN, this is fine. But for something like OSPF or BGP, we'd end up adding the exact CLI commands to our playbooks, line by line, which isn't really great. At that point, we're just copying CLI config into a YAML file.

What Is a Resource Module?

A resource module is a module built to manage one specific part of the device config, such as VLANs, interfaces, or OSPF. Instead of giving it CLI commands, you describe what you want using structured data, and the module takes care of the rest.

Behind the scenes, the module reads the current config from the device, understands it, compares it with what you asked for, and then works out which commands to send. You don't need to worry about the exact syntax or how the device displays its config.

For VLANs, the cisco.ios collection includes a resource module called ios_vlans. Here's the same VLAN, created using ios_vlans instead of ios_config.

---
- name: Configure VLANs
  hosts: all
  gather_facts: false

  tasks:
    - name: Create VLAN 10
      cisco.ios.ios_vlans:
        config:
          - vlan_id: 10
            name: users

Instead of parents and lines, we now have a config option, which is a list of VLANs. Each VLAN is a dictionary with a vlan_id and a name. There are no CLI commands here at all. We're simply telling Ansible that we want VLAN 10 to exist with the name users. If you check the documentation for ios_vlans, you'll see all the other options you can set for a VLAN, such as shutdown and mtu.

Since VLAN 10 already exists from the previous section, running this playbook shows ok, because there's nothing to change.

If you remove VLAN 10 from core1 and run the playbook again, you'll see changed, and the VLAN is created again. Run it once more, and it goes back to ok.

Creating Multiple VLANs

Now that we have one VLAN, let's add two more. Since config is a list, adding more VLANs is as simple as adding more items to the list.

---
- name: Configure VLANs
  hosts: all
  gather_facts: false

  tasks:
    - name: Create VLANs
      cisco.ios.ios_vlans:
        config:
          - vlan_id: 10
            name: users
          - vlan_id: 20
            name: voice
          - vlan_id: 30
            name: printers

Each VLAN is a separate item in the list, starting with a dash, and each one has its own vlan_id and name. I've also renamed the task to Create VLANs, since it now creates more than one. Let's run the playbook.

ansible-playbook create_vlan.yml

PLAY [Configure VLANs] ********************************

TASK [Create VLANs] ***********************************
changed: [core1]

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

The task shows changed, since VLANs 20 and 30 didn't exist before. VLAN 10 is still in the list, but since it already exists, Ansible leaves it alone and only creates the two new ones.

core1#show vlan

VLAN Name                             Status    Ports
---- -------------------------------- --------- -------------------------------
1    default                          active    Et1/1, Et1/2, Et1/3
10   users                            active    
20   voice                            active    
30   printers                         active    
1002 fddi-default                     act/unsup 
1003 token-ring-default               act/unsup 
1004 fddinet-default                  act/unsup 
1005 trnet-default                    act/unsup 

If you run the playbook again, you'll see ok, because all three VLANs are now on the device.

With ios_config, we would've needed a separate task for each VLAN, or used something called a loop to repeat the same task for each one. With ios_vlans, we don't need any of that, because the module already accepts a list of VLANs. We'll still look at loops later in the book, since there are plenty of other cases where you'll need them.

As I mentioned before, whenever you use a new module, make sure to check its documentation to see what options it supports. For ios_vlans, the docs show that config is a list of VLANs, and each VLAN supports options such as vlan_id, name, shutdown, and mtu. You'll also see a top-level state option, which defaults to merged. We'll look at the other states later in the book.

Adding a Second Device

So far, we've only been working with core1. Now let's add core2 to the inventory and run the same playbook against both switches.

---
all:
  hosts:
    core1:
      ansible_host: 192.168.202.101
      ansible_network_os: cisco.ios.ios
      ansible_connection: ansible.netcommon.network_cli
      ansible_user: admin
      ansible_password: admin
    core2:
      ansible_host: 192.168.202.102
      ansible_network_os: cisco.ios.ios
      ansible_connection: ansible.netcommon.network_cli
      ansible_user: admin
      ansible_password: admin

core2 sits under hosts at the same level as core1, with its own IP address and connection details. We don't need to change anything in the playbook. Since it uses hosts: all, it will now run against both switches.

ansible-playbook create_vlan.yml
ansible-playbook create_vlan.yml

PLAY [Configure VLANs] ********************************

TASK [Create VLANs] ***********************************
ok: [core1]
changed: [core2]

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

This time, you'll see a result for each device. core1 shows ok because it already has all the VLANs, while core2 shows changed because the VLANs were just created on it. The PLAY RECAP also shows a separate line for each device.

This is where Ansible really starts to pay off. Whether you have 2 switches or 200, the playbook stays exactly the same. You only need to add the devices to the inventory.

Also worth mentioning that Ansible doesn't configure the devices one after the other. By default, it runs against up to five devices at the same time, so adding more devices doesn't slow things down as much as you might expect.

Inventory Groups

If you look at the inventory we have so far, you'll notice that we're repeating the same connection settings for every device. ansible_network_os, ansible_connection, ansible_user, and ansible_password are exactly the same for both switches. With two devices that's manageable, but imagine having 50 switches and needing to change the password. You'd have to update it in 50 places.

There's also another problem. Right now, the playbook runs against every device in the inventory. But what if we only want to configure the two core switches and not the access switches? This is where inventory groups can help.

A group is simply a way to put devices together, so you can define settings once for the whole group and target the group from your playbooks. Let's add all five switches to the inventory and organise them into groups.

---
all:
  children:
    cisco:
      vars:
        ansible_network_os: cisco.ios.ios
        ansible_connection: ansible.netcommon.network_cli
        ansible_user: admin
        ansible_password: admin
      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

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

  • all - The built-in group that includes every device in the inventory, just like before.
  • children - Used to create groups inside another group. Under all, we've created a group called cisco.
  • cisco - A group for all our Cisco devices. You can name groups anything you like.
  • vars - Variables defined here apply to every device in the cisco group. This is where we've moved the connection settings, so we only define them once.
  • children (under cisco) - We use children again to create two more groups inside cisco, one called core and one called access.
  • core and access - Each of these groups has its own hosts, which lists the devices in that group.
  • hosts - The devices in the group. Each device now only has ansible_host, since everything else comes from the cisco group.

The important thing to understand is that groups inherit variables from their parent. core and access are both inside cisco, so every device in those groups gets the connection settings we defined under cisco. If you need to change the password, you only change it in one place.

You might be wondering why we created a cisco group instead of putting the variables directly under all. In the future, you may add a Juniper device to the inventory, and its connection settings will be different. Keeping the Cisco settings under a cisco group means they only apply to Cisco devices.

Viewing the Inventory

When your inventory starts to grow, it can be hard to see how everything fits together. You can use the ansible-inventory command to display the groups and devices as a tree.

ansible-inventory --graph
ansible-inventory --graph

@all:
  |--@ungrouped:
  |--@cisco:
  |  |--@core:
  |  |  |--core1
  |  |  |--core2
  |  |--@access:
  |  |  |--access1
  |  |  |--access2
  |  |  |--access3

This is a quick way to confirm that your devices are in the right groups before running a playbook.

Targeting a Group

Now, let's update the playbook to only run against the core switches. The only thing we need to change is hosts.

---
- name: Configure VLANs
  hosts: core
  gather_facts: false

  tasks:
    - name: Create VLANs
      cisco.ios.ios_vlans:
        config:
          - vlan_id: 10
            name: users
          - vlan_id: 20
            name: voice
          - vlan_id: 30
            name: printers

Let's run the playbook.

ansible-playbook create_vlan.yml

PLAY [Configure VLANs] ********************************

TASK [Create VLANs] ***********************************
ok: [core1]
ok: [core2]

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

As you can see, the playbook only ran against core1 and core2. The access switches weren't touched at all.

You can use any group name in hosts. For example, hosts: access would only run against the access switches, and hosts: cisco would run against all five. You can also target more than one group by separating them with a comma, such as hosts: core,access, or use a single device name, such as hosts: core1.

Let's try targeting both groups by updating hosts in the playbook.

hosts: core,access
ansible-playbook create_vlan.yml

PLAY [Configure VLANs] ********************************

TASK [Create VLANs] ***********************************
ok: [core1]
ok: [core2]
changed: [access2]
changed: [access3]
changed: [access1]

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

This time, the playbook ran against all five switches. core1 and core2 show ok because they already have all four VLANs from the previous run, so there was nothing to change. The three access switches show changed because this was the first time the playbook ran against them, and the VLANs were just created.

INI Inventory Format

So far, we've been writing our inventory in YAML, but Ansible also supports the INI format. You'll come across it quite often, especially in older examples and blog posts, so it's worth knowing how it looks. Here's the same inventory in INI format.

[core]
core1 ansible_host=192.168.202.101
core2 ansible_host=192.168.202.102

[access]
access1 ansible_host=192.168.202.103
access2 ansible_host=192.168.202.104
access3 ansible_host=192.168.202.105

[cisco:children]
core
access

[cisco:vars]
ansible_network_os=cisco.ios.ios
ansible_connection=ansible.netcommon.network_cli
ansible_user=admin
ansible_password=admin

The structure is a bit different from YAML, so let's go through it.

  • [core] and [access] - The name in square brackets is the group name. The lines below it are the devices in that group.
  • Device lines - Each device goes on its own line, followed by its variables in key=value format. Notice that INI uses = instead of :.
  • [cisco:children] - Adding :children to a group name means the lines below it are groups, not devices. Here, we're making core and access child groups of cisco.
  • [cisco:vars] - Adding :vars to a group name lets you define variables for that group. Just like before, every device in core and access inherits these variables.

You don't need to define the all group in INI format. Every device is automatically part of it.

To use this inventory, save it as inventory.ini and update ansible.cfg to point to it.

[defaults]
inventory = inventory.ini
host_key_checking = False

You can also use the -i option to specify an inventory file when running a playbook. This overrides the inventory set in ansible.cfg, which is handy if you want to quickly test a different inventory without changing the config file.

ansible-playbook create_vlan.yml -i inventory.ini

If you run ansible-inventory --graph, you'll see exactly the same groups and devices as before.

ansible-inventory --graph

@all:
  |--@ungrouped:
  |--@cisco:
  |  |--@core:
  |  |  |--core1
  |  |  |--core2
  |  |--@access:
  |  |  |--access1
  |  |  |--access2
  |  |  |--access3

Both formats do the same job, so it really comes down to personal preference. INI is shorter and quicker to write for simple inventories, while YAML is easier to read once you have lots of groups and variables. I prefer YAML, and that's what we'll use for the rest of this book.

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.