Writing Your First Nautobot Job, Pt.2

Welcome to Part 2 of our blog series “Writing Your First Nautobot Job.” The goal of this series is to provide Nautobot users with everything they need to start writing Nautobot Jobs from scratch. Now, we assume you have a basic understanding of the Python programming language and you have Nautobot up and running in your environment.

The first entry in this series (Part 1) reviewed fundamental topics, such as the Django ORM and Django data models. Now that we have a good understanding of how we can access and manipulate the data in Nautobot, we can start exploring the mechanisms within Nautobot that allow us to perform those manipulations in a structured and repeatable way.

Introduction

At the time of writing this blog post, Nautobot has just released a new major version 2.0, which comes with some significant changes to Nautobot Jobs. A full explanation of the changes can be found in our Job Migration Guide. As for this blog post, we will be focusing on Jobs compatible with version 2.0.

Historically, when Network Engineers start down their automation journey they usually begin with one of two technologies, which are either Python scripts or Ansible Playbooks. These technologies are great for tasks that you need to execute frequently. However, when it comes time to expose these tools to other people with limited knowledge or experience, the users can easily become overwhelmed by the unfamiliarity of the mediums where these technologies are executed. Nautobot provides us with a framework to safely provide users with access to our automation scripts inside a UI that is intuitive and familiar. This framework is implemented as the Job class inside of Nautobot.

The Job Class

The Job class is defined in the Nautobot source code here. You will see the Job class is empty but inherits all of its functionality from the parent class BaseJob. This is a common development practice that adds a layer of abstraction and minimizes the impact of changes to the BaseJob class on the Jobs you develop.

When we define our Jobs we will always inherit from the Job class and not the BaseJob class. Here is an example of a Job definition.

# jobs.py
from nautobot.apps.jobs import Job

