Working with Ansible Inventory and Variables

Working with Ansible Inventory and Variables
In: Ansible-Course
Table of Contents

In the previous chapter, we created inventory groups and moved the connection settings into the cisco group, so we only had to define them once. That works fine for now, but as your network grows, the inventory file can quickly get long and hard to manage, especially once you start adding more variables for each group and device.

We also have the VLANs hardcoded in the playbook. Right now, every switch gets the same four VLANs. But in a real network, you'll probably want different VLANs on the core switches and the access switches, and maybe even a specific VLAN on just one switch. Creating a separate playbook for each case isn't a good idea.

In this chapter, we'll look at how to organise the inventory and variables properly using group_vars and host_vars. We'll move the VLANs out of the playbook and into variables, so the same playbook can create different VLANs on different switches. We'll also look at how Ansible decides which variable to use when the same variable is defined in more than one place, and a few useful ways to target devices when running a playbook.

Moving Variables Out of the Inventory

At the moment, our connection settings live inside the inventory file under the cisco group. That works, but as we start adding more variables, the inventory file will get longer and harder to read. A cleaner approach is to keep the inventory for devices and groups only, and move the variables into their own files.

Ansible makes this easy with a directory called group_vars. Inside this directory, you create a YAML file named after a group, and Ansible automatically applies the variables in that file to every device in that group. You don't need to reference the file anywhere. As long as the file name matches the group name, Ansible picks it up.

Let's create a group_vars directory inside the project directory and add a file called cisco.yml. Here's what the project looks like now.

ansible_v1/
├── ansible.cfg
├── inventory.yml
├── group_vars/
│   └── cisco.yml
└── create_vlan.yml

Next, move the connection settings from the inventory into group_vars/cisco.yml.

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

Notice that there's no vars key here. Since the file itself is already tied to the cisco group, we just list the variables directly. With the variables moved out, the inventory now only contains devices and groups.

---
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

The group_vars directory needs to sit in the same directory as your inventory file, which in our case is the project directory.

Checking the Variables for a Device

Now that the variables live in a different file, how do we know Ansible is actually picking them up? This is where the ansible-inventory command comes in handy again. If you use the --host option followed by a device name, Ansible shows you all the variables that apply to that device.

ansible-inventory --host core1
{
    "ansible_connection": "ansible.netcommon.network_cli",
    "ansible_host": "192.168.202.101",
    "ansible_network_os": "cisco.ios.ios",
    "ansible_password": "admin",
    "ansible_user": "admin"
}

Here, we can see ansible_host from the inventory, along with the four connection settings from group_vars/cisco.yml. This is the final list of variables Ansible uses when it connects to core1. I use this command all the time, especially once variables start coming from multiple places, since it shows you exactly what Ansible sees for each device. If we run the playbook again, everything works just like before.

Using Variables in a Playbook

So far, the VLANs have been hardcoded inside the task. If we wanted to add or remove a VLAN, we'd have to edit the task itself. A better approach is to keep the data separate from the task.

The simplest place to define a variable is inside the playbook itself, using the vars keyword.

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

  vars:
    vlans:
      - vlan_id: 10
        name: users
      - vlan_id: 20
        name: voice
	  - vlan_id: 30
        name: printers

  tasks:
    - name: Create VLANs
      cisco.ios.ios_vlans:
        config: "{{ vlans }}"

Under vars, we've created a variable called vlans. Its value is the same list of VLANs we had in the task before. You can name the variable anything you like, as long as it doesn't clash with any of Ansible's own variable names.

In the task, instead of listing the VLANs directly, we now have config: "{{ vlans }}". The double curly brackets tell Ansible to replace this with the value of the vlans variable. So when the playbook runs, Ansible takes the list from vlans and passes it to the config option, exactly as if we had written it there ourselves.

You'll notice that {{ vlans }} is wrapped in double quotes. This is required whenever a value starts with curly brackets. Without the quotes, YAML gets confused and thinks you're trying to create a dictionary, and you'll get an error.

The double curly brackets come from something called Jinja2, which Ansible uses for variables and templates. We'll cover Jinja2 in its own chapter later, so for now, just remember that {{ variable_name }} is how you use a variable.

If you re-run the playbook, the result is exactly the same as before. The difference is that the task no longer cares which VLANs it's creating. It simply takes whatever is in the vlans variable.

Moving Variables to group_vars

Defining the VLANs in the playbook works, but the data is still tied to the playbook. Since we already have a group_vars file for our Cisco devices, let's move the vlans variable there instead.

