Ansible and Dynamic Inventory

Author: Kirk Byers
Date: 2015-04-20

So you are chugging along using Ansible, but are having problems maintaining your Ansible inventory.

Remember, Ansible uses inventory information about hosts and groups of hosts to connect to and manage client devices. By default, inventory information is stored in /etc/ansible/hosts. This inventory information can also be expanded to include the 'group_vars' and 'host_vars' directories.

An example, Ansible inventory file could look like the following:

[local]
localhost ansible_connection=local

[arista]
pynet-sw1 eapi_port=8243
pynet-sw2 eapi_port=8343
pynet-sw3 eapi_port=8443
pynet-sw4 eapi_port=8543

[arista:vars]
ansible_connection=local
eapi_hostname=10.10.10.227
eapi_username=admin1
eapi_password=password

Here I have two groups '[local]' and '[arista]'. I have various hosts underneath these two groups including localhost and pynet-sw1. Finally, I have a set of arista group variables in the '[arista:vars]' section.

Now managing a large flat file can be unwieldy. If you are frequently adding and removing devices, then keeping the inventory current might be problematic and time consuming. Additionally, you might be duplicating information that is stored in an alternate system.

Ansible has an alternate way of managing inventory called dynamic inventory. Basically, what you can do is pass in an alternate inventory source using the '-i' option:

$ ansible-playbook my_playbook.yml -i ./dyn_inv.py

Here Ansible will use the inventory provided by the dyn_inv.py script (that we will create).

Now what must the dyn_inv.py script do? First, we have to output our data as JSON. Second, we have to support a --list and a --host option.

The --list option must list out all of the groups and the associated hosts and group variables. The --host option must either return an empty dictionary or a dictionary of variables relevant to that host.

So for the inventory file specified above, './dyn_inv.py --list' should produce the following:

{
    'arista': {
        'hosts': ['pynet-sw1', 'pynet-sw2', 'pynet-sw3', 'pynet-sw4'],
        'vars': {
            'ansible_connection': 'local',
            'eapi_hostname': '10.10.10.227',
            'eapi_password': 'password',
            'eapi_username': 'admin1'
        }
    },
    'local': {
        'hosts': ['localhost'],
        'vars': {'ansible_connection': 'local'}
    }
}

Similarly, './dyn_inv.py --host pynet-sw1' should create this:

$ ./dyn_inventory.py --host pynet-sw1
{"eapi_port": 8243}

Now let's mock this up in a Python script, we will parse the arguments using argparse (for information on argparse, see here):

# Argument parsing
parser = argparse.ArgumentParser(description="Ansible dynamic inventory")
parser.add_argument(
    "--list", 
    help="Ansible inventory of all of the groups",
    action="store_true", 
    dest="list_inventory"
)
parser.add_argument(
    "--host", 
    help="Ansible inventory of a particular host", action="store",
    dest="ansible_host", 
    type=str
)

cli_args = parser.parse_args()
list_inventory = cli_args.list_inventory
ansible_host = cli_args.ansible_host

Here, I create an ArgumentParser object and provide a description. I then add two arguments '--list' which is a boolean (action="store_true") and '--host ' which will store the hostname as a string.

I then process these arguments and ultimately obtain two variables--list_inventory and ansible_host.

Here is the entire script at this point:

#!/usr/bin/env python
import argparse

def main():

    # Argument parsing
    parser = argparse.ArgumentParser(
        description="Ansible dynamic inventory"
    )
    parser.add_argument(
        "--list", 
        help="Ansible inventory of all of the groups",
        action="store_true", 
        dest="list_inventory"
    )
    parser.add_argument("--host",
        help="Ansible inventory of a particular host", 
        action="store",
        dest="ansible_host", 
        type=str
    )

    cli_args = parser.parse_args()
    list_inventory = cli_args.list_inventory
    ansible_host = cli_args.ansible_host

    print "list_inventory: {}".format(list_inventory)
    print "ansible_host: {}".format(ansible_host)


if __name__ == "__main__":
    main()

Now let's execute this script and verify the arguments are being parsed correctly.

First, let's just call the script with --help (note, I didn't have to add a --help argument; argparse created this automatically from the arguments, description, and help strings):

$ ./dyn_inv.py --help
usage: dyn_inv.py [-h] [--list] [--host ANSIBLE_HOST]

Ansible dynamic inventory

optional arguments:
  -h, --help           show this help message and exit
  --list               Ansible inventory of all of the groups
  --host ANSIBLE_HOST  Ansible inventory of a particular host

Now let's execute this script with and without the --list option:

$ ./dyn_inv.py --list
list_inventory: True
ansible_host: None

$ ./dyn_inv.py
list_inventory: False
ansible_host: None

Now let's try it using the --host option:

$ ./dyn_inv.py --host
usage: dyn_inv.py [-h] [--list] [--host ANSIBLE_HOST]
dyn_inv.py: error: argument --host: expected one argument

$ ./dyn_inv.py --host pynet-sw1
list_inventory: False
ansible_host: pynet-sw1

