Getting Started with Python Network Libraries for Network Engineers – Part 3

Blog Detail

This blog post is the third in a series covering common Python libraries that can be used to interact with network devices. In this post we will cover the Scrapli Python library by Carl Montanari. Per its documentation, Scrapli is the words “scrape” and “cli” (i.e. screen scrape) squished together. Its goal is to provide a thoroughly tested, well typed, well documented, simple API that supports both synchronous and asynchronous interaction with network devices.

Differentiation from Other Libraries

Scrapli is different from other libraries in the following ways:

  1. Scrapli provides multiple forms of transport. It defaults to using a system’s local SSH binary. This is in contrast to Netmiko, which uses Paramiko for transport. The forms of transport it supports can be found here along with a justification for the decision to allow for multiple transports and default to a system’s local SSH binary.
  2. Scrapli supports the same platforms as NAPALM out of the box. This is a subset of the platforms supported by Netmiko. The scrapli community project provides the ability for the scrapli community to contribute to and use drivers beyond those included in the base project. The platforms supported out of the box are:
    • Cisco IOS-XE
    • Cisco NX-OS
    • Juniper JunOS
    • Cisco IOS-XR
    • Arista EOS
  3. If you’re looking to write unit/integration tests for your code, scrapli has an adjacent library called scrapli-replay that can be used to do so.

Installation

You can install Scrapli via pip install scrapli. Or, if you are using Poetry, you can use poetry add scrapli. Other methods for installing scrapli are documented here.

Getting Connected

Note: We will only cover connecting via SSH in this blog post.

To start, you’ll want to import and instantiate one of the scrapli “core” drivers from scrapli.driver.core.

Scrapli requires a hostname (IP or DNS name) and an authentication method (generally username and password) to get connected. In addition to this, you will also want to specify whether or not to use SSH strict host key checking (we pass in False below).

>>> from scrapli.driver.core import IOSXEDriver
>>>
>>> conn = IOSXEDriver(
...     host="192.0.2.3",
...     auth_username="cisco",
...     auth_password="cisco",
...     auth_strict_key=False,
... )
>>> type(conn)
scrapli.driver.core.cisco_iosxe.sync_driver.IOSXEDriver
>>> conn.open()

Note: If host strict key checking is enabled (the default), an SSH session will be permitted only to hosts that have a key defined inside of your system’s “known_hosts” file.

You can also use the Scrapli class to dynamically select and instantiate a driver (much like ConnectHandler in Netmiko) in the following way:

>>> from scrapli import Scrapli
>>>
>>> conn = Scrapli(
...     host="192.0.2.3",
...     auth_username="cisco",
...     auth_password="cisco",
...     auth_strict_key=False,
...     platform="cisco_iosxe",
...  )
>>> type(conn)
scrapli.driver.core.cisco_iosxe.sync_driver.IOSXEDriver
>>> conn.open()

A list of the basic driver arguments can be found here.

Sending Commands

Once you have instantiated your Driver object, you can send single show commands via the .send_command() method. You will use the same command syntax you would type in if you were directly connected to the device via SSH:

>>> response = conn.send_command("show ip interface brief")
>>> print(response.result)
Interface              IP-Address      OK? Method Status                Protocol
FastEthernet0          unassigned      YES NVRAM  down                  down
GigabitEthernet1/0/1   192.0.2.3       YES unset  up                    up
GigabitEthernet1/0/2   unassigned      YES unset  up                    up
GigabitEthernet1/0/3   unassigned      YES unset  up                    up
>>> print(response.failed)
False

Likewise, you can send multiple commands using the .send_commands() method.

>>> response = conn.send_commands(["show ip interface brief", "show running-config | include hostname"])
>>> print(response.result[0])
Interface              IP-Address      OK? Method Status                Protocol
FastEthernet0          unassigned      YES NVRAM  down                  down
GigabitEthernet1/0/1   192.0.2.3       YES unset  up                    up
GigabitEthernet1/0/2   unassigned      YES unset  up                    up
GigabitEthernet1/0/3   unassigned      YES unset  up                    up
>>> print(response.result[1])
hostname cisco

You can also send commands from a file using the .send_commands_from_file() method.

# commands.txt
show ip int br
show run | i hostname
response = conn.send_commands_from_file("commands.txt")
>>> print(response.result[0])
Interface              IP-Address      OK? Method Status                Protocol
FastEthernet0          unassigned      YES NVRAM  down                  down
GigabitEthernet1/0/1   192.0.2.3       YES unset  up                    up
GigabitEthernet1/0/2   unassigned      YES unset  up                    up
GigabitEthernet1/0/3   unassigned      YES unset  up                    up
>>> print(response.result[1])
hostname cisco

