Skip to content

Python Idempotent

A function is considered idempotent, if it does not result in a change after running one or multiple times. Being able to run a function with knowledge that no change will happen, unless it needs to happen, inherently makes it a safer change. Idempotent functions are generally completed by checking the current state to see if it is in it's desired state and if so, make no action.

Imagine if you run a script that has ten actions, and the script fails for reason outside of your control, such as the resource not being available or the connection failing on step five. If the ten functions are not idempotent, the script would need to run from where it failed.

Let's look at an example:

  • Payload that takes in data
    • Device
    • Port Number
    • VLAN
  • Opens ServiceNow ticket with that information
  • Obtains the device current port configuration
  • Posts current configuration to ServiceNow ticket
  • Runs show command ensure interface is down
  • Posts that output to ServiceNow ticket
  • Updates configuration
  • Obtains current configuration
  • Posts that to ServiceNow ticket
  • Closes Ticket

Now, let's imagine that ticket failed on the step "Updates configuration". This workflow is built such that you would end up with an open ticket and an incomplete job. Re-running this process with the exact same input, would create another ticket, and then potentially fail again.

You could come up with complex logic, to consider multiple entry points, however, if we follow another approach. Highlighted with 🔵 are the idempotent configurations.

Here in this example we can see

  • Payload that takes in data
    • Device
    • Port Number
    • VLAN
  • 🔵 [Key off of the open ticket with same payload] ~Opens~ Ensure ServiceNow ticket with that information is open
  • Obtains the device current port configuration.
  • Posts current configuration to ServiceNow ticket
  • Runs show command ensure interface is down
  • Posts that output to ServiceNow ticket
  • 🔵 [Key off of the same configuration] ~Updates~ Ensures configuration is pushed
  • Obtains current configuration
  • Posts that to ServiceNow ticket
  • Closes Ticket

Hopefully this makes it clear how idempotent functions in the design can make your workflows much cleaner.

Additionally, idempotent commands can often help you to build dry run capabilities. Since the first part is to check the state, you have already done the hard work, and can report back if any action would be taken. This is a common method that Ansible uses.

Impotency in REST

This is often covered within the REST framework between which methods should and should not be idempotent. This screenshot taken from wikipedia can describe which are idempotent or not.

Framework

Idempotent in Action

Let's start with a simply example, here is a function to add a neighbor to a list.

>>> neighbors = ["nyc-rt01"]
>>>
>>> def update_list(neighbors, neighbor):
...     neighbors.append(neighbor)
...     return neighbors
...
>>> neighbors = update_list(neighbors, "nyc-rt02")
>>> neighbors = update_list(neighbors, "nyc-rt02")
>>> print(neighbors)
['nyc-rt01', 'nyc-rt02', 'nyc-rt02']
>>>

This is likely not intended, instead you this idempotent function is likely what you are looking for.

>>> neighbors = ["nyc-rt01"]
>>>
>>> def update_list_idempotently(neighbors, neighbor):
...     if neighbor not in neighbors:
...         neighbors.append(neighbor)
...     return neighbors
...
>>> neighbors = update_list_idempotently(neighbors, "nyc-rt02")
>>> neighbors = update_list_idempotently(neighbors, "nyc-rt02")
>>> print(neighbors)
['nyc-rt01', 'nyc-rt02']

Idempotent in Action - LAB

Given the following vlan structure, build an idempotent function with the provided starting code.

current_vlans = {
    "200": "server",
    "300": "desktop",
    "400": "voice"
}
intended_vlans = {
    "200": "server",
    "350": "desktop",
    "500": "video"
}

def create_vlan_idempotent(current_vlans, intended_vlans):
    pass

pushed_vlans = create_vlan_idempotent(current_vlans, intended_vlans)
print(pushed_vlans)

Info

Hint, multiple vlans can have the same vlan name.

Idempotent Functions using Set Theory

Set theory can help you figure out how to build idempotent functions. Let's say we have a large list of devices in our inventory and we need to compare which devices in our discovery engine that need to be added idempotently.

current_devices = ["nyc-rt01", "nyc-rt02", "nyc-rt03", "sfo-rt01", "sfo-rt02", "sfo-rt03", "lon-rt01", "lon-rt02", "lon-rt03"]
discovered_devices = ["nyc-rt01", "nyc-rt02", "nyc-rt03", "sfo-rt01", "sfo-rt02", "lon-rt01", "lon-rt02", "lon-rt03", "dal-rt01", "dal-rt02", "dal-rt03"]

pushed_devices = set(discovered_devices).difference(set(current_devices))

Info

Notice that the format is wanted_set.difference(current_set).

The resulting values {'dal-rt02', 'dal-rt01', 'dal-rt03'}, noticeably does not include sfo-rt03 which is not in the discovered device discovered_devices. Following idempotent functions, this is to be expected. We will explore this situation further in declarative configurations.

Idempotent Functions using Set Theory - LAB

Given the following vlan structure, build an idempotent function using set theory with the provided starting code.

current_vlans = {
    "200": "server",
    "300": "desktop",
    "400": "voice"
}
intended_vlans = {
    "200": "server",
    "350": "desktop",
    "500": "video"
}

def create_vlan_idempotent(current_vlans, intended_vlans):
    pass

pushed_vlans = create_vlan_idempotent(current_vlans, intended_vlans)
print(pushed_vlans)

Info

Hint: you send dict_items and receive a set

Idempotent CLI Commands

As covered, netutils provides the compliance capability, in that compliant capability netutils provides the ability to understand the missing configurations.

>>> from netutils.config.compliance import compliance
>>>
>>> features = [
...     {
...         "name": "ntp",
...         "ordered": True,
...         "section": [
...             "ntp"
...         ]
...     }
... ]
>>>
>>> backup = "ntp server 192.168.1.1\nntp server 192.168.1.2"
>>>
>>> intended = "ntp server 192.168.1.1\nntp server 192.168.1.3"
>>>
>>> network_os = "cisco_ios"
>>>
>>> compliance(features, backup, intended, network_os, "string")
{'ntp': {'compliant': False,
  'missing': 'ntp server 192.168.1.3',
  'extra': 'ntp server 192.168.1.2',
  'cannot_parse': True,
  'unordered_compliant': False,
  'ordered_compliant': False,
  'actual': 'ntp server 192.168.1.1\nntp server 192.168.1.2',
  'intended': 'ntp server 192.168.1.1\nntp server 192.168.1.3'}
}

Using the configuraiton from the missing key, you can push only the configurations you need.

Idempotent CLI Commands - LAB

Build a function that:

  • Leverages your existing backup solution
  • Is called deploy_bgp
  • Deploys the below bgp_config configuration
  • Deploys via netmiko or napalm load_merge_candidate method

In order to get you started, you are welcome to use the included code:

bgp_config = '''router bgp 65250
 bgp log-neighbor-changes
 no bgp default ipv4-unicast
 neighbor 10.1.12.1 remote-as 65100'''

features = [
    {
        "name": "bgp",
        "ordered": True,
        "section": [
            "router bgp"
        ]
    }
]

network_os = "cisco_ios"

def deploy_bgp(): # Fill in args
    pass

Warning

You only need to run this against a single device. The bgp configuration will be the same.