Okay, argument parsing is working.

Now we need to expand upon this script and return the JSON structures specified above (for --list and for --host ). Let's simplify the problem and only worry about outputting the proper JSON. In order to do this, we will just manually create the below data structure (later in a subsequent article we will dynamically generate most of this):

ANSIBLE_INV = {
    "arista": {
        "hosts": ["pynet-sw1", "pynet-sw2", "pynet-sw3", "pynet-sw4"],
        "vars": {
            "ansible_connection": "local",
            "eapi_hostname": "10.10.10.227",
            "eapi_username": "admin1",
            "eapi_password": "password",
        }
    },
    'local': {
        'hosts': ['localhost'],
        'vars': {'ansible_connection': 'local'}
    }
}

Let's also add a simple function that will print this as JSON:

import json

def output_list_inventory(json_output):
    print json.dumps(json_output)

I will then add the following into the main() function:

if list_inventory:
    output_list_inventory(ANSIBLE_INV)

Now, when I execute the script I get the following:

$ ./dyn_inv.py --list
{
    'local': {'hosts': ['localhost'], 'vars': {'ansible_connection': 'local'}},
    'arista': {
        'hosts': ['pynet-sw1', 'pynet-sw2', 'pynet-sw3', 'pynet-sw4'],
        'vars': {
            'eapi_password': 'password',
            'eapi_username': 'admin1',
            'eapi_hostname': '10.10.10.227',
            'ansible_connection': 'local'
        }
    }
} 

It is a bit more compressed, but that looks correct.

Now let's add a placeholder data structure for the host variables:

HOST_VARS = {
    "pynet-sw1": {"eapi_port": 8243},
    "pynet-sw2": {"eapi_port": 8343},
    "pynet-sw3": {"eapi_port": 8443},
    "pynet-sw4": {"eapi_port": 8543},
}

And similarly a function that will output this as JSON:

def find_host(search_host, inventory):
    host_attribs = inventory.get(search_host, {})
    print json.dumps(host_attribs)

Finally, we will add a conditional that calls the find_host() function if ansible_host is defined:

if ansible_host:
    find_host(ansible_host, host_vars)

Our main() function now looks as follows:

def main():

    # Argument parsing
    parser = argparse.ArgumentParser(
        description="Ansible dynamic inventory"
    )
    parser.add_argument(
        "--list", 
        help="Ansible inventory of all of the groups", 
        action="store_true", 
        dest="list_inventory"
    )
    parser.add_argument(
        "--host",
        help="Ansible inventory of a particular host", 
        action="store",
        dest="ansible_host", 
        type=str
    )

    cli_args = parser.parse_args()
    list_inventory = cli_args.list_inventory
    ansible_host = cli_args.ansible_host

    if list_inventory:
        output_list_inventory(ANSIBLE_INV)

    if ansible_host:
        find_host(ansible_host, HOST_VARS)


if __name__ == "__main__":
    main()

Let's try executing this program with various arguments:

$ ./dyn_inv.py --list
{
    'local': {'hosts': ['localhost'], 'vars': {'ansible_connection': 'local'}},
    'arista': {
        'hosts': ['pynet-sw1', 'pynet-sw2', 'pynet-sw3', 'pynet-sw4'],
        'vars': {
            'eapi_password': 'password',
            'eapi_username': 'admin1',
            'eapi_hostname': '10.10.10.227',
            'ansible_connection': 'local'
        }
    }
}

$ ./dyn_inv.py --host localhost
{}

$ ./dyn_inv.py --host pynet-sw1
{"eapi_port": 8243}

$ ./dyn_inv.py --host pynet-sw3
{"eapi_port": 8443}

The script is now outputting the proper data.

Now let's tie this all together and see if it works:

$ ansible-playbook arista_command.yml -i ./dyn_inv.py 

PLAY [Arista Ansible testing] ************************************************* 

GATHERING FACTS *************************************************************** 
ok: [pynet-sw2]
ok: [pynet-sw1]
ok: [pynet-sw3]
ok: [pynet-sw4] 

TASK: [Testing command (not idempotent)] ************************************** 
ok: [pynet-sw1]
ok: [pynet-sw3]
ok: [pynet-sw2]
ok: [pynet-sw4] 

PLAY RECAP ******************************************************************** 
pynet-sw1                  : ok=2    changed=0    unreachable=0    failed=0   
pynet-sw2                  : ok=2    changed=0    unreachable=0    failed=0   
pynet-sw3                  : ok=2    changed=0    unreachable=0    failed=0   
pynet-sw4                  : ok=2    changed=0    unreachable=0    failed=0

This playbook connects to the four Arista switches via Arista's API and executes a 'show version' command. As you can see above it is working properly.

Now what if I try to execute the playbook without the '-i' option:

$ ansible-playbook arista_command.yml
ERROR: Unable to find an inventory file, specify one with -i ?

Note, I have moved the static inventory file so it is no longer available to Ansible.

Reference Material

Code used in this article (slightly cleaned up)

Kirk Byers

@kirkbyers

You might also be interested in: