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

Blog Detail

This post is the fourth in a series exploring Python libraries that are commonly used for interacting with network devices. The previous entries in this series covered libraries for directly interacting with network devices. Libraries such as netmikoNAPALM, and Scrapli greatly ease the process of connecting to, and configuring, a network device. This blog will examine Nornir, an automation framework for orchestrating tasks across an entire inventory of devices.

What Is Nornir, and How Does It Compare to Other Automation Frameworks?

Nornir is an automation framework built to address the same use cases as other popular automation frameworks, such as Ansible, Salt, or Puppet. It exists to allow the user to interact with many devices, at-scale, in a programmatic fashion.

  • Nornir’s workflow revolves around the definition of tasks and identifying which hosts in your inventory a task should be executed against.
  • Nornir is not an opinionated framework. It makes no assumptions around what kind of tasks you want to automate, how to connect to a device, configuration data-models, or any of the myriad implementation details that comprise a comprehensive network automation solution. There are many existing libraries to solve these problems, and these libraries can be called by Nornir during task execution. Nornir remains focused on the issue of task management and is meant to be a single pillar in the construction of a personalized system. Nornir does the scaling; what processes you actually run in your environment are for you to define.
  • Nornir has no domain-specific language. It is written, configured, and executed as pure Python code. While a DSL like Ansible’s may be simpler to learn, initially, DSLs can become cumbersome and complex when dealing with complicated logic or when attempting to extend functionality. Without a non-Python abstraction layer you have access to all of its data structures, flow control, and third-party libraries.

If you are familiar with Python web frameworks, you may notice the different philosophies of Ansible and Nornir are similar to the differences between Django and Flask, with very similar pros and cons.

Installation

You can install Nornir via pip:

pip install nornir

Or, if you are using Poetry for dependency management:

poetry add nornir

Nornir is supported by the community in the form of plugins. Plugins provide inventory integrations, predefined tasks, and other tools to simplify development. This demo will be using the nornir-scrapli plugin in order to utilize the popular Scrapli library for connecting to network devices, and Scrapli’s support for Genie to parse device output into structured data.

pip install nornir-scrapli
pip install 'pyats[library]'
poetry add nornir-scrapli
poetry add 'pyats[library]'

Note: This demo will be using the Cisco DevNet Sandbox as target for the example code. Visit Cisco DevNet to sign up and follow along for free.

Configuration and Inventory

Before Nornir can connect to any devices, it must be configured and an inventory must be defined. The “Simple Inventory” plugin is perfect for this demo due to the small size of our test inventory, and it comes built into Nornir. It uses .yaml files to define the host, group, and default configurations. More specific configuration settings override more generic ones, with default configurations being overwritten first.

# /config.yaml

---
inventory:
  plugin: SimpleInventory
  options:
    host_file: "inventory/hosts.yaml"
    group_file: "inventory/groups.yaml"
    defaults_file: "inventory/defaults.yaml"

runner:
  plugin: threaded
  options:
    num_workers: 3

Strict SSH key checking must be disabled since the public sandbox devices of the DevNet lab have unknown SSH keys. This specific bit of config is unique to Scrapli, and more info about its options can be found in the scrapli-nornir documentation:

# /inventory/defaults.yaml

connection_options:
  scrapli:
    extras:
      auth_strict_key: False

Three groups have been configured below, one for each different network OS in the sandbox lab. Each device has a unique set of credentials, although those have been redacted here. The credentials are provided when connecting to the DevNet lab and are subject to change at any time. The naming convention of the groups is arbitrary. You can create any number of groups to allow hosts to inherit any kind of data. Hosts can belong to any number of groups.

The platform key is a configuration option that Nornir uses to pass OS information to transport plugins for driver and parser selection. This can be defined at any point in the inventory configuration or defined programmatically:

# /inventory/groups.yaml

nxos_devices:
  username: #REDACTED
  password: #REDACTED
  platform: cisco_nxos

iosxr_devices:
  username: #REDACTED
  password: #REDACTED
  platform: cisco_iosxr

iosxe_devices:
  username: #REDACTED
  password: #REDACTED
  platform: cisco_iosxe

Please note that it is bad practice to place credentials directly into code. It is one of the most common causes of accidentally exposed secrets.

Finally, the individual hosts must be created in the inventory. The top-level key is an arbitrary name used to uniquely identify a single host, and the hostname key is the network address Nornir will pass to any tasks that require connectivity. The groups key is a list of group names of which each host is a member. The information under the data key is special and can be any arbitrary structured data. It allows for per-host custom fields for any kind of configuration, identification, or contextual data that may be helpful in targeting or orchestration.