If you want to run a command to edit the configuration, you would use the .send_configs() method instead. This method takes care of entering configuration mode for you, and it requires the commands be in a list or set:

>>> response = conn.send_configs(["interface Gi1/0/3", "no description"])
>>> print(response.result)
configure terminal
Enter configuration commands, one per line.  End with CNTL/Z.
cisco(config)#interface Gi1/0/3
cisco(config-if)#no description

Note: The send_configs() method doesn’t exit config mode like Netmiko does. It, instead, relies on the send_command() method to acquire the user exec mode prompt.

As is the case with .send_commands(), you can also send configurations from a file using the send_configs_from_file() method.

# config_changes.txt
interface Gi1/0/3
 no description
>>> response = conn.send_configs_from_file("config_changes.txt")
>>> print(response.result)
configure terminal
Enter configuration commands, one per line.  End with CNTL/Z.
cisco(config)#interface Gi1/0/3
cisco(config-if)#no description

Finally, there is a send_config() method that will parse the configuration provided as input and split it into lines, each line being sent to the device. This is useful for copying full configs over to the device.

Command Output Parsing

We’ve covered parsing strategies here on this blog before. Just as with Netmiko, Scrapli supports TextFSMTTP, and Genie. Let’s take a quick look on how to use them with Scrapli.

TextFSM

Scrapli defines TextFSM as an “extra”. As such, the first step to using TextFSM templates with scrapli is to install the TextFSM library. You can use pip or Poetry to do so.

pip install 'scrapli[textfsm]'
poetry add 'scrapli[textfsm]'

Once TextFSM is installed, you can use the .textfsm_parse_output() method on a Scrapli response object to return the output as parsed by the NTC template. This method uses the platform inferred by the driver to set the textfsm-platform. It combines this with the command sent to the device to guess as to which template it should use to parse the returned data.

