network-automation-blog

A collection of posts on technologies used in the area of network automation and programmability.

View on GitHub

Using PyInvoke to run shell tasks

As network automation engineers we develop multiple software tools and applications that require building documentation, unit tests, build artifacts (think Python wheels, or docker compose configurations), and more. Being able to update or use such functions repeatedly for every patch or feature update to our applications is required to ensure our applications are tested, shipped with required documemtation updates, etc.

One of the advantages of developing software applications using Python is that there is a library or tool for doing nearly everything in the development process. For example, building unit tests can be done with pytest, documentation can be generated using mkdocs or sphinx – but maybe not everyone in the team knows how to use these tools and use the right commands or flags to do what they want (run tests, run the application as a docker container or kubernetes deployment, …).

This where tools such as make and pyinvoke come in handy as it allows developers to provide “aliases” for such commands or workflows.

About PyInvoke

PyInvoke or Invoke is a Python library that provides a high level Pythonic interface to interact with your host shell. This means that you can use pyinvoke to expose repeatable shell based tasks as Pythonic processes. This is useful as it marries the advantages that come with programming in a high level general purpose programming language such as Python with the interactive nature of your shell program. Pythonic features such as exception handling, cross platform portability, its standard/vendored libraries, and extensibility make pyinvoke an extremely handy tool for Python developers.

Installing invoke

Invoke tasks

Invoke is designed to look for a tasks.py file in a given project directory to understand what commands it can expose for the user to invoke. This file should be programmed with the various tasks (or commands as referenced) the user can run with the invoke program: invoke <task-xyz> where task_xyz is defined with the @task decorator:

from invoke import task  # the wrapper around the high level API to the shell subprocesses.

# decorate your task function as such.
@task
def task_xyz(ctx, a, b):
    """
    Task XYZ
    
    This task can be called `invoke task-xyz -a value_a -b value_b`
    The flags `a` and `b` are string values.
    If other types need to be used, then a default value of the type can be given to the flag arg:

    def task_xyz(ctx, a=5, b=False):
    """

Dashes v/s underscores for invoke tasks

This task() wrapper allows the programmer to provide the following helpful properties for the command task (there are more options but I think these are used more frequently):

An example:


@task
def pre_task():
    print("pre task")

@task
def post_task():
    print("post task")

@task(
    help={
        "http": "Send request as an HTTP request, defaults to `localhost:8080` if this flag is invoked as boolean",
        "log_file": "Log into a file, defaults to `/opt/app/file.log` if flag is invoked as boolean",
    },
    optional=["http", "log"],
    pre=[pre_task],
    post=[post_task],
    iterable=[],
)
def test_api_endpoint(context, http=None, log=None):  # default of None means it is a string
    """
    Task to test an API endpoint
    """
    if http:
        http_url = "http://localhost:8080"
        if isinstance(http, str):
            http_url = http
        logfile = "/opt/app/file.log"
        if log:
            if isinstance(log, str):
                logfile = log
            http_resp: dict = make_http_request(url=http_url, logfile=logfile)
        else:
            http_resp: dict = make_http_request(url=http_url)
    else:
        ...  # Python Ellipsis

The context argument

Invoke tasks require a context argument which is the first positional argument in the task defintion. This context allows for the sharing of “global” data coming from invoke configuration with the commands run via the run() method that is meant to run processes in the shell. This context is helpful in cases such as when the process needs to be executed in a custom shell program, or in case the sudo/root password prompt needs to be handled to complete the shell process.

https://docs.pyinvoke.org/en/stable/getting-started.html#why-context

Running shell commands inside tasks

Now that we understand invoke’s tasks and the context arg for those tasks, we finally come to the crux of invoke, running commands in the target shell. This is where the context provides information as to how to run our shell processes. Inside the task, you can run a shell command as such:

@task
def mytask(ctx):
    ctx.run("echo hello world")

Using invoke to build your own CLI programs

Invoke can also be used to create your own binary executables as their own programs. Check this out for documentation from the invoke project about the same.

Network automation example

To outline how invoke tasks can be helpful in a project, I describe a small exercise of wrapping Batfish questions against network configurations inside invoke tasks that can be used by network administrators to get summaries for their network.

The project tree holds directories for regions with network sites in these regions. The site level directories hold the network configs that Batfish will interpret. Batfish has proven to be a helpful tool for network administrators to gain summaries or answers to certain “questions” about their network configurations – summaries can include whether BGP sessions between two (say, iBGP) peers are compatible or misconfigured (maybe an incorrect peer AS or peer IP address), or summaries of access policies to certain networks based on ACLs configured on the network devices.