# /inventory/hosts.yaml

switch1:
  hostname: sandbox-nxos-1.cisco.com
  groups:
    - nxos_devices
  data:
    site: "cloud"
    role: "butcher"

router1:
  hostname: sandbox-iosxr-1.cisco.com
  groups:
    - iosxr_devices
  data:
    site: "sandbox"
    role: "baker"

router2:
  hostname: sandbox-iosxe-latest-1.cisco.com
  groups:
    - iosxe_devices
  data:
    site: "sandbox"
    role: "butcher"

Initializing Nornir

Let’s create a new Python script to truly get started. The following code instantiates a new Nornir object with a copy of the configured inventory.

# /demo.py

from nornir import InitNornir
nr = InitNornir("config.yaml")

Note: It is possible to programmatically configure Nornir, instead of using externally imported .yaml files, or to combine both methods. See Nornir’s documentation for more details.

# /demo.py

from nornir import InitNornir
nr = InitNornir(
    runner={
        "plugin": "threaded",
        "options": {
            "num_workers": 3,
        },
    },
    inventory={
        "plugin": "SimpleInventory",
        "options": {
            "host_file": "inventory/hosts.yaml",
            "group_file": "inventory/groups.yaml",
            "defaults_file": "inventory/defaults.yaml"
        },
    },
)

Filtering the Inventory

Once the Nornir object is instantiated, you can access the configured inventory data as object properties.

nr.inventory.hosts and nr.inventory.groups are dictionary-like objects that can be iterated over to see all of the hosts or groups in the inventory.

>>> nr.inventory.hosts.keys()
dict_keys(['switch1', 'router1', 'router2'])

>>> nr.inventory.groups.keys()
dict_keys(['nxos_devices', 'iosxr_devices', 'iosxe_devices'])

Iterating over the inventory can be useful, but Nornir implements a system of filters that allow a subset of hosts to be efficiently targeted. The following filter works to target all hosts with the “butcher” role:

>>> nr.filter(role="butcher").inventory.hosts
{'switch1': Host: switch1, 'router2': Host: router2}

This filter identifies all of the hosts with their ‘site’ set to sandbox:

>>> nr.filter(site="sandbox").inventory.hosts.keys()
dict_keys(['router1', 'router2'])

Filters can be combined to narrow down search results:

>>> filter1 = nr.filter(site="sandbox")
..: filter1.filter(role="butcher").inventory.hosts
{'router2': Host: router2}

Filtering in Nornir is a deep subject. Please check the Nornir documentation for more advanced topics and other methods of filtering the Nornir inventory.

Executing Tasks

Now that Nornir is initialized and its inventory can be filtered, tasks can be executed against those hosts. This first example is using a predefined task provided by the Scrapli plugin to retrieve the results of show interface status brief, and having a built-in utility plugin from nornir_utils display that result in an easy-to-read manner.

# /demo.py

from nornir import InitNornir
from nornir_utils.plugins.functions import print_result
from nornir_scrapli.tasks import send_command

nr = InitNornir("config.yaml")

result = nr.run(task=send_command, command="show ip interface brief")
print_result(result)

Running the script will produce the following output, which has been truncated for brevity:

send_command********************************************************************
* router1 ** changed : False ***************************************************
vvvv send_command ** changed : False vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv INFO
Wed Jul 13 18:13:38.817 UTC

Interface                      IP-Address      Status          Protocol Vrf-Name
BVI227                         unassigned      Shutdown        Down     default
BVI511                         10.200.188.33   Down            Down     default
Bundle-Ether10                 unassigned      Down            Down     default
Loopback0                      2.2.2.2         Up              Up       default
^^^^ END send_command ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
* router2 ** changed : False ***************************************************
vvvv send_command ** changed : False vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv INFO
Interface              IP-Address      OK? Method Status                Protocol
GigabitEthernet1       10.10.20.48     YES NVRAM  up                    up
GigabitEthernet2       unassigned      YES NVRAM  administratively down down
Loopback11             unassigned      YES unset  up                    up
Loopback111            10.10.30.1      YES manual up                    up
Tunnel0                10.10.30.1      YES TFTP   up                    up
^^^^ END send_command ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
* switch1 ** changed : False ***************************************************
vvvv send_command ** changed : False vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv INFO
IP Interface Status for VRF "default"(1)
Interface            IP Address      Interface Status
Vlan10               192.168.10.1    protocol-down/link-down/admin-down
Vlan100              172.16.100.1    protocol-down/link-down/admin-down
Lo1                  1.1.1.1         protocol-down/link-down/admin-up
Lo2                  2.2.2.2         protocol-up/link-up/admin-up
Eth1/3               192.168.1.10    protocol-down/link-down/admin-down
Eth1/5               172.16.1.1      protocol-down/link-down/admin-down
^^^^ END send_command ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

