Skip to content

Commit aaf93ee

Browse files
authored
Add files via upload
1 parent c74bb9d commit aaf93ee

File tree

1 file changed

+353
-0
lines changed

1 file changed

+353
-0
lines changed

mi_bom_tool.py

+353
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,353 @@
1+
readMe = """This is a script to count how many and what type of Meraki Insight licenses would
2+
be required to fully cover all the MX and Z-series appliances used in networks in an organization.
3+
4+
Script syntax, Windows:
5+
python mi_bom_tool.py -k <api_key> [-o <org_name>] [-i <net_tag>] [-e <net_tag>]
6+
7+
Script syntax, Linux and Mac:
8+
python3 mi_bom_tool.py -k <api_key> [-o <org_name>] [-i <net_tag>] [-e <net_tag>]
9+
10+
Mandatory parameters:
11+
-k <api_key> Your Meraki Dashboard API key
12+
-o <org_name> If your Meraki Dashboard API key has access to multiple
13+
organizations, you will need to specify the name of the
14+
organization to be processed. This can be omitted for
15+
keys with access to a single organization only
16+
17+
Optional parameters:
18+
-i <net_tag> "Include" filter. Only process networks WITH specified network tag
19+
-e <net_tag> "Exclude" filter. Only process networks WITHOUT specified tag
20+
21+
Note: parameters "-i" and "-i" are incmpatible with each other.
22+
23+
Example count all Insight licenses needed for networks tagged "sales-office" in organization
24+
"Big Industries Inc":
25+
python mi_bom_tool.py -k 1234 -o "Big Industries Inc" -i sales-office
26+
27+
Required Python 3 modules:
28+
requests
29+
30+
To install these Python 3 modules via pip you can use the following commands:
31+
pip install requests
32+
33+
Depending on your operating system and Python environment, you may need to use commands
34+
"python3" and "pip3" instead of "python" and "pip".
35+
"""
36+
37+
38+
import sys, getopt, time, datetime, yaml, re
39+
40+
from urllib.parse import urlencode
41+
from requests import Session, utils
42+
43+
class NoRebuildAuthSession(Session):
44+
def rebuild_auth(self, prepared_request, response):
45+
"""
46+
This method is intentionally empty. Needed to prevent auth header stripping on redirect. More info:
47+
https://stackoverflow.com/questions/60358216/python-requests-post-request-dropping-authorization-header
48+
"""
49+
50+
API_MAX_RETRIES = 3
51+
API_CONNECT_TIMEOUT = 60
52+
API_TRANSMIT_TIMEOUT = 60
53+
API_STATUS_RATE_LIMIT = 429
54+
55+
#Set to True or False to enable/disable console logging of sent API requests
56+
FLAG_REQUEST_VERBOSE = True
57+
58+
API_BASE_URL = "https://api.meraki.com/api/v1"
59+
60+
61+
def merakiRequest(p_apiKey, p_httpVerb, p_endpoint, p_additionalHeaders=None, p_queryItems=None,
62+
p_requestBody=None, p_verbose=False, p_retry=0):
63+
#returns success, errors, responseHeaders, responseBody
64+
65+
if p_retry > API_MAX_RETRIES:
66+
if(p_verbose):
67+
print("ERROR: Reached max retries")
68+
return False, None, None, None
69+
70+
bearerString = "Bearer " + str(p_apiKey)
71+
headers = {"Authorization": bearerString}
72+
if not p_additionalHeaders is None:
73+
headers.update(p_additionalHeaders)
74+
75+
query = ""
76+
if not p_queryItems is None:
77+
query = "?" + urlencode(p_queryItems, True)
78+
url = API_BASE_URL + p_endpoint + query
79+
80+
verb = p_httpVerb.upper()
81+
82+
session = NoRebuildAuthSession()
83+
84+
try:
85+
if(p_verbose):
86+
print(verb, url)
87+
if verb == "GET":
88+
r = session.get(
89+
url,
90+
headers = headers,
91+
timeout = (API_CONNECT_TIMEOUT, API_TRANSMIT_TIMEOUT)
92+
)
93+
elif verb == "PUT":
94+
if not p_requestBody is None:
95+
if (p_verbose):
96+
print("body", p_requestBody)
97+
r = session.put(
98+
url,
99+
headers = headers,
100+
json = p_requestBody,
101+
timeout = (API_CONNECT_TIMEOUT, API_TRANSMIT_TIMEOUT)
102+
)
103+
elif verb == "POST":
104+
if not p_requestBody is None:
105+
if (p_verbose):
106+
print("body", p_requestBody)
107+
r = session.post(
108+
url,
109+
headers = headers,
110+
json = p_requestBody,
111+
timeout = (API_CONNECT_TIMEOUT, API_TRANSMIT_TIMEOUT)
112+
)
113+
elif verb == "DELETE":
114+
r = session.delete(
115+
url,
116+
headers = headers,
117+
timeout = (API_CONNECT_TIMEOUT, API_TRANSMIT_TIMEOUT)
118+
)
119+
else:
120+
return False, None, None, None
121+
except:
122+
return False, None, None, None
123+
124+
if(p_verbose):
125+
print(r.status_code)
126+
127+
success = r.status_code in range (200, 299)
128+
errors = None
129+
responseHeaders = None
130+
responseBody = None
131+
132+
if r.status_code == API_STATUS_RATE_LIMIT:
133+
retryInterval = 2
134+
if "Retry-After" in r.headers:
135+
retryInterval = r.headers["Retry-After"]
136+
if "retry-after" in r.headers:
137+
retryInterval = r.headers["retry-after"]
138+
139+
if(p_verbose):
140+
print("INFO: Hit max request rate. Retrying %s after %s seconds" % (p_retry+1, retryInterval))
141+
time.sleep(int(retryInterval))
142+
success, errors, responseHeaders, responseBody = merakiRequest(p_apiKey, p_httpVerb, p_endpoint, p_additionalHeaders,
143+
p_queryItems, p_requestBody, p_verbose, p_retry+1)
144+
return success, errors, responseHeaders, responseBody
145+
146+
try:
147+
rjson = r.json()
148+
except:
149+
rjson = None
150+
151+
if not rjson is None:
152+
if "errors" in rjson:
153+
errors = rjson["errors"]
154+
if(p_verbose):
155+
print(errors)
156+
else:
157+
responseBody = rjson
158+
159+
if "Link" in r.headers:
160+
parsedLinks = utils.parse_header_links(r.headers["Link"])
161+
for link in parsedLinks:
162+
if link["rel"] == "next":
163+
if(p_verbose):
164+
print("Next page:", link["url"])
165+
splitLink = link["url"].split("/api/v1")
166+
success, errors, responseHeaders, nextBody = merakiRequest(p_apiKey, p_httpVerb, splitLink[1],
167+
p_additionalHeaders=p_additionalHeaders,
168+
p_requestBody=p_requestBody,
169+
p_verbose=p_verbose)
170+
if success:
171+
if not responseBody is None:
172+
responseBody = responseBody + nextBody
173+
else:
174+
responseBody = None
175+
176+
return success, errors, responseHeaders, responseBody
177+
178+
179+
def getOrganizations(apiKey):
180+
endpoint = "/organizations"
181+
success, errors, headers, response = merakiRequest(apiKey, "GET", endpoint, p_verbose=FLAG_REQUEST_VERBOSE)
182+
return success, errors, headers, response
183+
184+
185+
def getOrganizationInventoryDevices(apiKey, organizationId):
186+
endpoint = "/organizations/%s/inventoryDevices" % organizationId
187+
success, errors, headers, response = merakiRequest(apiKey, "GET", endpoint, p_verbose=FLAG_REQUEST_VERBOSE)
188+
return success, errors, headers, response
189+
190+
191+
def getOrganizationNetworks(apiKey, organizationId):
192+
endpoint = "/organizations/%s/networks" % organizationId
193+
success, errors, headers, response = merakiRequest(apiKey, "GET", endpoint, p_verbose=FLAG_REQUEST_VERBOSE)
194+
return success, errors, headers, response
195+
196+
197+
198+
199+
def log(text, filePath=None):
200+
logString = "%s -- %s" % (datetime.datetime.now(), text)
201+
print(logString)
202+
if not filePath is None:
203+
try:
204+
with open(filePath, "a") as logFile:
205+
logFile.write("%s\n" % logString)
206+
except:
207+
log("ERROR: Unable to append to log file")
208+
209+
210+
def killScript(reason=None):
211+
if reason is None:
212+
print(readMe)
213+
sys.exit()
214+
else:
215+
log("ERROR: %s" % reason)
216+
sys.exit()
217+
218+
219+
def findNetworkWithId(netId, netList):
220+
for net in netList:
221+
if net["id"] == netId:
222+
return net
223+
return None
224+
225+
226+
def findApplianceForNetworkId(netId, applianceList):
227+
for appliance in applianceList:
228+
if appliance["networkId"] == netId:
229+
return appliance
230+
return None
231+
232+
233+
def findLicenseForModel(model):
234+
if model.startswith("Z"):
235+
return "MI-XS"
236+
if model.startswith("MX6"):
237+
return "MI-S"
238+
if model.startswith("MX7") or model.startswith("MX8") or model.startswith("MX9") or model.startswith("MX10"):
239+
return "MI-M"
240+
if model.startswith("MX250"):
241+
return "MI-L"
242+
if model.startswith("MX450"):
243+
return "MI-XL"
244+
return None
245+
246+
247+
248+
def main(argv):
249+
arg_apiKey = None
250+
arg_orgName = None
251+
arg_includeTag = None
252+
arg_excludeTag = None
253+
254+
try:
255+
opts, args = getopt.getopt(argv, 'k:o:i:e:')
256+
except getopt.GetoptError:
257+
sys.exit(2)
258+
259+
for opt, arg in opts:
260+
if opt == '-k':
261+
arg_apiKey = str(arg)
262+
if opt == '-o':
263+
arg_orgName = str(arg)
264+
if opt == '-i':
265+
arg_includeTag = str(arg)
266+
if opt == '-e':
267+
arg_excludeTag = str(arg)
268+
269+
if arg_apiKey is None:
270+
killScript()
271+
272+
if (not arg_includeTag is None) and (not arg_excludeTag is None):
273+
killScript("Include and Exclude tag filters cannot be used at the same time")
274+
275+
success, errors, headers, organizations = getOrganizations(arg_apiKey)
276+
277+
if organizations is None:
278+
killScript("Unable to fetch organizations for that API key")
279+
280+
organizationId = None
281+
organizationName = None
282+
283+
if len(organizations) == 1 and arg_orgName is None:
284+
organizationId = organizations[0]["id"]
285+
organizationName = organizations[0]["name"]
286+
287+
for org in organizations:
288+
if org['name'] == arg_orgName:
289+
organizationId = org['id']
290+
organizationName = org['name']
291+
break
292+
293+
if organizationId is None:
294+
killScript("No organization found with that name")
295+
296+
success, errors, headers, allNetworks = getOrganizationNetworks(arg_apiKey, organizationId)
297+
298+
if allNetworks is None:
299+
killScript("Unable to fetch networks for that organization")
300+
301+
302+
networks = []
303+
304+
for net in allNetworks:
305+
if "appliance" in net["productTypes"]:
306+
networkIsInScope = True
307+
if (not arg_includeTag is None) and (not (arg_includeTag in net["tags"])):
308+
networkIsInScope = False
309+
310+
if (not arg_excludeTag is None) and (arg_excludeTag in net["tags"]):
311+
networkIsInScope = False
312+
313+
if networkIsInScope:
314+
networks.append(net)
315+
316+
success, errors, headers, allDevices = getOrganizationInventoryDevices(arg_apiKey, organizationId)
317+
318+
if allDevices is None:
319+
killScript("Unable to fetch devices for that organization")
320+
321+
appliances = []
322+
323+
for device in allDevices:
324+
if not device["networkId"] is None and (device["model"].startswith("MX") or device["model"].startswith("Z")):
325+
if not findNetworkWithId(device["networkId"], networks) is None:
326+
appliances.append(device)
327+
328+
counters = {
329+
"MI-XS" : 0,
330+
"MI-S" : 0,
331+
"MI-M" : 0,
332+
"MI-L" : 0,
333+
"MI-XL" : 0
334+
}
335+
336+
# looping networks and not appliances to eliminate HA pair duplicates more easily
337+
for net in networks:
338+
appliance = findApplianceForNetworkId(net["id"], appliances)
339+
if not appliance is None:
340+
licenseType = findLicenseForModel(appliance["model"])
341+
if licenseType is None:
342+
log("WARNING: Unsupported security appliance model. Results may be inaccurate")
343+
else:
344+
counters[licenseType] += 1
345+
346+
print('\nTotal MI license capacity needed for networks in scope:\n')
347+
348+
for size in counters:
349+
if counters[size] > 0:
350+
print("%-6s: %s" % (size, counters[size]))
351+
352+
if __name__ == '__main__':
353+
main(sys.argv[1:])

0 commit comments

Comments
 (0)