Jonny Winter
Network engineer, coffee devotee & IT professional

Performing and Splitting Action Batches with the Meraki API

“Action Batches are a special type of Dashboard API mechanism for submitting batched configuration requests in a single synchronous or asynchronous transaction. Action Batches are ideal for bulk configuration, either in the initial provisioning process, or for rolling out wide-scale configuration changes.” - Action Batch Documentation by Meraki

Summary

Performing an API call to make a change is quick and simple, and performing many requests in loops to the same endpoint or in series to multiple endpoints is also easy. However, when the quantity rises so does the time you need to wait for your script(s) to finish - think about making 144 seperate API calls to fully configure three 48-port switches. Imagine there was a nice, easy way to perform multiple actions within a single call. Oh wait - an ACTION BATCH can! In this post I’m going to discribe the steps required to perform an action batch then how to handle large numbers of actions whilst navigating the restrictions imposed by the Meraki Action Batch API.

Performing and Splitting Action Batches

My Environment

Coffee: Pike Place from Starbucks
Music: An Answer Can Be Found by CKY
OS: Windows 10 Pro v20H2 x64.
IDE: Visual Studio Code v1.61.2
Browser: Google Chrome v95

Tip o’ the Hat

Carl F’s reply on this Stack Overflow post
Meraki’s Action Batch documentation on developer.cisco.com

Let’s Begin

<NOTE>: Instead of re-inventing the wheel and explaining things that have been well defined by someone else, I have included links next to some words/technologies/acronyms/protocols that I feel could proove useful to those not yet ‘in the know’. </NOTE>

All Action Batches (AB) are performed with a POST request to https://api.meraki.com/api/v1/organizations/{organizationId}/actionBatches. GET requests can also be sent to that destination to retrieve the list of all ABs that have been performed. With each POST request to create an AB, you must specify a body in JSON, and for it to confirm to the following format (two example actions exist in the below - one to create a VLAN and one to update a switch port) -

JSON

{
    "confirmed": true,
    "synchronous": false,
    "actions": [
        {
            "resource": "/networks/12345678910/vlans",
            "operation": "create",
            "body": {
                "id": "123",
                "name": "API-VLAN",
                "applianceIp": "172.16.123.1",
                "subnet": "172.16.123.0/24",
            },
        },
        {
            "resource": "/devices/AAAA-BBBB-CCCC/switchPorts/1",
            "operation": "update",
            "body": {
                "type": "access",
                "vlan": "999"
            }
        }
    ]
}

Lets split this into two parts - the parameters and the actions.

Parameters

Actions

Now that we understand what’s going on, a quick & simple, asynchronous, and pre-confirmed AB to create a VLAN and update a switch port via Python using the requests & json modules could look like the below. I’ve added a few comments to a few lines for ease of learning -

<NOTE>: In the below code, the Meraki API key is stored in an environmental variable. This is simple to do and can be set up using my guide from a previous post here. A dummy organisation ID is also used - information on how to get this is ID is outlined in a previous post here.</NOTE>

Python

import requests #---Load the requests module for performing API calls.
import json #---Loca the JSON module to dump the Python dictionaries into well formatted JSON.

headers = {
    'X-Cisco-Meraki-API-Key': os.environ.get('merakiApiKey'), #---API key stored in environmental variable.
    'Content-Type': 'application/json',
    'Accept': 'application/json'
}
organisationId = '12345678910' #---Dummy organisation ID.
url = f"https://api.meraki.com/api/v1/organizations/{organisationId}/actionBatches" #---AB API endpoint.

payload = {
    "confirmed": True, #---To perform the AB immediately.
    "synchronous": False, #---To perform all actions at the same time.
    "actions": [
        {
            "resource": "/networks/12345678910/vlans", #---Endpoint for API calls to create a VLAN for a dummy network.
            "operation": "create", #---This would have been POST, but AB requires create to be specified.
            "body": {
                "id": "123",
                "name": "API-VLAN",
                "applianceIp": "172.16.123.1",
                "subnet": "172.16.123.0/24",
            },
        },
        {
            "resource": "/devices/AAAA-BBBB-CCCC/switchPorts/1", #---Endpoint for API calls to update a switch port for a dummy switch.
            "operation": "update", #---This would have been PUT, but AB requires update to be specified.
            "body": {
                "type": "access",
                "vlan": "999"
            }
        }
    ]
}
response = requests.post(url=url, data=json.dumps(payload), headers=headers) #---Perform the AB and store the response as a variable called response.
print(response.status_code) #---Print out the response status code, it would be 201 if the AB succeded.
print(response.text) #---Print out the response text. Why not. Good to see what was returned.