If you prepend the run method with one of the filters from earlier, this command can be run against a subset of hosts. The following modification of the demo.py script will target only hosts with the baker role. (Output is, again, truncated for brevity.)

result = nr.filter(role="baker").run(
    task=send_command, command="show ip interface brief"
)
print_result(result)
send_command********************************************************************
* router1 ** changed : False ***************************************************
vvvv send_command ** changed : False vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv INFO
Wed Jul 13 18:44:47.061 UTC

Interface                      IP-Address      Status          Protocol Vrf-Name
BVI227                         unassigned      Shutdown        Down     default
BVI511                         10.200.188.33   Down            Down     default
Bundle-Ether10                 unassigned      Down            Down     default
Loopback0                      2.2.2.2         Up              Up       default
Loopback1                      2.2.2.20        Up              Down     default
tunnel-ip99                    1.1.1.1         Down            Down     default
MgmtEth0/RP0/CPU0/0            10.10.20.175    Up              Up       default
GigabitEthernet0/0/0/0         unassigned      Down            Down     default
GigabitEthernet0/0/0/0.100     10.1.1.1        Down            Down     default
^^^^ END send_command ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

Note: These are publicly accessible sandbox devices. Their configuration and output is subject to constant change.

Defining Custom Tasks

task is a reusable chunk of code that implements logic to be run on a single host. Every task is a function that takes a Task object as its first argument and returns a Result object. Community plugins integrating popular network libraries such as Netmiko and Napalm provide many useful prebuilt tasks, and it is simple to create your own, or to extend existing tasks.

# /demo_2.py

from nornir import InitNornir
from nornir.core.task import Task, Result
from nornir_utils.plugins.functions import print_result

nr = InitNornir("config.yaml")

def hello_world_task(task: Task) -> Result:
    """
    Your first custom Nornir task!
    """
    return Result(host=task.host, result=f"{task.host.name} says hello!")

result = nr.filter(site="sandbox").run(task=hello_world_task)
print_result(result)
hello_world_task****************************************************************
* router1 ** changed : False ***************************************************
vvvv hello_world_task ** changed : False vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv INFO
router1 says hello!
^^^^ END hello_world_task ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
* router2 ** changed : False ***************************************************
vvvv hello_world_task ** changed : False vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv INFO
router2 says hello!
^^^^ END hello_world_task ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

Tasks can call other tasks or manipulate the data in the Nornir object’s inventory. Scrapli can use pyATS Genie to parse device output into a Python dictionary, which can then be associated with the host the data was gathered from. Thanks to the task argument passed to the custom task function, it has access to the inventory data of the targeted hosts.

# /demo_3.py

from pprint import pprint
from nornir import InitNornir
from nornir.core.task import Task, Result
from nornir_scrapli.tasks import send_command

nr = InitNornir("config.yaml")

# This task populates the inventory of the Nornir object with
# the host's "show interface status" data.
def populate_host_interface_status(task: Task) -> None:
    """
    Get the live interface status from a device.
    Transform the response into structured data associated with each host.
    """
    result = task.run(task=send_command, command="show ip interface brief")
    task.host["interface_status"] = result.scrapli_response.genie_parse_output()


nr.run(task=populate_host_interface_status)
router2 = nr.inventory.hosts["router2"]
pprint(router2["interface_status"])

Output:

{'interface': {'GigabitEthernet1': {'interface_is_ok': 'YES',
                                    'ip_address': '10.10.20.48',
                                    'method': 'NVRAM',
                                    'protocol': 'up',
                                    'status': 'up'},
               'GigabitEthernet2': {'interface_is_ok': 'YES',
                                    'ip_address': '10.255.255.1',
                                    'method': 'other',
                                    'protocol': 'up',
                                    'status': 'up'},
               'GigabitEthernet3': {'interface_is_ok': 'YES',
                                    'ip_address': 'unassigned',
                                    'method': 'NVRAM',
                                    'protocol': 'down',
                                    'status': 'administratively down'},
               'Loopback100': {'interface_is_ok': 'YES',
                               'ip_address': '172.16.100.1',
                               'method': 'other',
                               'protocol': 'up',
                               'status': 'up'},
               'Loopback11': {'interface_is_ok': 'YES',
                              'ip_address': 'unassigned',
                              'method': 'unset',
                              'protocol': 'up',
                              'status': 'up'},
               'Loopback111': {'interface_is_ok': 'YES',
                               'ip_address': '10.10.30.1',
                               'method': 'manual',
                               'protocol': 'up',
                               'status': 'up'},
               'Loopback21': {'interface_is_ok': 'YES',
                              'ip_address': 'unassigned',
                              'method': 'unset',
                              'protocol': 'up',
                              'status': 'up'},
               'Tunnel0': {'interface_is_ok': 'YES',
                           'ip_address': '10.10.30.1',
                           'method': 'TFTP',
                           'protocol': 'up',
                           'status': 'up'}}}