Project tree:

.
├── Dockerfile-batfish
├── poetry.lock
├── pyproject.toml
├── regions
│   ├── africa
│   ├── asia
│   │   └── bangalore
│   │       └── configs
│   │           ├── router1.cfg
│   │           ├── switch1.cfg
│   │           └── switch2.cfg
│   ├── australia
│   ├── europe
│   ├── north_america
│   │   └── nyc
│   │       └── configs
│   │           ├── core-rtr01.cfg
│   │           ├── core-rtr02.cfg
│   │           ├── edge-firewall01.cfg
│   │           ├── edge-sw01.cfg
│   │           └── internet-rtr01.cfg
│   └── south_america
└── tasks.py

PyBatfish will be invoked by the invoke tasks developed to be able to interpret the network configurations in their regional directories. The BGP sessions analyzer task look as such:

@task(
    pre=[
        tasks.call(docker_build, dockerfile="Dockerfile-batfish"),
        tasks.call(docker_run, image="batfish/latest", publish_exposed_ports=["8888", "9996", "9997"]),
    ],
    post=[tasks.call(docker_stop),]
)
def bgp_sessions(ctx, region="", site=""):
    '''
    Analyze BGP sessions for a site using Batfish.
    '''
    if not site or not region:
        sys.exit("Site/region not given -- do not know which site to analyze BGP sessions for, exiting!")

    bf = Session(host="localhost", ssl=False, verify_ssl_certs=False)
    bf.set_network(f"{site}")

    SNAPSHOT_DIR = f'./regions/{region}/{site}/'
    tmp_uuid = uuid.uuid1().hex
    bf.init_snapshot(SNAPSHOT_DIR, name=f'snapshot-bgpsessions-{str(datetime.date.today())}-{tmp_uuid}', overwrite=True)

    print("running bgp session status")
    result = bf.q.bgpSessionStatus().answer().frame()
    
    print("BGP sessions analysis:\n")
    for res in result.iloc:
        print(res)
        print("\n")

A run down of what is going on here:

The user can run invoke -l to get a list of tasks they can run in the project.

> invoke -l
Available tasks:

  bgp-sessions   Analyze BGP sessions for a site using Batfish.
  docker-build   Docker build task.
  docker-run
  docker-stop

Notice how invoke picks up the description from the task’s docstring

Once these are sufficiently defined, a user who did not develop the functionalities mentioned above can simply install the invoke tool as mentioned in the beginning of this article, and run

invoke bgp-sessions -r north_america -s nyc

or

invoke bgp-sessions --region north_america --site nyc

To run the invoke task to analyze the BGP sessions configured for the network in site nyc within region north_america in the directory structure. The result will appear as such:

Your snapshot was successfully initialized but Batfish failed to fully recognized some lines in one or more input files. Some unrecognized configuration lines are not uncommon for new networks, and it is often fine to proceed with further analysis. You can help the Batfish developers improve support for your network by running:

    bf.upload_diagnostics(dry_run=False, contact_info='<optional email address>')

to share private, anonymized information. For more information, see the documentation with:

    help(bf.upload_diagnostics)
running bgp session status
BGP sessions analysis:

Node                  internet-rtr01
VRF                          default
Local_AS                       12122
Local_Interface                 None
Local_IP                        None
Remote_AS                      65000
Remote_Node                     None
Remote_Interface                None
Remote_IP               104.34.34.34
Address_Families                  []
Session_Type          EBGP_SINGLEHOP
Established_Status    NOT_COMPATIBLE

The network admin/user who runs this invoke task can now see, without having to go through the configuration files or understand the syntax of such configurations, that there is a session configured on internet-rtr01 in the default VRF with the ASN 12122, peering with remote AS 65000/remote IP 104.34.34.34 as a single hop eBGP session. As Batfish could not find this remote IP in the configurations it analyzed, it flagged the status of such a session as NOT_COMPATIBLE – Batfish has documented what possible statuses it can show and the meaning of such here.

The power of invoke is displayed here – we are able to wrap workflows or tasks that may otherwise be handled manually inside a high level task that can be invoked by a user that just wants to use the software application. It provides a simple user interface for the user that is command line based.

Final notes

Invoke is a multifaceted Python library that can do more than just expose tasks that can be run with the invoke command, it can also allow developers to create application binaries that provide subcommands that can be used in CI or CD workflows. It is a project that has drawn inspiration from make, Ruby’s rake, and its predecessor fabric – to provide a high level API to run commands and processes on host shells (fabric is actually designed to also run on remote shells!).

You can find the code I described above here.

Some helpful places to understand more about invoke: