Juniper Configs, Nornir, and a Dynamic Inventory
Getting started with bulk configuration

Many folks have heard about ansible for configuration management, but nornir is gaining traction among automators for its simplicity — at least for those who are already comfortable in a python environment. You simply import nornir and related libraries at the top of your .py
file like any other python library, and get to work with your usual code–no looking up how to do if
statements and for
loops in yaml.
What’s a Dynamic Inventory?
Most demonstrations and how-to’s on nornir, however, talk about the device inventory as a static yaml file you create, similar to ansible. But for automation, doesn’t it make more sense for your inventory to be the Source of Truth (SOT), particularly if you’re already maintaining one? Then, when you update data models in the SOT, your nornir or ansible inventory is automatically (what?) updated. And Gilbert Moisio provided us with a working example in his 2021 blog post on the subject – the only problem is trying to remember that high school French! And also learn a few new tech terms.
Soon after following along with Gilbert’s nornir example (he also provides an ansible example), I discovered the nornir_pyez library by DataKnox. It integrates the pyez library from Juniper with nornir for ease of automating specific tasks for junos devices, although most examples in the documentation (including this intro video) use a manually-created static inventory.
Armed with both of these tools, I figured it should be possible to do something like change the syslog server on many different devices at once, while setting the appropriate source interface for each one to its primary_ip4 (the management address, according to nautobot).
Because it’s a sort of in-between config remediation (only a snippet of config at a time rather than the all or nothing of Golden Config), this is a little different from the model espoused in my Four Stages. But if it works in the meantime and leverages an SOT that’s actually being used while saving somebody some time or tedium, I figure it’s worth it! And maybe it fits in somewhere between Stages 0 and 1, if I’m still sticking to that model of automation–and I’m not sure that I am.
Initializing Nornir with Nautobot
Seen below in main(), I first got my nautobot (version 1.5.9) set up as the Nornir inventory.
Side note: be careful with the number of workers–more is not necessarily better. In one case with Nornir when I had too many workers, it actually caused issues and reducing it to 20 fixed them.
The next thing I did (even though it comes first in the code below) was define a nornir task that would show me which variables I could access for each host. In this task, which I called host_output
, I referenced the pynautobot_dictionary
endpoint and dumped it as an indented json.
from os import getenv
from nornir import InitNornir
from nornir.core.task import Task, Result
from nornir_utils.plugins.functions import print_result
'''
Shows host inventory as dictionary, for development purposes.
'''
def host_output(task: Task) -> Result:
return Result(host=task.host, result=task.host["pynautobot_dictionary"])
def main():
nr = InitNornir(
runner={
"plugin": "threaded",
"options": {
"num_workers": 20,
},
},
inventory={
"plugin": "NautobotInventory",
"options": {
"nautobot_url": "https://nautobot.mycompany.net",
"nautobot_token": getenv("NB_API_TOKEN"),
"filter_parameters": {"site": "NYC"},
"ssl_verify": True,
},
},
)
# Run nornir task
result = nr.run(task=host_output)
print_result(result)
if __name__ == "__main__":
main()
Note also above that, for testing purposes, I’ve set filter_parameters to limit the output to just those devices in the New York City (“NYC”) site, so I don’t get a ton of output at this point in development. For more info on filtering, see Nautobot REST API Filtering (change the version at the bottom right corner of the page if you are not using latest/stable).
Using the nornir_pyez library
Once I had the working inventory and knew the data format for task hosts, I started to think about configuring devices with Jinja2 template snippets. As mentioned above, for Juniper devices, I wanted to use nornir_pyez
, which includes the following tasks, among others:
- pyez_config
- pyez_diff
- pyez_commit
To get started with these, I added the following import line:
from nornir_pyez.plugins.tasks import pyez_config, pyez_diff, pyez_commit
Just below the InitNornir section, I also included a couple of lines to get device credentials from environment variables (this is also possible with environ.get if the environ
library is imported instead of os
):
#override default user/pw from nautobot/inventory if it exists
nr.inventory.defaults.username = getenv("MY_USERNAME")
nr.inventory.defaults.password = getenv("MY_PASSWORD")
Jinja Templating
Hidden away in one of the nornir_pyez
docs, which assumed a static, manually-generated yaml inventory for variables, I found a mention of templates with one tiny example. The pynautobot_dictionary
format, however, allowed for the use of the dynamic inventory and I was hoping it was possible to reference the entire DCIM endpoint this way. After some experimenting, I got rid of the host_output function and had some success creating a task function sort of like this:
def template_config(task):
#Assign pynautobot dictionary to template variable data
nb_data = task.host.data["pynautobot_dictionary"]
send_result = task.run(
task=pyez_config, template_path='syslog-change.j2', template_vars=nb_data, data_format='set')
if send_result:
diff_result = task.run(task=pyez_diff)
if diff_result:
task.run(task=pyez_commit)
The above function can be either before main()–like the host_output function was–or within it. Depending on the script, I’ve done both; in this case I did so within main.
Warning: The above code as written will commit the changes to your Juniper devices if successful–use at your own risk. Before moving on to testing in production (assuming lab devices first), it is strongly recommended to comment out the pyez_commit task line and, if necessary, replace with a print of diff_result until you’re confident in your changes.
So finally, and still within main, I also called the new template_config
function as a replacement of the host_output call and printed the response:
response = nr.run(task=template_config)
print_result(response)
The template file syslog-change.j2
specified above looks like this:

Notice that it is using config_context
and a made-up variable called syslog_servers
within it–one of the great things about nautobot is that it provides features such as configuration contexts and relationships to give you the flexibility to model data that doesn’t normally fall within the usual core SOT models such as DCIM and IPAM. In nautobot, you can find config_context in the UI under Extensibility->Config Contexts, and it’s specified using json like this:
{
"syslog_servers": [
{
"host": "192.168.8.84",
"type": "standard"
},
{
"host": "192.168.8.86",
"type": "structured"
},
{
"host": "192.168.8.87",
"type": "structured"
},
]
}
At any rate, it appears that anything that can be accessed in the dcim.devices API endpoint can be used as a template variable when pushing jinja config snippets using nornir_pyez
. I’ll update if I discover any limitations or other details, but for now it looks like a great way to get started with Juniper automation if you already have an SOT.
Update, 1/1/2024: Nautobot, GraphQL and a Dynamic Inventory, a two-part series on extending the dynamic inventory further with GraphQL (and how network engineers can learn python through one-off struggles), is now out!