5 minute read

Putting the Pieces Together, and Taking it Even Further

At the end of Part 1, we saw that it’s possible to embed a query of nautobot’s graphql API into python in order to extend the nornir_nautobot inventory beyond the default dcim.devices endpoint, pynautobot_dictionary. In this particular case, the magic happened in the add_variable(task) function with a dictionary update:

task.host.data.update(gql.json.get('data'))

So, that’s great, and all, but what if you don’t know how to craft that query in the first place? For those who haven’t yet had to set up a SoTAgg query for the the Golden Configuration app, let’s take a quick detour and cover the nautobot GraphQL API explorer (remember, you can use the TOC at right to skip ahead if you’re already well-versed in this).

Writing a Usable Query with GraphiQL

To get to the GraphiQL explorer, click the handy little GraphQL link at the bottom right of the nautobot UI. This opens up a 3-frame page, where on the left you can start creating a graphql query like this, after the “Welcome” comments:

You can see previous queries by clicking on the Queries drop-down, and if you start typing on a new line, it’ll suggest variable names available at that level, starting with the typed letter(s). Hit the play button to execute after you get it how you want it. For per-device queries that start with query ($device_id: ID!) like in the image, you’ll need to supply a “device_id” in the bottom left QUERY VARIABLES frame, so copy/paste one from a valid device in your nautobot instance:

The demo instance has some good example queries, particularly “GoldenConfig SoTAgg Query”, but if you want to start from scratch you can use the Docs button at the top right to navigate through the entire schema:

For more details on using GraphQL in nautobot, see the feature guide.

Using Updated Host Data with Jinja Templates

The next thing to do is point to this updated task.host data for our Jinja template variables. To figure out what the resulting structure is, I went back to the old host_data task function:

def host_data(task: Task) -> Result:
    return Result(host=task.host, result=task.host.data)

and ran it after the add_variable task:

hostdata = nr.run(task=host_data)
print_result(hostdata)

It turns out that pynautobot_dictionary is still there, and this new data is in a dictionary at the same level called device, so I modified my trusty template_config function from the original nornir_pyez post with an nb_data reference to the new dictionary:

def template_config(task):
    #Assign new graphql dictionary to template variable data
    nb_data = task.host.data["device"]
    send_result = task.run(
        task=pyez_config, 
        template_path='intf-config.j2', 
        template_vars=nb_data, 
        data_format='set')
    if send_result:
        diff_result = task.run(task=pyez_diff)
    if diff_result:
        print(diff_result)
        #task.run(task=pyez_commit)

And replaced the hostdata run and printout with template_config. Note that the last line in the function is commented, so that while figuring this stuff out, I’m not actually committing configs on the router, lab or otherwise. Lots of troubleshooting was required to get the templates correct, and in the meantime we can see what rendered config is being sent to the Juniper by logging in and executing show log interactive-commands | match labuser, or whatever $MY_USERNAME is set to.

What the team noticed is that the above was creating all interfaces on the router (which is what Golden Config intended is for), and wanted to know if we could limit the query to just what we need for one new customer, such as a single interface.

Further GraphQL Customization

To do this, we further experimented in GraphiQL until we narrowed it down to the data we wanted, then created variables to specify a single router and interface:

current_router = "er1.nyc"
current_interface = "ae1.1001"

The router we supplied in the InitNornir section as a filter parameter under options:

nr = InitNornir(
    runner={"options": {"num_workers": 20}},
    inventory={
        "plugin": "NautobotInventory",
        "options": {
            "nautobot_url": "https://nautobot.mycompany.net",
            "nautobot_token": getenv("NB_API_TOKEN"),
            "filter_parameters": {"name": current_router},
            "ssl_verify": True,
            },
        },
    )

And the interface we supplied in the query variables (the add_variable function from Part 1, try clause):

gql = nb.graphql.query(
         query=intf_query, 
         variables={
            "device_id": task.host["pynautobot_dictionary"]["id"],
            "name_str": current_interface
            }
         )

And modified the query to remove the device level, and take in two variables instead of one (at the interfaces level, name refers to interface name):

intf_query = '''
query ($device_id: [String], $name_str: [String]) {
  interfaces(device_id: $device_id, name: $name_str) {
    description
    device {
      name
      site {
        name
      }
    }
    enabled
    name
    ip_addresses {
      address
      family
    }
    tagged_vlans {
      vid
      tenant {
        name
      }
    }
    untagged_vlan {
      vid
      tenant {
        name
      }
    }
  }
}
'''

Verifying JSON Output

Finally, since the query root is the more-specific interfaces instead of device in general, we need to verify the JSON output format for dictionary referencing. The first section of output from the host_data task result was:

{ 'interfaces': [ { 'description': 'Test Customer Interface',
                    'device': { 'name': 'er1.nyc',
                                'site': {'name': 'NYC'}},
                    'enabled': True,
                    'ip_addresses': [],
                    'name': 'ae1.1001',
                    'tagged_vlans': [ { 'tenant': { 'name': 'Test Customer'},
                                        'vid': 1001}],
                    'untagged_vlan': None}],

As expected, task.host.data has been updated with a dictionary element at the beginning called interfaces, so I replaced “devices” with “interfaces” in my nb_data assignment:

   nb_data = task.host.data["interfaces"]

…but got this error at the end of a long traceback:

ValueError: dictionary update sequence element #0 has length 7; 2 is required

Google pointed me in the right direction, which had to do with improper formatting of a dictionary. At first this didn’t really make sense to me, but upon closer inspection of the host_data JSON, I realized interfaces is actually a list. It should only ever have one element in it (and, I realize, best practice would involve try/except with robust error handling, but this is so far just a POC with test data) and I just wanted to get it working, so I referenced the first element in the list instead:

   nb_data = task.host.data["interfaces"][0]

And now we have success with the template! Since the root level is now the first element of the interfaces list, we can refer directly to its dictionary keys in the template:

Once all the checks and error handling clauses are added to make this more robust, along with argument parsing to make it easier to use, we should have a pretty targeted provisioning script for use whenever new customer interfaces are added to nautobot. Again, this is just for interim/legacy devices, because nautobot 1.6+ (and eventually 2.0+) with Golden Config remediation is the end goal.