The terminal print out of the response.text could display something like the following (example taken from Meraki AB documentation) -

JSON

{
    "id": "123",
    "status": "completed",
    "confirmed": true,
    "actions": [
        {
            "resource": "/devices/QXXX-XXXX-XXXX/switchPorts/3",
            "operation": "update",
            "body": {
                "enabled": false
            }
        }
    ]
}

<NOTE>:Using the value from the id key-value-pair from the above response.text, we could also perform a GET request to the following URL - https://api.meraki.com/api/v1/organizations/12345678910/actionBatches/123 - to check the status of the AB. In this example, I am once again using ‘12345678910’ as my dummy organisation ID.</NOTE>

If you’re going to be manually specifying your actions, the above code may be enough for you - copy & paste/append dictionaries to the actions list (again, up to 20 for synchronous or 100 for asynchronous per batch - refered to as 20/100 from now on) and you’re good to go. Get creative, but that’s it. However, what if you’re using a dynamic number of actions which could supass the 20/100 limits? The following code addresses that exact problem.

<NOTE>: From this point forward (until the end of the post), there may not be any explanation behind certain code elements, such as for loops and if statements. Previous posts cover these, but if you’re stuck, some solid ‘googling’ will get you there - I have faith in you! - or reach out to me via the socials.</NOTE>

When working with >20/100 actions, we will need to split these up due to the limitations outlined above. A good method to achieve this is with the following Python function -

Python

def batching(iterable, batchSize=1):
    l = len(iterable)
    for ndx in range(0, l, batchSize):
        yield iterable[ndx:min(ndx + batchSize, l)]

Described in the post referenced in the Tip o’ the Hat section, the batching function takes two arguments -

  1. First position (iterable) - A list. In our case, this will be what we will want to be our actions.
  2. Second position (batchSize) - An integer, which will default to 1 if not specified. In our case, this will be the maximum number of actions in our AB. We could set this to be any number in the range of 1-100 (for example, 42 or 99) and all of which would a valid number of actions to have in an AB. Using the above code, look what happens when we provide a list and a batchSize number -
    colourList = ['red', 'blue', 'white', 'green', 'brown', 'pink', 'orange', 'black']
    for batch in batching(colourList, 3):
     print(batch)
    #---TERMINAL OUTPUT BELOW---#
    ['red', 'blue', 'white']
    ['green', 'brown', 'pink']
    ['orange', 'black']
    

    Awesome, right? A 4-line function that useful is one for the kit bag. However, the way in which we use that to achieve the intended outcome of splitting ABs is still a way off. As with all things code, there can be many ways to get to the same destination, I’m going to outline my way - which isn’t to say it’s the right way, but it works and it handles a few different Meraki rate-limit restrictions at the same time. For the rest of this post, I am going to paste and anotate code to create a number of networks. A few points to note -

  3. The networks are stored in a file called networks.json and stored as a variable called networksJson. It looks like this -

JSON

{
    "networks":[
        {"name":"Jonnys Network 1","productTypes":["appliance","switch","wireless"]},
        {"name":"Jonnys Network 2","productTypes":["appliance","switch","wireless"]},
        {"name":"Jonnys Network 3","productTypes":["appliance","switch","wireless"]},
        {"...etc..."},
        {"name":"Jonnys Network 299","productTypes":["appliance","switch","wireless"]},
        {"name":"Jonnys Network 300","productTypes":["appliance","switch","wireless"]}
    ]
}
  1. Once again storing the Meraki API key in environmental variables. This is simple to do and can be set up using my guide from a previous post here
  2. There are a few hard-coded references to networks, so this would need stripping and generalising if it was to be applied to other ABs and to make sense. This was primarily due to ease of learning/training, but partly due to making the .GIF image look a bit more ‘lively’ (lack of a better word).
  3. I’m also using a dummy organisation ID hard coded at the top of the script.
  4. No more than 5 ABs can exist, see above notes.
  5. I am using a batchSize of 50. This can be seen within the actionBatching function.

Python

import time
import json
import requests
import os
import copy

headers = {
    'X-Cisco-Meraki-API-Key': os.environ.get('merakiApiKey'),
    'Content-Type': 'application/json',
    'Accept': 'application/json'
}
baseUrl = 'https://api.meraki.com/api/v1'
networksJson = json.load(open('networks.json', 'r'))
organisationId = '12345678910' #---Dummy organisation ID.