First, remove the vars section from the playbook.

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

  tasks:
    - name: Create VLANs
      cisco.ios.ios_vlans:
        config: "{{ vlans }}"

Next, add the vlans variable to group_vars/cisco.yml, below the connection settings.

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

vlans:
  - vlan_id: 10
    name: users
  - vlan_id: 20
    name: voice
  - vlan_id: 30
    name: printers

If you run the playbook again, the result is the same as before. The task still uses {{ vlans }}, but this time, Ansible finds the variable in group_vars/cisco.yml instead of the playbook.

If we run ansible-inventory --host core1 again, we can now see vlans listed alongside the connection settings. Since the variable is defined in group_vars, it's part of the inventory, and every device in the cisco group gets it.

ansible-inventory --host core1
{
    "ansible_connection": "ansible.netcommon.network_cli",
    "ansible_host": "192.168.202.101",
    "ansible_network_os": "cisco.ios.ios",
    "ansible_password": "admin",
    "ansible_user": "admin",
    "vlans": [
        {
            "name": "users",
            "vlan_id": 10
        },
        {
            "name": "voice",
            "vlan_id": 20
        },
        {
            "name": "printers",
            "vlan_id": 30
        }
    ]
}

The playbook now has no data in it at all. It only describes what to do, create VLANs, while the inventory and group_vars describe which devices to configure and which VLANs they should have. If you need to add a new VLAN, you only update the variables, and you never have to touch the playbook. It also means you can reuse the same playbook in a completely different network, just by giving it a different inventory.

Group Variables

At the moment, every switch gets exactly the same VLANs, because the vlans variable is defined in group_vars/cisco.yml, and all five switches are in the cisco group. But in a real network, the core and access switches may not have the same VLANs. For example, the core switches might need a VLAN for servers, while the access switches don't.

Since we already have core and access groups in our inventory, we can create a group_vars file for each of them. Here's what the project looks like now.

ansible_v1/
├── ansible.cfg
├── inventory.yml
├── group_vars/
│   ├── cisco.yml
│   ├── core.yml
│   └── access.yml
└── create_vlan_vars.yml

First, remove the vlans variable from group_vars/cisco.yml, so it only contains the connection settings.

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

Next, create group_vars/core.yml with the VLANs for the core switches. We're keeping the same three VLANs and adding a new one called servers.

---
vlans:
  - vlan_id: 10
    name: users
  - vlan_id: 20
    name: voice
  - vlan_id: 30
    name: printers
  - vlan_id: 100
    name: servers

Then create group_vars/access.yml with the VLANs for the access switches.

---
vlans:
  - vlan_id: 10
    name: users
  - vlan_id: 20
    name: voice
  - vlan_id: 30
    name: printers

Both files define a variable with the same name, vlans, but with different values. Each device gets the value from the group it belongs to. So core1 and core2 get the list from core.yml, and the access switches get the list from access.yml. The connection settings still come from cisco.yml, since both groups are inside the cisco group.

We can confirm this using ansible-inventory.

ansible-inventory --host core1
{
    "ansible_connection": "ansible.netcommon.network_cli",
    "ansible_host": "192.168.202.101",
    "ansible_network_os": "cisco.ios.ios",
    "ansible_password": "admin",
    "ansible_user": "admin",
    "vlans": [
        {
            "name": "users",
            "vlan_id": 10
        },
        {
            "name": "voice",
            "vlan_id": 20
        },
        {
            "name": "printers",
            "vlan_id": 30
        },
        {
            "name": "servers",
            "vlan_id": 100
        }
    ]
}
ansible-inventory --host access1
{
    "ansible_connection": "ansible.netcommon.network_cli",
    "ansible_host": "192.168.202.103",
    "ansible_network_os": "cisco.ios.ios",
    "ansible_password": "admin",
    "ansible_user": "admin",
    "vlans": [
        {
            "name": "users",
            "vlan_id": 10
        },
        {
            "name": "voice",
            "vlan_id": 20
        },
        {
            "name": "printers",
            "vlan_id": 30
        }
    ]
}

As you can see, core1 has VLAN 100 in its list, while access1 doesn't.

The playbook doesn't need to change at all. Let's run it.

ansible-playbook create_vlan_vars.yml
PLAY [Configure VLANs] ********************************

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

PLAY RECAP ********************************************
access1  : ok=1  changed=0  unreachable=0  failed=0  skipped=0  rescued=0  ignored=0
access2  : ok=1  changed=0  unreachable=0  failed=0  skipped=0  rescued=0  ignored=0
access3  : ok=1  changed=0  unreachable=0  failed=0  skipped=0  rescued=0  ignored=0
core1    : ok=1  changed=1  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