class ExampleJob(Job):
    """This is our example Job definition.""
    ...

In the example above, we first imported the Job class from the Nautobot source code. Then we defined a class called ExampleJob that inherits from Job.

Settings

Nautobot Jobs have several settings that can modify its behavior. These settings can be defined in the jobs meta class definition or the UI.

To override the defaults, we can set each attribute with an appropriate value under a Meta class definition for our Job.

# jobs.py
from nautobot.apps.jobs import Job

class ExampleJob(Job):
    """This is our example Job definition."""

    class Meta:
        name = "Example Job"
        description = "This is the description of my ExampleJob."

The full list of the available settings, their default values, and a general description can be found in the table below.

Class AttributeDefault ValueDescription
name(Name of your Job class)The name of the job as it appears in the UI.
description-A general description of what functions the job performs. This can accept either plain text or Markdown-formatted text.
approval_requiredFalseThis boolean dictates whether or not an approval is required before the job can be executed.
dryrun_defaultFalseThis boolean represents the default state of the Dryrun checkbox when a job is run.
has_sensitive_variablesTrueThis boolean has several implications. The first is that it prevents input parameters from being saved to the database. This protects against inadvertent database exposure to sensitive information such as credentials. This setting also enables/disables the ability to rerun jobs (i.e., refill the job input parameters with a click of a button). This will also prevent the job from being scheduled or being marked as requiring approval.
hiddenFalseThis boolean prevents the job from being displayed by default in the UI and requires users to apply specific filters to the Job list view to be seen.
read_onlyFalseThis boolean is just a flag to indicate that the job does not make any changes to the environment. It is up to the author of the job to ensure the actual behavior of the job is “read only”
soft_time_limit300An integer or float value, in seconds, at which the celery.exceptions.SoftTimeLimitExceeded exception will be raised. Jobs can be written to catch this to clean up anything before the hard cutoff time_limit.
time_limit600An integer or float value, in seconds, at which the task is silently terminated.
task_queues[ ]A list of task queue names that the job is allowed to be routed to. By default only the default queue can be used. The queue listed first will be used for a job run via an API call.
template_name-A path relative to the job source code that contains a Django template which provides additional code to customize the Job’s submission form. Example

User Inputs

Now the next functionality of Nautobot Jobs that we need to consider is input variables. We will often want users to provide input data to set the scope of the Job. User inputs are optional, and it can sometimes be better to have no user inputs when the Job performs specific tasks. When we run a job, the first thing that happens is a user input form will be displayed. The types of user input options that are displayed in this form are controlled by the variable types that we use when defining the attribute of our Job class instance.

All job variables support the following default options:

  • default – The field’s default value
  • description – A brief user-friendly description of the field
  • label – The field name to be displayed in the rendered form
  • required – Indicates whether the field is mandatory (all fields are required by default)
  • widget – The class of form widget to use (see the Django documentation)

The full list of input variable types can be found here. However, I will explain some of the nuances of a few of the variable types below.

ChoiceVar and MultiChoiceVar

This input variable type allows you to define a set of choices from which the user can select one; it is rendered as a dropdown menu.

  • choices – A list of (value, label) tuples representing the available choices. For example:
CHOICES = (
    ('n', 'North'),
    ('s', 'South'),
    ('e', 'East'),
    ('w', 'West')
)

direction = ChoiceVar(choices=CHOICES)

In the example above, we first have to define a set of tuples to represent our choices. Then we pass that set of choices as an input parameter of the ChoiceVar. The user will see a dropdown menu with the list of choices being NorthSouthEast, and West. If a user selects North in the dropdown menu, then the direction variable will equal ‘n’.

Similar to ChoiceVar, the MultiChoiceVar allows for the selection of multiple choices and results in a list of values.

ObjectVar and MultiObjectVar

When your user needs to select a particular object within Nautobot, you will need to use an ObjectVar. Each ObjectVar specifies a particular model and allows the user to select one of the available instances. ObjectVar accepts several arguments, listed below.

  • model – The model class (Device, IPAddress, VLAN, Status, etc.)
  • display_field – The name of the REST API object field to display in the selection list (default: ‘display’)
  • query_params – A dictionary of REST API query parameters to use when retrieving available options (optional)
  • null_option – A label representing a “null” or empty choice (optional)

As you can probably tell, this input type performs an API call to Nautobot itself and constructs the list of options out of the values returned. The display_field argument is useful in cases where using the display API field is not desired for referencing the object. For example, when displaying a list of IP Addresses, you might want to use the dns_name field:

address = ObjectVar(
    model=IPAddress,
    display_field="dns_name",
)

To limit the selections available within the list, additional query parameters can be passed as the query_params dictionary. For example, to show only devices with an “active” status:

device = ObjectVar(
    model=Device,
    query_params={
        'status': 'Active'
    }
)

Multiple values can be specified by assigning a list to the dictionary key. It is also possible to reference the value of other fields in the form by prepending a dollar sign ($) to the variable’s name. The keys you can use in this dictionary are the same ones that are available in the REST API — as an example, it is also possible to filter the Location ObjectVar for its location_type.

location_type = ObjectVar(
    model=LocationType
)
location = ObjectVar(
    model=Location,
    query_params={
        "location_type": "$location_type"
    }
)

Similar to ObjectVar, the MultiObjectVar allows for the selection of multiple objects.

FileVar

An uploaded file. Note that uploaded files are present in memory only for the duration of the job’s execution and they will not be automatically saved for future use. The job is responsible for writing file contents to disk where necessary. This input option is good for when you need to perform bulk operations inside of your job.

IPAddressVar

An IPv4 or IPv6 address, without a mask. Returns a netaddr.IPAddress object. This is rendered as a one-line text box.

10.11.12.13

IPAddressWithMaskVar

An IPv4 or IPv6 address with a mask. Returns a netaddr.IPNetwork object which includes the mask. This is rendered as a one-line text box.

10.11.12.13/24

IPNetworkVar

An IPv4 or IPv6 network with a mask. Returns a netaddr.IPNetwork object. This is rendered as a one-line text box.

Two attributes are available to validate the provided mask:

  • min_prefix_length – Minimum length of the mask
  • max_prefix_length – Maximum length of the mask
10.11.12.0/24

Run()

Now that we have our user inputs defined, we can start defining what actions our job will perform. In our Job class, we define these actions in the run() method. This method takes the self argument and every variable defined on the job as keyword arguments.

# jobs.py
from nautobot.apps.jobs import Job, ObjectVar

class ExampleJob(Job):
    """This is our example Job definition."""

    device = ObjectVar(
        model=Device,
        query_params={
            'status': 'Active'
        }
    )

    class Meta:
        name = "Example Job"
        description = "This is the description of my ExampleJob."
    
    def run(self, device):
        """Do all the things here."""
        ...

In the example above, we can now reference the variable device in our run method and it will contain the object selected by the user when the job is run.

Job Outputs

The main output of Nautobot Jobs, other than the actions the job performs, is the JobResult. The job results page appears once the job starts running. From this page we can watch as the job executes. The status will be set to “running” and if you have log statements in your job they will be displayed as the job runs. Once the job is finished executing, the status will be updated to either “completed” or “failed”. Additional data, such as who ran the job, what the input variables were set to, and how long the job ran for, are also displayed.

Logging

We can log information from inside our jobs using the logger property of the Job class. This returns a logger object from the standard Python logging module (documentation). You can log messages at different logging levels with the different level methods of the logger. So if we wanted to log a warning message to the users, we would simply add the statement logger.warning("My warning message here.") to our job.

An optional grouping and/or object may be provided in log messages by passing them in the log function call’s extra kwarg. If a grouping is not provided, it will default to the function name that logged the message. The object will default to None.

# jobs.py
from nautobot.apps.jobs import Job, ObjectVar

class ExampleJob(Job):
    """This is our example Job definition."""

    device = ObjectVar(
        model=Device,
        query_params={
            'status': 'Active'
        }
    )

    class Meta:
        name = "Example Job"
        description = "This is the description of my ExampleJob."
    
    def run(self, device):
        """Do all the things here."""
        logger.warning("This object is not made by Cisco.", extra={"grouping": "validation", "object": device})
        ...

Status Control

As long as a job is completed without raising any exceptions, the job will be marked as “completed” when it finishes running. However, sometimes we want to mark the job as “failed” if certain conditions aren’t met or the result isn’t what we expected. To do this, we have to raise an Exception within our run() method.

# jobs.py
from nautobot.apps.jobs import Job, ObjectVar

class ExampleJob(Job):
    """This is our example Job definition."""

    device = ObjectVar(
        model=Device,
        query_params={
            'status': 'Active'
        }
    )

    class Meta:
        name = "Example Job"
        description = "This is the description of my ExampleJob."
    
    def run(self, device):
        """Do all the things here."""
        if not device.manufacture.name == "Cisco":
            logger.warning("This object is not made by Cisco.", extra={"grouping": "validation", "object": device})
            raise Exception("A non Cisco device was selected.")

Advanced Features

We have now covered everything you need to write your first Nautobot Job. Most jobs typically perform data manipulation tasks or pull/push data between Nautobot and an external system, but there are some advanced actions that Nautobot Jobs enable us to perform.

  • Job buttons allow us to add a button at the top of a specified object’s ObjectDetailView, which will pass that object as the input parameter for a job and execute it. Read more about Job Buttons here.
  • Job Hooks are jobs that run whenever objects of a specified model are created, updated, or deleted. We define specific tasks to be executed depending on what action was performed against the object. The documentation for Job Hooks can be found here.
  • A job can call another job using the enqueue_job method. We have plans to make this easier in the future.
  • Permissions to run or enable a job can be set per job/user via constraints.
  • Jobs can be approved via API, so an external ticketing system could be configured to read and update approval requests. You can see an example of such an API call here.

Conclusion

In Part 3, we will explore the different ways to load our jobs into Nautobot and the file structure each way requires. Then we will write our first Nautobot Job together.

-Allen



ntc img
ntc img

Contact Us to Learn More

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

Author