def batching(iterable, batchSize=1):
    l = len(iterable)
    for ndx in range(0, l, batchSize):
        yield iterable[ndx:min(ndx + batchSize, l)]

def getActionBatches():
    while True:
        try:
            response = requests.get(url=(baseUrl + f'/organizations/{organisationId}/actionBatches'), headers=headers) #---Get all of the ABs.
            if response.status_code == 200:
                responseJson = response.json() #---Convert the response to JSON.
                count = 0 #---Used below.
                for actionBatch in responseJson:
                    if actionBatch['confirmed'] == True and actionBatch['status']['completed'] == False and actionBatch['status']['failed'] == False: #---This catches only confirmed, incomplete, valid ABs. 
                        count += 1 #---Increases the count above.
                        print('Action batch in progress:',actionBatch['id']) #---For troubleshooting/visibility, print out the AB ID.
                    else:
                        pass #---Why not.
                print(count,'incomplete action batches exist') #---For troubleshooting/visibility.
                return count
            elif response.status_code == 429: #---The status code returned when you exceed the maximum number of API calls per second.
                time.sleep(int(response.headers["Retry-After"])) #---Get one of the response headers and sleep the amount of time it's asking you to.
                return getActionBatches(organisationId) #---Re-run the function.
            else:
                return 'An unexpected status code' #---Simple error handling.
        except:
            return 'An unknown error occured.' #---Simple error handling.

def actionBatching(networks):
    networksCopy = copy.deepcopy(networks) #---Create an independant, but direct copy of the networksJson list variable. This is used so we can compare the number of AB actions that need performing against those that have already been submitted, even if we have to restart the function if hit by rate-limitng.
    if len(networksCopy['networks']) == 0:
        return 'No networks to create' #---Simple validation.
    print("There are a total of #" + str(len(networksCopy['networks'])) + " networks left to create")
    while True:
        try:
            for batch in batching(networks['networks'], 50): #---List & batchSize.
                payload = {
                    "confirmed": True,
                    "synchronous": False,
                    "actions": []
                } #---The parameters part of the AB body.
                for network in batch:
                    action = {
                        "resource": f"/organizations/{organisationId}/networks",
                        "operation": "create",
                        "body": network
                    } #---The actions part of the AB body. This is the part that can grow up to 100 per AB.
                    payload['actions'].append(action) #---Add the action to the actions list. 
                    for i in networksCopy['networks']: #---Simple validation
                        if i['name'] == network['name']: #---Simple validation
                            networksCopy['networks'].remove(i) #---Remove the action from the list of actions to be performed so that it isn't performed again, even if the function restarts.
                        else:
                            pass #---Why not?
                print(len(networksCopy['networks']), 'networks left to create')
                if len(payload['actions']) != 0:
                    while getActionBatches() >= 5: #---If there are 5 or more ABs that are in progress.
                        print('Incomplete action batches are currently equal to or greater than 5. Sleeping for 5 seconds.')
                        time.sleep(5) #---Waits for 5 seconds before retrying.
                    response = requests.post(url=(baseUrl + f"/organizations/{organisationId}/actionBatches"), headers=headers, data = json.dumps(payload)) #---Submit the AB.
                    if response.status_code == 201:
                        responseJson = response.json()
                        print('Action batch', responseJson['id'], 'has been submitted') #---For troubleshooting/visibility.
                    elif response.status_code == 429: #---The status code returned when you exceed the maximum number of API calls per second.
                        time.sleep(int(response.headers["Retry-After"])) #---Get one of the response headers and sleep the amount of time it's asking you to.
                        return actionBatching(organisationId, networksCopy) #---Re-run the function.
                    else:
                        return 'An unexpected status code' #---Simple error handling.
                else:
                    return 'No actions to perform' #---Nothing should hit this. Can be removed.
            return 'Complete' #---All done!
        except:
            return 'An unknown error occured.' #---Simple error handling.

results = actionBatching(networksJson)
print(results)

<NOTE>: There is an error in the code which I didn’t spot until creating this post. Currently, if rate-limiting is hit the list of actions that was attempted to be submitted in the AB to create won’t be passed into the next retry. I will resolve this the next time I need the code and update this blog post. Everyday’s a school day.</NOTE>

That’s it. We’re now able to perform mass creation of networks whilst operating within the limitations set by the API documentation. With a few minutes of editing this can be re-applied to any other AB action, or combination of actions. Handy stuff! If this code helps you, or you have some comments/ideas/ammendments, reach out and let me know.

Happy scripting!