As you can see, only the core switches show changed. The servers VLAN was the only new VLAN, so Ansible created it on core1 and core2. The access switches already had VLANs 10, 20, and 30, so there was nothing to change on them.

This is where variables really start to make sense. We have one playbook, but each group of switches gets its own VLANs, simply by putting the data in the right group_vars file.

Variable Inheritance and Precedence

So far, each variable has only been defined in one place. But what happens if the same variable is defined in more than one place? For example, let's add a vlans variable to group_vars/cisco.yml with a single dummy VLAN.

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

vlans:
  - vlan_id: 5
    name: dummy

Now, access1 has two vlans variables to choose from. One from cisco.yml, since it's in the cisco group, and one from access.yml, since it's in the access group. Let's see which one Ansible uses.

ansible-inventory --host access1
{
  "ansible_connection": "ansible.netcommon.network_cli",
  "ansible_host": "192.168.202.103",
  "ansible_network_os": "cisco.ios.ios",
  "ansible_password": "admin",
  "ansible_user": "admin",
  "vlans": [
    {
      "name": "users",
      "vlan_id": 10
    },
    {
      "name": "voice",
      "vlan_id": 20
    },
    {
      "name": "printers",
      "vlan_id": 30
    }
  ]
}

As you can see, access1 gets the VLANs from access.yml, and the dummy VLAN from cisco.yml is nowhere to be seen. This is because access is a child group of cisco, and a child group's variables take priority over its parent's. The same applies to the core switches, which get their VLANs from core.yml.

Also notice that Ansible didn't merge the two lists. It didn't add VLAN 5 to the access VLANs. When the same variable is defined in more than one place, Ansible picks one value and ignores the others completely.

The simple rule to remember is that the more specific definition wins. Here's the order, from lowest to highest priority.

  1. all - Variables that apply to every device
  2. Parent group - For example, cisco
  3. Child group - For example, core or access
  4. Host - Variables defined for a specific device

So, if a variable is defined for a single device, it always wins over any group. We'll look at host variables in the next section.

Ansible actually has a much longer list of places where variables can be defined, such as in the playbook, on the command line, and in roles, each with its own priority. You don't need to memorise all of them. For now, this simplified order covers everything we're doing with the inventory.

Before moving on, remove the dummy vlans variable from cisco.yml, so it doesn't confuse things later.

Host Variables

Group variables work well when every device in a group needs the same thing. But sometimes, a single device needs something different. For example, let's say access1 is in a room used for lab equipment, and it needs an extra VLAN called lab that the other access switches don't need.

We could create a new group just for access1, but that would get messy very quickly. Instead, Ansible has a directory called host_vars, which works exactly like group_vars, except the file is named after a device instead of a group.

Let's create a host_vars directory inside the project directory and add a file called access1.yml.

ansible_v1/
├── ansible.cfg
├── inventory.yml
├── group_vars/
│   ├── cisco.yml
│   ├── core.yml
│   └── access.yml
├── host_vars/
│   └── access1.yml
└── create_vlan_vars.yml
---
vlans:
  - vlan_id: 10
    name: users
  - vlan_id: 20
    name: voice
  - vlan_id: 30
    name: printers
  - vlan_id: 50
    name: lab

You might be wondering why we're repeating VLANs 10, 20, and 30 here. Remember, variables don't merge. If this file only had VLAN 50, the vlans variable for access1 would only contain VLAN 50, since the host variable completely replaces the one from access.yml. So we need to include the full list of VLANs that access1 should have.

Let's check the variables for access1.

ansible-inventory --host access1
{
  "ansible_connection": "ansible.netcommon.network_cli",
  "ansible_host": "192.168.202.103",
  "ansible_network_os": "cisco.ios.ios",
  "ansible_password": "admin",
  "ansible_user": "admin",
  "vlans": [
    {
      "name": "users",
      "vlan_id": 10
    },
    {
      "name": "voice",
      "vlan_id": 20
    },
    {
      "name": "printers",
      "vlan_id": 30
    },
    {
      "name": "lab",
      "vlan_id": 50
    }
  ]
}

access1 now has four VLANs, including the lab VLAN. This confirms what we covered in the previous section. Host variables have a higher priority than group variables, so the vlans variable from host_vars/access1.yml wins over the one from group_vars/access.yml. If you run the same command for access2, you'll see it still only has the three VLANs from access.yml.

Let's run the playbook.

ansible-playbook create_vlan_vars.yml
PLAY [Configure VLANs] ********************************

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