Conclusion

Nornir is a powerful framework due to its focus and simplicity. It is an excellent option for teams who do not intend to use the breadth of functionality from a more batteries-included framework, teams with a strong foundation in Python programming, or teams looking to integrate an automation framework into an existing Python ecosystem. Make sure to check out the full Nornir documentation and list of Nornir plugins from the community, and don’t forget Network to Code’s Nornir channel on Slack.

– Boabdil

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!

Nautobot Feature: Secrets and Secrets Groups

Blog Detail

Nautobot 1.2 introduced the functionality of Secrets. Secrets provide the ability for Nautobot to access and use sensitive data. To make the best use of automation, jobs and other features need a reliable way to access secrets without having to be built with a strong opinion on access patterns and varying sources of truth (environment variables, Hashicorp vaults, etc.). This post will explain how Nautobot Secrets make that possible.

Using secrets is exposed through two concepts, the Secret model and Secrets Groups.

The Secret model (simply Secret as we continue) abstracts away the retrieving of a piece of sensitive data from its source of truth into a unique identifier that can be used later.

Secrets Groups allow not only associating Secrets together but hinting at their access patterns and use cases. More on that below.

For now, let’s dive into the basics of a Secret.

Basics of Secrets

Since Nautobot’s implementation of secrets is based on leveraging them instead of providing them, there is no direct way to view the secret data from the GUI or APIs (including GraphQL). The secret data can be accessed programmatically, either by plugins or core features like Git repository syncs.

As explained in our documentation:

This model does not store the secret value itself, but instead defines how Nautobot can retrieve the secret value as and when it is needed. By using this model as an abstraction of the underlying secrets storage implementation, this makes it possible for any Nautobot feature to make use of secret values without needing to know or care where or how the secret is actually stored.

To access the value of a Secret, Nautobot must first know who is ultimately providing the secret (called a Secret Provider). Nautobot provides two built-in Secret Providers: Environment Variable and Text File.

For a secret from the Environment Variable Provider, Nautobot will ask for the variable name to look for to retrieve the secret, for example CUSTOM_ENV_SECRET.

Basics of Secrets

For the Text File Provider, this would be the file path, for example /tmp/my-secret-location.txt.

Basics of Secrets

Since you should not be passing secrets as arguments, any runner or container that would need to access secrets will also need to have access to the same secrets back ends as well. For example, secrets provided by environment variables will need to be set with the same name everywhere; or in the case of file provided secrets, files will need to be in the same path on all runners or containers.

Large installations can quickly see a bloat in the number of Secrets they would need to register, for example if you have unique credentials per device type or site. Thankfully these fields support Jinja templating. The object can be provided as obj variable for context. So if device passwords are accessible via the file system, stored as the site name, a Secret can be created with the file path being: /path/to/secrets/devices/{{ obj.site.slug }}.txt.

Use and access of secrets also can be scoped with permissions. More on that over in our documentation.

Secrets was built with extensibility in mind. A core abstract class called SecretsProvider is available for any plugin to integrate any external provider.

While any plugin can publish its own SecretsProvider subclass, an open-source plugin has already been created called nautobot-secrets-providers that already provides Hashicorp Vault and AWS Secrets Manager back-end integrations.

Now that we know how to tell Nautobot how to retrieve secrets, let’s get to using them.

Basics of Secrets Groups

By design, Secrets are generally not accessed directly. This would require rigid naming conventions and may lead to collisions. Instead, Secrets Groups provide a way to link a Secret to how it should be used (as a username, a token, a password, etc.) as well as the method of use (Console, REST, etc.).

This provides several neat features with this level of abstraction:

  • One relationship between a secret group and a model can provide specification as to how a device can be accessed
  • One secret group can provide sets of credentials for multiple access methods (username and password for console access, token for REST access)
  • No single back end needs to have all secrets for a group (passwords could be provided by Hashicorp Vault, tokens could be provided by environment variables)

In providing these features, adding Secrets to Secrets Groups becomes a unique tuple of access method and secret type linking to a Secret.