>>> response = conn.send_command("show interfaces")
>>> print(response.textfsm_parse_output())
[{'abort': '',
  'address': '381c.1ae6.cd81',
  'bandwidth': '100000 Kbit',
  'bia': '381c.1ae6.cd81',
  'crc': '0',
  'delay': '100 usec',
...

You can see the template used for this command here.

Scrapli also supports defining the template that should be used to parse the response manually. You can import and use the textfsm_parse function from scrapli.helper to do so.

>>> from scrapli.helper import textfsm_parse
>>>
>>> response = conn.send_command("show interfaces")
>>> structured_result = textfsm_parse("/path/to/template", response.result)

TTP

Scrapli also supports the TTP parsing library. As with TextFSM, you will need to install it via pip install ttp or poetry add ttp before it can be used. The TTP installation does not currently include any templates, so you will need to find or create your own and provide the path to those templates.

>>> response = conn.send_command("show interfaces")
>>> structured_result = response.ttp_parse_output(template="show_interfaces_template.ttp")
>>> pprint(structured_result)
[[[{'description': 'CAM1',
    'interface': 'GigabitEthernet1/0/1',
    'link_status': 'up',
    'protocol_status': 'up'},
   {'description': 'CAM2',
    'interface': 'GigabitEthernet1/0/2',
    'link_status': 'up',
    'protocol_status': 'up'},
...

Genie

The last parser that Scrapli currently supports is Genie. As with TextFSM and TTP, scrapli does not install Genie nor its required library, pyATS, by default, so you will need to install them separately via pip install 'pyats[library]' or poetry add 'pyats[library]'. Athough Genie parsers exist for non-Cisco platforms, In Scrapli, Genie parsers can be used only for Cisco devices.

>>> response = conn.send_command("show interfaces")
>>> structured_result = response.genie_parse_output()
>>> pprint(structured_result)
{'GigabitEthernet1/0/1': {'arp_timeout': '04:00:00',
                          'arp_type': 'arpa',
                          'bandwidth': 100000,
                          'connected': True,
                          'counters': {'in_broadcast_pkts': 41240,
...

Note: Genie does not support custom templates.

Next Steps

  • Perhaps you’d like to use asyncio for interactions with network devices, or learn more advanced concepts? The documentation is excellent, and a great place to start.
  • If you’d like more examples of common interactions using scrapli, Carl has created a directory with such examples here.
  • Scrapli has an ecosystem including a few adjacent tools that are worth checking out (scrapli_netconf, scrapli_nornir…etc.).

Conclusion

I hope you enjoy Scrapli. It’s an excellent tool and is my personal go-to tool for new projects in which I need to automate interactions with devices for which it has a driver.

-Phillip

New to Python libraries? NTC’s Training Academy is holding a 3-day course Automating Networks with Python I on September 26-28, 2022 with 50% labs to get you up to speed.
Visit our 2022 public course schedule to see our full list.



ntc img
ntc img

Contact Us to Learn More

Share details about yourself & someone from our team will reach out to you ASAP!

Introducing Schema Enforcer

Blog Detail

These days, most organizations heavily leverage YAML and JSON to store and organize all sorts of data. This is done in order to define variables, be provided as input for generating device configurations, define inventory, and for many other use cases. Both YAML and JSON are very popular because both languages are very flexible and are easy to use. It is relatively easy for users who have little to no experience working with structured data (as well as for very experienced programmers) to use JSON and YAML because the formats do not require users to define a schema in order to define data.

As the use of structured data increases, the flexibility provided because these languages don’t require data to adhere to a schema create complexity and risk. If a user accidentally defines the data for ntp_servers in two different structures (e.g. one is a list, and one is a dictionary), automation tooling must be written to handle the differences in inputs in some way. Often times, the automation tooling just bombs out with a cryptic message in such cases. This is because the tool consuming this data rightfully expects to have a contract with it, that the data will adhere to a clearly defined form and thus the tool can interact with the data in a standard way. It is for this reason that APIs, when updated, should never change the format in which they provide data unless there is some way to delineate the new format (e.g. an API version increment). By ensuring data is defined in a standard way, complexity and risk can be mitigated.

With structured data languages like YAML and JSON which do not inherently define a schema (contract) for the data they define, a schema definition language can be used to provide this contract, thereby mitigating complexity and risk. Schema definition languages come with their own added maintenance though as the burden of writing the logic to ensure structured data is schema valid falls on the user. The user doesn’t just need to maintain structured data and schemas, they also have to build and maintain the tooling that checks if data is schema valid. To allow users to simply write schemas and structured data and worry less about writing and maintaining the code that bolts them together, Network to Code has developed a tool called Schema Enforcer. Today we are happy to announce that we are making Schema Enforcer available to the community.

Check it out on Github!

What is Schema Enforcer

Schema Enforcer is a framework for allowing users to define schemas for their structured data and assert that the defined structured data adheres to their schemas. This structured data can (currently) come in the the form of a data file in JSON or YAML format, or an Ansible inventory. The schema definition is defined by using the JSONSchema language in YAML or JSON format.

Why use Schema Enforcer?

If you’re familiar with JSONSchema already, you may be thinking “wait, doesn’t JSONSchema do all of this?”. JSONSchema does allow you to validate that structured data adheres to a schema definition, but it requires for you to write your own code to interact with and manage the data’s adherence to defined schema. Schema Enforcer is meant to provide a wrapper which makes it easy for users to manage structured data without needing to write their own code to check their structured data for adherence to a schema. It provides the following advantages over just using JSONSchema:

  • Provides a framework for mapping data files to the schema definitions against which they should be checked for adherence
  • Provides a framework for validating that Ansible inventory adheres to a schema definition or multiple schema definitions
  • Prints clear log messages indicating each data object examined which is not adherent to schema, and the specific way in which these data objects are not adherent
  • Allows a user to define unit tests asserting that their schema definitions are written correctly (e.g. that non-adherent data fails validation in a specific way, and adherent data passes validation)
  • Exits with an exit code of 1 in the event that data is not adherent to schema. This makes it fit for use in a CI pipeline along-side linters and unit tests

An Example

I’ve created the following directories and files in a repository called new_example.

schema-enforcer-intro2

The directory includes

  • structured data (in YAML format) defining ntp servers for the host chi-beijing-rt01 inside of the file at hostvars/chi-beijing-rt01/ntp.yml
  • a schema definition inside of the file at schema/schemas/ntp.yml

If we examine the file at hostvars/chi-being-rt01/ntp.yml we can see the following data defined in YAML format.

# jsonschema: schemas/ntp
---
ntp_servers:
  - address: 192.2.0.1
  - address: 192.2.0.2

Note the comment # jsonschema: schemas/ntp at the top of the YAML file. This comment is used to declare the schema that the data in this file should be checked for adherence to, as well as the language being used to define the schema (JSONSchema here). Multiple schemas can be declared by comma separating them in the comment. For instance, the comment # jsonschema: schemas/ntp,schemas/syslog would declare that the data should be checked for adherence to two schema, one schema with the ID schemas/ntp and another with the id schemas/syslog. We can validate that this mapping is being inferred correctly by running the command schema-enforcer validate --show-checks

The --show-checks flag shows each data file along with a list of every schema IDs it will be checked for adherence to.

schema-enforcer-intro3

Other mechanisms for mapping data files to schemas against which they should be validated. See docs/mapping_schemas.md in the Schema Enforcer git repository. for more details.
YAML supports the addition of comments using an octothorp. JSON does not support the addition of comments. To this end, only data defined in YAML format can declare the schema to which it should adhere with a comment. Another mechanism for mapping needs to be used if your data is defined in JSON format.

If we examine the file at schema/schemas/ntp.yml we can see the following schema definition. This is written in the JSONSchema language and formatted in YAML.

---
$schema: "http://json-schema.org/draft-07/schema#"
$id: "schemas/ntp"
description: "NTP Configuration schema."
type: "object"
properties:
  ntp_servers:
    type: "array"
    items:
      type: "object"
      properties:
        name:
          type: "string"
        address:
          type: "string"
          format: "ipv4"
        vrf:
          type: "string"
      required:
          - "address"
    uniqueItems: true
additionalProperties: false
required:
  - "ntp_servers"

The schema definition above is used to ensure that:

  • The ntp_servers property is of type hash/dictionary (object in JSONSchema parlance)
  • No top level keys can be defined in the data file besides ntp_servers
  • It’s value is of type array/list
  • Each item in this array must be unique
  • Each element of this array/list is a dictionary with the possible keys nameaddress and vrf
    • Of these keys, address is required, name and vrf can optionally be defined, but it is not necessary to define them.
    • address must be of type “string” and it must be a valid IP address
    • name must be of type “string” if it is defined
    • vrf must be of type “string” if it is defined

Here is an example of the structured data being checked for adherence to the schema definition.

schema-enforcer-intro1

We can see that when schema-enforcer runs, it shows that all files containing structured data are schema valid. Also note that Schema Enforcer exits with a code of 0.

What happens if we modify the data such that the first ntp server defined has a value of the boolean true and add a syslog_servers dictionary/hash type object at the top level of the YAML file.

# jsonschema: schemas/ntp
---
ntp_servers:
  - address: true
  - address: 192.2.0.2
syslog_servers:
  - address: 192.0.5.3
schema-enforcer-intro4

We can see that two errors are flagged. The first informs us that the first element in the array which is the value of the ntp_servers top level key is a boolean and a string was expected. The second informs us that the additional top level property syslog_servers is a property that is additional to (is not specified in) the properties defined by the schema, and that additional properties are not allowed per the schema definition. Note that schema-enforcer exits with a code of 1 indicating a failure. If Schema Enforcer were to be used before structured data is ingested into automation tools as part of a pipeline, the pipeline would never have the automation tools consume the malformed data.

Validating Ansible Inventory

Schema Enforcer supports validating that variables defined in an Ansible inventory adhere to a schema definition (or multiple schema definitions).

To do this, Schema Enforcer first constructs a dictionary containing key/value pairs for each attribute defined in the inventory. It does this by flattening the varibles from the groups the host is a part of. After doing this, schema-enforcer maps which schemas it should use to validate the hosts variables in one of two ways:

  • By using a list of schema ids defined by the schema_enforcer_schema_ids attribute (defined at the host or group level).
  • By automatically mapping a schema’s top level properties to the Ansible host’s keys.

That may have been gibberish on first pass, but the examples in the following sections will hopefully make things clearer.

schema-enforcer-ansible

An Example of Validating Ansible Variables

In the following example, we have an inventory file which defines three groups, nycspine, and leafspine and leaf are children of nyc.

[nyc:children]
spine
leaf

[spine]
spine1
spine2

[leaf]
leaf1
leaf2

The group spine.yaml has two top level keys; dns_servers and interfaces.

cat group_vars/spine.yaml
---
dns_servers:
  - address: true
  - address: "10.2.2.2"
interfaces:
  swp1:
    role: "uplink"
  swp2:
    role: "uplink"

schema_enforcer_schema_ids:
  - "schemas/dns_servers"
  - "schemas/interfaces"

Note the schema_enforcer_schema_ids variable. This declaratively tells Schema Enforcer which schemas to use when running tests to ensure that the Ansible host vars for every host in the spine group are schema valid.

Here is the interfaces schema which is declared above:

bash$ cat schema/schemas/interfaces.yml
---
$schema: "http://json-schema.org/draft-07/schema#"
$id: "schemas/interfaces"
description: "Interfaces configuration schema."
type: "object"
properties:
  interfaces:
    type: "object"
    patternProperties:
      ^swp.*$:
        properties:
          type:
            type: "string"
          description:
            type: "string"
          role:
            type: "string"

Note that the $id property is what is being declared by the schema_enforcer_schema_ids variable.

schema-enforcer-ansible2

When we run the schema-enforcer ansible command with the --show-pass flag, we can see that the spine1 and spine2’s defined dns_servers attribute did not adhere to schema.

By default, schema-enforcer prints a “FAIL” message to stdout for each object in the data file which does not adhere to schema. If no objects fail to adhere to schema definitions, a single line is printed indicating that all data files are schema valid. The --show-pass flag modifies this behavior such that, in addition the the default behavior, a line is printed to stdout for every file that is schema valid indicating it passed the schema adherence check.

In looking at the group_vars/spine.yaml group above. This is because the first dns server in the list which is the value of dns_servers has a value of the boolean true.

bash$ cat schema/schemas/dns.yml
---
$schema: "http://json-schema.org/draft-07/schema#"
$id: "schemas/dns_servers"
description: "DNS Server Configuration schema."
type: "object"
properties:
  dns_servers:
    type: "array"
    items:
      type: "object"
      properties:
        name:
          type: "string"
        address:
          type: "string"
          format: "ipv4"
        vrf:
          type: "string"
        required:
            - "address"
    uniqueItems: true
required:
  - "dns_servers"

In looking at the schema for dns servers, we see that DNS servers address field must be of type string and format ipv4 (e.g. IPv4 address). Because the first element in the list of DNS servers has an address of the boolean true it is not schema valid.

Another Example of Validating Ansible Vars

Similar to the way that schema-enforcer validate --show-checks can be used to show which data files will be checked by which schema definitions, the schema-enforcer ansible --show-checks command can be used to show which Ansible hosts will be checked for adherence to which schema IDs.

schema-enforcer-ansible3

From the execution of the command, we can see that 4 hosts were loaded from inventory. This is just what we expect from our earlier examination of the .ini file which defines Ansible inventory. We just saw how spine1 and spine2 were checked for adherence to both the schemas/dns_servers and schemas/interfaces schema definitions, and how the schema_enforcer_schema_ids var was configured to declare that devices belonging to the spine group should adhere to those schemas. Lets now examine the leaf group a little more closely.

cat ansible/group_vars/leaf.yml
---
dns_servers:
  - address: "10.1.1.1"
  - address: "10.2.2.2"

In the leaf.yml file, no schema_enforcer_schema_ids var is configured. There is also no individual data defined at the host level for leaf1 and leaf2 which belong to the leaf group. This brings up the question, how does schema-enforcer know to check the leaf switches for adherence to the schemas/dns_servers schema definition?

The default behavior of schema-enforcer is to map the top level property in a schema definition to vars associated with each Ansible host that have the same name.

bash$ cat schema/schemas/dns.yml
---
$schema: "http://json-schema.org/draft-07/schema#"
$id: "schemas/dns_servers"
description: "DNS Server Configuration schema."
type: "object"
properties:
  dns_servers:
    type: "array"
    items:
      type: "object"
      properties:
        name:
          type: "string"
        address:
          type: "string"
          format: "ipv4"
        vrf:
          type: "string"
        required:
            - "address"
    uniqueItems: true
required:
  - "dns_servers"

Because the property defined in the schema definition above is dns_servers, the matching Ansible host var dns_servers will be checked for adherence against it.

In fact, if we make the following changes to the leaf group var definition then run schema-enforcer --show-checks, we can see that devices belonging to the leaf group are now slated to be checked for adherence to both the schemas/dns_servers and schemas/interfaces schema definitions.

cat ansible/group_vars/leaf.yml
---
dns_servers:
  - address: "10.1.1.1"
  - address: "10.2.2.2"
interfaces:
  swp01:
    role: uplink
schema-enforcer-ansible3

Using Schema Enforcer

O.K. so you’ve defined schemas for your data, now what? Here are a couple of use cases for Schema Enforcer we’ve found to be “juice worth the squeeze.”

1) Use Schema Enforcer in your CI system to validate defined structured data before merging code. Virtually all git version control systems (GitHub, GitLab…etc) allow the ability to configure tests which must pass before code can be merged from a feature branch into the code base. Schema Enforcer can be turned on along side your other tests (unit tests, linters…etc). If your data is not schema valid, the exact reason why the data is not schema valid will be printed to the output of the CI system when the tool is run and the tool will exit with a code of 1 causing the CI system to register a failure. When the CI system sees a failure, it will not allow the merge of data which is not adherent to schema.

2) Use it in a pipeline. Say you have YAML structured data which defines the configuration for network devices. You can run schema enforcer as part of a pipeline and run it before automation tooling (Ansible, Python…etc) consumes this data in order to render configurations for devices. If the data isn’t schema valid, the pipeline will fail before rendering configurations and pushing them to devices (or exploding with a stack trace that takes you 30 minutes and lots of googling to troubleshoot).

3) Run it after your tooling generates structured data and prints it to a file. In this case, Schema Enforcer can act as a sanity check to ensure that your tooling is dumping correctly structured output.

Where Are We Going Next

We plan to add the support for the following features to Schema Enforcer in the future:

  • Validation of Nornir inventory attributes
  • Business logic validation

Conclusion

Have a use case for Schema Enforcer? Try it out and let us know what you think! Do you want the ability to write schema definitions in YANG or have another cool idea? We are iterating on the tool and we are open to feedback!

-Phillip Simonds



ntc img
ntc img

Contact Us to Learn More

Share details about yourself & someone from our team will reach out to you ASAP!

Ansible Dynamic Inventory Using Plugins

Blog Detail

The inventory is a critical component of every automation framework. Most frameworks support multiple options to provide the inventory, either by using a local file or by integrating with third party systems that will dynamically provide the inventory. Ansible supports multiple inventory formats; in this blog post, we’ll start by taking a low resolution look at static inventory, then take a look at a dynamic inventory. In a future blog post, we’ll jump into how to construct an Ansible inventory plugin in order to use a custom API endpoint as an inventory source.

Inventory Structure

In Ansible, the inventory is primarily composed of hosts, that can be organized in groups with each group or host having associated variables. A host represents the entity to automate, usually a host represents a server, a VM, or a network device, but it could be anything that needs to be automated (a VIP, a rack, SDN system, etc.). By default, the tasks defined in a playbook will be executed per host in the inventory. A host can be part of multiple groups and the groups can be organized with an optional hierarchy. Variables are properties of a given host. For instance, a host CORE-SW01 will have a hostname that describes it, a management IP, SNMP servers which it uses, etc. These properties may be specific to a single host, or they may be shared by a group of hosts.
You can specify the variables associated with a given host in the inventory file or by using a pre-defined directory structure. Ansible searches for host and group variable files by searching the host_vars and group_vars directories when a static inventory source is used. It looks for these directories by searching for them relative to the inventory or playbook file being used.

If the same variable is defined in multiple places (e.g. as an attribute of a host and as an attribute of a group), Ansible will use the following hierarchy to decide which variable to use when it parses inventory sources:

1) Use the variable associated at the host level 2) Use the variable associated at the group level 3) Use the variable associated at the all group level

More detail about how to build your inventory can be found here

Locate the Inventory

By default, Ansible leverages local host file in /etc/ansible/hosts to construct an inventory of devices against which playbooks can be executed. The use of a different inventory file (or files) can be specified in one of several ways; the environment variable ANSIBLE_INVENTORY can be set, the ini key inventory in the defaults section of your Ansible config file can be specified, the default path can be relied on, or the -i parameter can be passed at run time. The order of precedence to resolve which inventory declaration is used if multiple are defined can be found here

Dynamic Inventory

The method for defining inventory discussed up to this point is called static inventory. dynamic inventory can also be defined. Dynamic inventory is useful in an environment where the local inventory files aren’t the source of truth for device inventory. For instance, if you define your hosts in NetBox or in a ServiceNow CMDB, rather than replicating these inventory sources to your local host’s static files, a dynamic inventory allows you to pull inventory directly from NetBox, ServiceNow, or a myriad of other tools at run time (it can also be cached).

Ansible supports two forms of dynamic inventories: inventory plugins and inventory scripts. Inventory plugins are the recommended way of interacting with a dynamic inventory source, so we will discuss only inventory plugins here.

Using NetBox Inventory Plugin

There are multiple inventory plugins available for Ansible, most are disabled by default. To understand how plugins work, we’ll use the NetBox inventory plugin that is currently shipping with Ansible.

To use an existing inventory plugin, we need to generate a configuration file (in YAML format) at the root of the project, that will indicate the name of the plugin that we want to use (plugin: netbox). The configuration file usually contains parameters specific to each plugin that will help to define how to connect to the remote system.

For the NetBox inventory plugin, the mandatory information to provide is the api_endpoint and the token; both can be provided either via the configuration file or via environment variables.

# netbox_inventory.yml
plugin: netbox
validate_certs: False
api_endpoint: http://localhost:8000
token: 0123456abcdef0123456789abcdef01234567

netbox_inventory.yml can now be used as our inventory and can be referenced in ansible configuration file to be used by default.

# ansible.cfg
inventory = netbox_inventory.yml

To be able to automatically determine if the inventory file is a static file or a configuration file for a plugin, Ansible is configured by default with the following list of default inventory plugins:

inventory_plugin_defaults

As you can see, one of the default “accepted” plugins is auto. The auto inventory plugin is used to parse an inventory file which references a plugin using the plugin attribute.

Note: Starting in Ansible 2.4 (circa July 2018), Ansible made inventory plugins available as part of its default installation. Starting in Ansible 2.10 (still in development as of this writing), Ansible is undergoing a significant restructure. Most of the plugins included by default with Ansible, including the NetBox plugin, will be moving to Ansible collections.

Querying NetBox as a dynamic inventory source

I’ve set up a NetBox instance on my local host using netbox-community/netbox-docker, which can be found here. This allows me to build a dockerized instance of NetBox on my local computer for testing purposes. After spinning up the instance of NetBox, I can navigate to http://localhost:8000, log in with the credentials admin/admin, and create some devices for testing with the Ansible dynamic inventory. I’ve created a mock-up WAN aggregation router:

netbox_devices_1

After building the device in NetBox, the following command can be run from the directory where the aforementioned ansible.cfg and netbox_inventory.yml files are located:

$ ansible-inventory --list

This effectively uses the inventory specified in the ansible.cfg file to pull from the local NetBox instance, which is reachable at http://localhost:8000. An example what is return is as follows:

{
    "_meta": {
        "hostvars": {
            "HQ-WAN-RT01": {
                "device_roles": [
                    "wan_aggregation_router"
                ],
                "device_types": [
                    "CISCO3945"
                ],
                "manufacturers": [
                    "Cisco"
                ],
                "platforms": [
                    "cisco_ios"
                ],
                "sites": [
                    "HQ"
                ]
            }
        }
    },
    "all": {
        "children": [
            "ungrouped"
        ]
    },
    "ungrouped": {
        "hosts": [
            "HQ-WAN-RT01"
        ]
    }
}

I’ve gone ahead and added a primary IP address to the HQ-WAN-RT01 router and changed it’s role to just ‘WAN Router’ (within the NetBox UI) as a 3945 is a little bit piddly as a WAN Agg ;). After doing so, I’ve re-run the ansible-inventory --list command.

{
    "_meta": {
        "hostvars": {
            "HQ-WAN-RT01": {
                "ansible_host": "192.0.2.1",
                "device_roles": [
                    "wan_router"
                ],
                "device_types": [
                    "CISCO3945"
                ],
                "manufacturers": [
                    "Cisco"
                ],
                "platforms": [
                    "cisco_ios"
                ],
                "primary_ip4": "192.0.2.1",
                "sites": [
                    "HQ"
                ]
            }
        }
    },
    "all": {
        "children": [
            "ungrouped"
        ]
    },
    "ungrouped": {
        "hosts": [
            "HQ-WAN-RT01"
        ]
    }
}

As you can see, the device now has an ansible_host value of 192.0.2.1 and a primary_ip4 value of the same. Additionally, the device_roles value returned a list containing a single element, wan_router instead of wan_aggregation_router as before.

Grouping devices from NetBox dynamic inventory

You can instruct the Ansible Netbox Inventory Plugin to take devices pulled from NetBox and add them to dynamically constructed groups. I’ve added the following lines to the netbox_inventory.yml file:

group_by:
  - device_roles
  - platforms

I also added a device, HQ-CORE-SW to NetBox and assigned it a role of “core_switch”. After doing so, ansible-inventory --list shows the router in two groups, device_roles_wan_router and platform_cisco_ios. The newly added core switch is also in two groups, device_roles_core_switch and platform_cisco_ios.

{
    "_meta": {
        "hostvars": {
            "HQ-CORE-SW": {
                "device_roles": [
                    "core_switch"
                ],
                "device_types": [
                    "WS-C4500X-32SFP+"
                ],
                "manufacturers": [
                    "Cisco"
                ],
                "platforms": [
                    "cisco_ios"
                ],
                "sites": [
                    "HQ"
                ]
            },
            "HQ-WAN-RT01": {
                "ansible_host": "192.0.2.1",
                "device_roles": [
                    "wan_router"
                ],
                "device_types": [
                    "CISCO3945"
                ],
                "manufacturers": [
                    "Cisco"
                ],
                "platforms": [
                    "cisco_ios"
                ],
                "primary_ip4": "192.0.2.1",
                "sites": [
                    "HQ"
                ]
            }
        }
    },
    "all": {
        "children": [
            "device_roles_core_switch",
            "device_roles_wan_router",
            "platforms_cisco_ios",
            "ungrouped"
        ]
    },
    "device_roles_core_switch": {
        "hosts": [
            "HQ-CORE-SW"
        ]
    },
    "device_roles_wan_router": {
        "hosts": [
            "HQ-WAN-RT01"
        ]
    },
    "platforms_cisco_ios": {
        "hosts": [
            "HQ-CORE-SW",
            "HQ-WAN-RT01"
        ]
    }
}

Having a dynamic inventory with these dynamically constructed groups allows the user to run playbooks against only a single group of devices. For instance, the user could run a playbook against only core_switches or only wan_routers, the user could also run a playbook against all ios_devices. More ways of grouping devices using the NetBox inventory plugin exist. For more information on the possible ways you can render a NetBox inventory plugin file to group hosts, see the documentation

Specifying Variables in Dynamic Inventory

When interacting with static inventory sources, Ansible dynamically squashes host data from groups it belongs to. For instance, if HQ-CORE-SW belongs to the core_switch group, and the core_switch group has a variable defined of snmp_server=192.0.2.5, Ansible will render the inventory such that HQ-CORE-SW has a variable defined of snmp_server=192.0.2.5. This is assuming that HQ-CORE-SW does not have a snmp_server value defined at the host level, which would over ride the group level definition.

Unlike static inventory, when dynamic inventory in use, no such squashing occurs on the host running Ansible. This squashing is done on the device being used as an inventory source instead. Hierarchical understanding of variables can still be implemented, it just needs to be done on the dynamic inventory source Ansible is querying.

Specifying Variables in Netbox

NetBox allows for the definition of variables, which can be associated with hosts. You can add variables to a host by navigating to the device in NetBox, then clicking edit and adding “Local Config Context Data” in JSON format.

local_config_context_data

After doing so, that data will be included when dynamic inventory is rendered from NetBox. That is, when ansible-inventory --list is run after the above data has been entered, Ansible includes the {“foo”: “bar”} key value pair associated with HQ-CORE-SW:

{
    "_meta": {
        "hostvars": {
            "HQ-CORE-SW": {
                "device_roles": [
                    "core_switch"
                ],
                "device_types": [
                    "WS-C4500X-32SFP+"
                ],
                "foo": "bar",     # <--------
                "manufacturers": [
                    "Cisco"
                ],
                "platforms": [
                    "cisco_ios"
                ],
                "sites": [
                    "HQ"
                ]
            },
            "HQ-WAN-RT01": {
                "ansible_host": "192.0.2.1",
                "device_roles": [
                    "wan_router"
                ],
                "device_types": [
                    "CISCO3945"
                ],
                "manufacturers": [
                    "Cisco"
                ],
                "platforms": [
                    "cisco_ios"
                ],
                "primary_ip4": "192.0.2.1",
                "sites": [
                    "HQ"
                ]
            }
        }
    },
    "all": {
        "children": [
            "device_roles_core_switch",
            "device_roles_wan_router",
            "platforms_cisco_ios",
            "ungrouped"
        ]
    },
    "device_roles_core_switch": {
        "hosts": [
            "HQ-CORE-SW"
        ]
    },
    "device_roles_wan_router": {
        "hosts": [
            "HQ-WAN-RT01"
        ]
    },
    "platforms_cisco_ios": {
        "hosts": [
            "HQ-CORE-SW",
            "HQ-WAN-RT01"
        ]
    }
}

Conclusion

This is the first blog post on Ansible dynamic inventory. In a future blog post, we’ll look at how a custom NetBox inventory plugin can be constructed to query a custom dynamic inventory source (e.g. a custom API endpoint, or one for which no plugin yet exists).

-Phillip



ntc img
ntc img

Contact Us to Learn More

Share details about yourself & someone from our team will reach out to you ASAP!