PLAY RECAP ********************************************
access1  : ok=1  changed=1  unreachable=0  failed=0  skipped=0  rescued=0  ignored=0
access2  : ok=1  changed=0  unreachable=0  failed=0  skipped=0  rescued=0  ignored=0
access3  : ok=1  changed=0  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

Only access1 shows changed, since VLAN 50 was the only new VLAN. Every other switch already had the VLANs it needed, so there was nothing to change.

host_vars is a good place for anything that's unique to a single device. In fact, you could also move ansible_host out of the inventory and into each device's host_vars file.

---
ansible_host: 192.168.202.103

vlans:
  - vlan_id: 10
    name: users
  - vlan_id: 20
    name: voice
  - vlan_id: 30
    name: printers
  - vlan_id: 50
    name: lab

Some people prefer this approach, so the inventory only lists device names and groups. I usually keep ansible_host in the inventory, since it makes it easy to see each device's IP address at a glance, but both approaches work just fine.

You can also use host_vars for anything else that's unique to a single device. For example, if one switch uses a different username and password, you can define ansible_user and ansible_password in its host_vars file, and it will override the values from group_vars/cisco.yml for that switch only.

Host Patterns

Throughout this chapter, we've used hosts to tell the playbook which devices to run against, such as a single group like core or multiple groups like core,access. These are called host patterns, and there are a few more ways to use them that come in handy.

Checking Which Devices a Playbook Will Run Against

Before we look at the patterns, there's a useful option called --list-hosts. It shows which devices a playbook would run against, without actually connecting to anything. This makes it a safe way to test your patterns.

ansible-playbook create_vlan_vars.yml --list-hosts
playbook: create_vlan_vars.yml

  play #1 (core,access): Configure VLANs  TAGS: []
    pattern: ['core,access']
    hosts (5):
      access2
      core2
      access3
      access1
      core1

Since our playbook uses hosts: core,access, it runs against all five switches.

Excluding Devices

You can exclude a device or a group by adding ! in front of it. For example, to run against all the access switches except access1, you would use the following.

  hosts: "access:!access1"

The colon separates the two patterns, just like the comma we used earlier, and !access1 removes access1 from the list. So this runs against access2 and access3 only. I've wrapped it in double quotes, since ! can have a special meaning in YAML.

playbook: create_vlan_vars.yml

  play #1 (access:!access1): Configure VLANs    TAGS: []
    pattern: ['access:!access1']
    hosts (2):
      access3
      access2

Wildcards

You can also use * as a wildcard to match device names. For example, the following matches every device whose name starts with access.

  hosts: "access*"
playbook: create_vlan_vars.yml

  play #1 (access*): Configure VLANs    TAGS: []
    pattern: ['access*']
    hosts (3):
      access1
      access3
      access2

This is useful when your devices follow a naming convention, but keep in mind that wildcards match names, so make sure they don't pick up more devices than you expect. --list-hosts is a good way to double-check.

Using --limit

So far, every time we wanted to target different devices, we had to change hosts in the playbook. But what if you only want to run the playbook against access1 this one time, without changing the playbook? This is where --limit comes in.

ansible-playbook create_vlan_vars.yml --limit access1

This runs the playbook against access1 only. You can also combine it with --list-hosts to confirm which devices will be targeted before running it for real.

ansible-playbook create_vlan_vars.yml --list-hosts --limit access1
playbook: create_vlan_vars.yml

  play #1 (access*): Configure VLANs    TAGS: []
    pattern: ['access*']
    hosts (1):
      access1

--limit accepts the same patterns as hosts, so you can also use groups, wildcards, and exclusions. If you use a pattern with ! on the command line, wrap it in single quotes, otherwise your shell may try to interpret the ! itself.

ansible-playbook create_vlan_vars.yml --limit 'access:!access1'

Keep in mind that --limit can only narrow down the devices already defined in hosts, it can't add new ones. Since our playbook uses hosts: core,access, --limit access1 works because access1 is already part of the access group. If a device isn't covered by hosts, --limit won't make the playbook run against it.

The all Group

There's one more group_vars file you'll come across a lot, and that's all.yml. Since every device in the inventory is part of the built-in all group, any variables you put in group_vars/all.yml apply to every device.

group_vars/
├── all.yml
├── cisco.yml
├── core.yml
└── access.yml

This is a good place for settings that are the same across your entire network, regardless of the vendor, such as NTP servers, a syslog server, or a DNS server. Just keep in mind that all has the lowest priority, so any group or host that defines the same variable will override it.

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.