I want to access OBJECT (the item the secret group is associated with) over ACCESS_METHOD (REST, Console, etc.), what is the value of SECRET_TYPE (username, password, etc.)?

Basics of Secrets Groups

Currently definable access methods are:

  • Generic
  • gNMI
  • HTTP(S)
  • NETCONF
  • REST
  • RESTCONF
  • SNMP
  • SSH

The constants for which are available from SecretsGroupAccessTypeChoices in nautobot.extras.choices.

Currently definable secret types are:

  • Key
  • Password
  • Secret
  • Token
  • Username

The constants for which are available from SecretsGroupSecretTypeChoices in nautobot.extras.choices.

Accessing Secrets

As stated earlier, secrets are meant to be accessed programmatically by core functions like Git, Jobs, or plugins. In the help documentation it should be defined how the module expects the group to be set up to retrieve the secrets.

For example from the Git Repository configuration, clicking the “?” Help modal button displays this:

Accessing Secrets

(also available on the Nautobot Read the Docs site)

This means when linking a Secrets Group to a Git repository, the group should have at least an association to a Secret with the Access Type being “HTTP(S)” and Secret Type being “Token”, and potentially another association for a Secret Type being “Username” depending on the use case.

Accessing Secrets

Another great example is the Nornir plugin for Nautobot. From the docs:

The default assumes Secrets Group contain secrets with “Access Type” of Generic and expects these secrets to have “Secret Type” of usernamepassword, and optionally secret. The “Access Type” is configurable via the plugin configuration parameter use_config_context, which if enabled changes the plugin functionality to pull the key nautobot_plugin_nornir.secret_access_type from each device’s config_context.

What this means is the plugin can either leverage a simple Secrets Group setup of a Generic Access Type and the relevant Secret Types, or it can dynamically query the Access Type based on information provided by the device itself.

How is this possible? Let’s briefly dive into some of the accessor methods of a Secrets Group.

A Secrets Group can be found by knowing the slug directly:

<span role="button" tabindex="0" data-code="my_device_secrets_group = SecretsGroup.objects.get(slug="device-secrets-groups") #
my_device_secrets_group = SecretsGroup.objects.get(slug="device-secrets-groups")
# <SecretsGroup: Device Secrets Groups>

Or, better yet, by grabbing the associated Secrets Group from the object itself, in this case a Device:

<span role="button" tabindex="0" data-code="my_device = Device.objects.all()[0] # <Device: rtr1.site-b> my_device_secrets_group = my_device.secrets_group #
my_device = Device.objects.all()[0]
# <Device: rtr1.site-b>

my_device_secrets_group = my_device.secrets_group
# <SecretsGroup: Device Secrets Groups>

Now that we have the Secrets Group to access the associated Secret, we use the get_secret_value method, passing in the necessary tuple as keyword arguments for Access Type and Secret Type:

from nautobot.extras.choices import SecretsGroupAccessTypeChoices, SecretsGroupSecretTypeChoices

my_device_secrets_group.get_secret_value(
  access_type=SecretsGroupAccessTypeChoices.TYPE_GENERIC,
  secret_type=SecretsGroupSecretTypeChoices.TYPE_PASSWORD
)

# 'THESECRETVALUE'

Note: While this call may work for non-templated Secrets, you will experience issues once a Secret expects the object to be passed in as obj. Therefor, you should always pass in a contextually relevant object. In this case, that’s the device we assigned to my_device.

from nautobot.extras.choices import SecretsGroupAccessTypeChoices, SecretsGroupSecretTypeChoices

my_device_secrets_group.get_secret_value(
  access_type=SecretsGroupAccessTypeChoices.TYPE_GENERIC,
  secret_type=SecretsGroupSecretTypeChoices.TYPE_PASSWORD,
  obj=my_device
)

# 'THE-rtr1-SECRETVALUE'

When building a plugin, if you support multiple authentication methods or want to permit falling back to different access types, that functionality must be explicitly built in the plugin. Secrets Groups provides no opinion on fallbacks or a “superset/subset” understanding. If you are looking to implement a form of fallback, Secrets Groups will throw a DoesNotExist exception from nautobot.extras.models.secrets.SecretsGroupAssociation, which a plugin can catch to try a different query.


Conclusion

I hope by now you can see the amazing power and flexibility that the Secret and Secrets Group provides for streamlining workflows and automations.

Any questions? Feel free to reach out via Disqus below, GitHub, or the #nautobot Slack Channel on the Network to Code Slack.

-Bryan Culver



ntc img
ntc img

Contact Us to Learn More

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