# -*- coding: utf-8 -*-
# Copyright: (c) 2018, Kevin Breit (@kbreit) <kevin.breit@kevinbreit.net>
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
from __future__ import absolute_import, division, print_function
__metaclass__ = type
import time
import os
import re
from ansible.module_utils.basic import AnsibleModule, json, env_fallback
from ansible.module_utils.common.dict_transformations import camel_dict_to_snake_dict, recursive_diff
from ansible.module_utils.urls import fetch_url
from ansible.module_utils.six.moves.urllib.parse import urlencode
from ansible.module_utils._text import to_native, to_bytes, to_text
RATE_LIMIT_RETRY_MULTIPLIER = 3
INTERNAL_ERROR_RETRY_MULTIPLIER = 3
def meraki_argument_spec():
return dict(auth_key=dict(type='str', no_log=True, fallback=(env_fallback, ['MERAKI_KEY']), required=True),
host=dict(type='str', default='api.meraki.com'),
use_proxy=dict(type='bool', default=False),
use_https=dict(type='bool', default=True),
validate_certs=dict(type='bool', default=True),
output_format=dict(type='str', choices=['camelcase', 'snakecase'], default='snakecase', fallback=(env_fallback, ['ANSIBLE_MERAKI_FORMAT'])),
output_level=dict(type='str', default='normal', choices=['normal', 'debug']),
timeout=dict(type='int', default=30),
org_name=dict(type='str', aliases=['organization']),
org_id=dict(type='str'),
rate_limit_retry_time=dict(type='int', default=165),
internal_error_retry_time=dict(type='int', default=60)
)
class RateLimitException(Exception):
def __init__(self, *args, **kwargs):
Exception.__init__(self, *args, **kwargs)
class InternalErrorException(Exception):
def __init__(self, *args, **kwargs):
Exception.__init__(self, *args, **kwargs)
class HTTPError(Exception):
def __init__(self, *args, **kwargs):
Exception.__init__(self, *args, **kwargs)
def _error_report(function):
def inner(self, *args, **kwargs):
while True:
try:
response = function(self, *args, **kwargs)
59 ↛ 60line 59 didn't jump to line 60, because the condition on line 59 was never true if self.status == 429:
raise RateLimitException(
"Rate limiter hit, retry {0}".format(self.retry))
62 ↛ 63line 62 didn't jump to line 63, because the condition on line 62 was never true elif self.status == 500:
raise InternalErrorException(
"Internal server error 500, retry {0}".format(self.retry))
65 ↛ 66line 65 didn't jump to line 66, because the condition on line 65 was never true elif self.status == 502:
raise InternalErrorException(
"Internal server error 502, retry {0}".format(self.retry))
68 ↛ 69line 68 didn't jump to line 69, because the condition on line 68 was never true elif self.status == 400:
raise HTTPError("")
70 ↛ 71line 70 didn't jump to line 71, because the condition on line 70 was never true elif self.status >= 400:
raise HTTPError("")
self.retry = 0 # Needs to reset in case of future retries
return response
except RateLimitException as e:
self.retry += 1
if self.retry <= 10:
self.retry_time += self.retry * RATE_LIMIT_RETRY_MULTIPLIER
time.sleep(self.retry * RATE_LIMIT_RETRY_MULTIPLIER)
else:
self.retry_time += 30
time.sleep(30)
if self.retry_time > self.params['rate_limit_retry_time']:
raise RateLimitException(e)
except InternalErrorException as e:
self.retry += 1
if self.retry <= 10:
self.retry_time += self.retry * INTERNAL_ERROR_RETRY_MULTIPLIER
time.sleep(self.retry * INTERNAL_ERROR_RETRY_MULTIPLIER)
else:
self.retry_time += 9
time.sleep(9)
if self.retry_time > self.params['internal_error_retry_time']:
raise InternalErrorException(e)
except HTTPError as e:
try:
self.fail_json(msg="HTTP error {0} - {1} - {2}".format(self.status, self.url, json.loads(self.body)['errors']),
body=json.loads(self.body))
except json.decoder.JSONDecodeError:
self.fail_json(msg="HTTP error {0} - {1}".format(self.status, self.url))
try:
return inner
except HTTPError:
pass
class MerakiModule(object):
def __init__(self, module, function=None):
self.module = module
self.params = module.params
self.result = dict(changed=False)
self.headers = dict()
self.function = function
self.orgs = None
self.nets = None
self.org_id = None
self.net_id = None
self.check_mode = module.check_mode
self.key_map = {}
self.request_attempts = 0
# normal output
self.existing = None
# info output
self.config = dict()
self.original = None
self.proposed = dict()
self.merged = None
self.ignored_keys = ['id', 'organizationId']
# debug output
self.filter_string = ''
self.method = None
self.path = None
self.response = None
self.status = None
self.url = None
self.body = None
# rate limiting statistics
self.retry = 0
self.retry_time = 0
# If URLs need to be modified or added for specific purposes, use .update() on the url_catalog dictionary
self.get_urls = {'organizations': '/organizations',
'network': '/organizations/{org_id}/networks',
'admins': '/organizations/{org_id}/admins',
'configTemplates': '/organizations/{org_id}/configTemplates',
'samlymbols': '/organizations/{org_id}/samlRoles',
'ssids': '/networks/{net_id}/ssids',
'groupPolicies': '/networks/{net_id}/groupPolicies',
'staticRoutes': '/networks/{net_id}/staticRoutes',
'vlans': '/networks/{net_id}/vlans',
'devices': '/networks/{net_id}/devices',
}
# Used to retrieve only one item
self.get_one_urls = {'organizations': '/organizations/{org_id}',
'network': '/networks/{net_id}',
}
# Module should add URLs which are required by the module
self.url_catalog = {'get_all': self.get_urls,
'get_one': self.get_one_urls,
'create': None,
'update': None,
'delete': None,
'misc': None,
}
172 ↛ 173line 172 didn't jump to line 173, because the condition on line 172 was never true if self.module._debug or self.params['output_level'] == 'debug':
self.module.warn('Enable debug output because ANSIBLE_DEBUG was set or output_level is set to debug.')
# TODO: This should be removed as org_name isn't always required
self.module.required_if = [('state', 'present', ['org_name']),
('state', 'absent', ['org_name']),
]
# self.module.mutually_exclusive = [('org_id', 'org_name'),
# ]
self.modifiable_methods = ['POST', 'PUT', 'DELETE']
self.headers = {'Content-Type': 'application/json',
'X-Cisco-Meraki-API-Key': module.params['auth_key'],
}
def define_protocol(self):
"""Set protocol based on use_https parameters."""
189 ↛ 192line 189 didn't jump to line 192, because the condition on line 189 was never false if self.params['use_https'] is True:
self.params['protocol'] = 'https'
else:
self.params['protocol'] = 'http'
def sanitize_keys(self, data):
if isinstance(data, dict):
items = {}
for k, v in data.items():
try:
new = {self.key_map[k]: data[k]}
items[self.key_map[k]] = self.sanitize_keys(data[k])
except KeyError:
snake_k = re.sub('([a-z0-9])([A-Z])', r'\1_\2', k).lower()
new = {snake_k: data[k]}
items[snake_k] = self.sanitize_keys(data[k])
return items
elif isinstance(data, list):
items = []
for i in data:
items.append(self.sanitize_keys(i))
return items
elif isinstance(data, int) or isinstance(data, str) or isinstance(data, float):
return data
def is_update_required(self, original, proposed, optional_ignore=None):
''' Compare two data-structures '''
self.ignored_keys.append('net_id')
if optional_ignore is not None:
self.ignored_keys = self.ignored_keys + optional_ignore
220 ↛ 221line 220 didn't jump to line 221, because the condition on line 220 was never true if isinstance(original, list):
if len(original) != len(proposed):
# self.fail_json(msg="Length of lists don't match")
return True
for a, b in zip(original, proposed):
if self.is_update_required(a, b):
# self.fail_json(msg="List doesn't match", a=a, b=b)
return True
elif isinstance(original, dict):
try:
for k, v in proposed.items():
if k not in self.ignored_keys:
232 ↛ 237line 232 didn't jump to line 237, because the condition on line 232 was never false if k in original:
if self.is_update_required(original[k], proposed[k]):
return True
else:
# self.fail_json(msg="Key not in original", k=k)
return True
except AttributeError:
return True
else:
if original != proposed:
# self.fail_json(msg="Fallback", original=original, proposed=proposed)
return True
return False
def generate_diff(self, before, after):
"""Creates a diff based on two objects. Applies to the object and returns nothing.
"""
try:
diff = recursive_diff(before, after)
self.result['diff'] = {'before': diff[0],
'after': diff[1]}
except AttributeError: # Normally for passing a list instead of a dict
diff = recursive_diff({'data': before},
{'data': after})
self.result['diff'] = {'before': diff[0]['data'],
'after': diff[1]['data']}
def get_orgs(self):
"""Downloads all organizations for a user."""
response = self.request('/organizations', method='GET')
262 ↛ 263line 262 didn't jump to line 263, because the condition on line 262 was never true if self.status != 200:
self.fail_json(msg='Organization lookup failed')
self.orgs = response
return self.orgs
def is_org_valid(self, data, org_name=None, org_id=None):
"""Checks whether a specific org exists and is duplicated.
If 0, doesn't exist. 1, exists and not duplicated. >1 duplicated.
"""
org_count = 0
273 ↛ 277line 273 didn't jump to line 277, because the condition on line 273 was never false if org_name is not None:
for o in data:
if o['name'] == org_name:
org_count += 1
277 ↛ 278line 277 didn't jump to line 278, because the condition on line 277 was never true if org_id is not None:
for o in data:
if o['id'] == org_id:
org_count += 1
return org_count
def get_org_id(self, org_name):
"""Returns an organization id based on organization name, only if unique.
If org_id is specified as parameter, return that instead of a lookup.
"""
orgs = self.get_orgs()
# self.fail_json(msg='ogs', orgs=orgs)
290 ↛ 291line 290 didn't jump to line 291, because the condition on line 290 was never true if self.params['org_id'] is not None:
if self.is_org_valid(orgs, org_id=self.params['org_id']) is True:
return self.params['org_id']
org_count = self.is_org_valid(orgs, org_name=org_name)
294 ↛ 295line 294 didn't jump to line 295, because the condition on line 294 was never true if org_count == 0:
self.fail_json(msg='There are no organizations with the name {org_name}'.format(org_name=org_name))
296 ↛ 297line 296 didn't jump to line 297, because the condition on line 296 was never true if org_count > 1:
self.fail_json(msg='There are multiple organizations with the name {org_name}'.format(org_name=org_name))
298 ↛ exitline 298 didn't return from function 'get_org_id', because the condition on line 298 was never false elif org_count == 1:
299 ↛ exitline 299 didn't return from function 'get_org_id', because the loop on line 299 didn't complete for i in orgs:
300 ↛ 299line 300 didn't jump to line 299, because the condition on line 300 was never false if org_name == i['name']:
# self.fail_json(msg=i['id'])
return str(i['id'])
def get_nets(self, org_name=None, org_id=None):
"""Downloads all networks in an organization."""
306 ↛ 307line 306 didn't jump to line 307, because the condition on line 306 was never true if org_name:
org_id = self.get_org_id(org_name)
path = self.construct_path('get_all', org_id=org_id, function='network')
r = self.request(path, method='GET')
310 ↛ 311line 310 didn't jump to line 311, because the condition on line 310 was never true if self.status != 200:
self.fail_json(msg='Network lookup failed')
self.nets = r
templates = self.get_config_templates(org_id)
for t in templates:
self.nets.append(t)
return self.nets
def get_net(self, org_name, net_name=None, org_id=None, data=None, net_id=None):
''' Return network information '''
320 ↛ 321line 320 didn't jump to line 321, because the condition on line 320 was never true if not data:
if not org_id:
org_id = self.get_org_id(org_name)
data = self.get_nets(org_id=org_id)
324 ↛ 331line 324 didn't jump to line 331, because the loop on line 324 didn't complete for n in data:
325 ↛ 328line 325 didn't jump to line 328, because the condition on line 325 was never false if net_id:
if n['id'] == net_id:
return n
elif net_name:
if n['name'] == net_name:
return n
return False
def get_net_id(self, org_name=None, net_name=None, data=None):
"""Return network id from lookup or existing data."""
335 ↛ 336line 335 didn't jump to line 336, because the condition on line 335 was never true if data is None:
self.fail_json(msg='Must implement lookup')
337 ↛ 340line 337 didn't jump to line 340, because the loop on line 337 didn't complete for n in data:
if n['name'] == net_name:
return n['id']
self.fail_json(msg='No network found with the name {0}'.format(net_name))
def get_config_templates(self, org_id):
path = self.construct_path('get_all', function='configTemplates', org_id=org_id)
response = self.request(path, 'GET')
345 ↛ 346line 345 didn't jump to line 346, because the condition on line 345 was never true if self.status != 200:
self.fail_json(msg='Unable to get configuration templates')
return response
def get_template_id(self, name, data):
for template in data:
if name == template['name']:
return template['id']
self.fail_json(msg='No configuration template named {0} found'.format(name))
def convert_camel_to_snake(self, data):
"""
Converts a dictionary or list to snake case from camel case
:type data: dict or list
:return: Converted data structure, if list or dict
"""
if isinstance(data, dict):
return camel_dict_to_snake_dict(data, ignore_list=('tags', 'tag'))
364 ↛ 367line 364 didn't jump to line 367, because the condition on line 364 was never false elif isinstance(data, list):
return [camel_dict_to_snake_dict(item, ignore_list=('tags', 'tag')) for item in data]
else:
return data
def construct_params_list(self, keys, aliases=None):
qs = {}
for key in keys:
if key in aliases:
qs[aliases[key]] = self.module.params[key]
else:
qs[key] = self.module.params[key]
return qs
def encode_url_params(self, params):
"""Encodes key value pairs for URL"""
return "?{0}".format(urlencode(params))
def construct_path(self,
action,
function=None,
org_id=None,
net_id=None,
org_name=None,
custom=None,
params=None):
"""Build a path from the URL catalog.
Uses function property from class for catalog lookup.
"""
built_path = None
if function is None:
built_path = self.url_catalog[action][self.function]
else:
built_path = self.url_catalog[action][function]
398 ↛ 399line 398 didn't jump to line 399, because the condition on line 398 was never true if org_name:
org_id = self.get_org_id(org_name)
400 ↛ 401line 400 didn't jump to line 401, because the condition on line 400 was never true if custom:
built_path = built_path.format(org_id=org_id, net_id=net_id, **custom)
else:
built_path = built_path.format(org_id=org_id, net_id=net_id)
404 ↛ 405line 404 didn't jump to line 405, because the condition on line 404 was never true if params:
built_path += self.encode_url_params(params)
return built_path
@_error_report
def request(self, path, method=None, payload=None):
"""Generic HTTP method for Meraki requests."""
self.path = path
self.define_protocol()
414 ↛ 416line 414 didn't jump to line 416, because the condition on line 414 was never false if method is not None:
self.method = method
self.url = '{protocol}://{host}/api/v0/{path}'.format(path=self.path.lstrip('/'), **self.params)
resp, info = fetch_url(self.module, self.url,
headers=self.headers,
data=payload,
method=self.method,
timeout=self.params['timeout'],
use_proxy=self.params['use_proxy'],
)
424 ↛ 425line 424 didn't jump to line 425, because the condition on line 424 was never true if 'body' in info:
self.body = info['body']
self.response = info['msg']
self.status = info['status']
try:
return json.loads(to_native(resp.read()))
except Exception:
pass
def exit_json(self, **kwargs):
"""Custom written method to exit from module."""
self.result['response'] = self.response
self.result['status'] = self.status
438 ↛ 439line 438 didn't jump to line 439, because the condition on line 438 was never true if self.retry > 0:
self.module.warn("Rate limiter triggered - retry count {0}".format(self.retry))
# Return the gory details when we need it
441 ↛ 442line 441 didn't jump to line 442, because the condition on line 441 was never true if self.params['output_level'] == 'debug':
self.result['method'] = self.method
self.result['url'] = self.url
self.result.update(**kwargs)
445 ↛ 446line 445 didn't jump to line 446, because the condition on line 445 was never true if self.params['output_format'] == 'camelcase':
self.module.deprecate("Update your playbooks to support snake_case format instead of camelCase format.", version=2.13)
else:
if 'data' in self.result:
try:
self.result['data'] = self.convert_camel_to_snake(self.result['data'])
self.result['diff'] = self.convert_camel_to_snake(self.result['diff'])
except (KeyError, AttributeError):
pass
self.module.exit_json(**self.result)
def fail_json(self, msg, **kwargs):
"""Custom written method to return info on failure."""
self.result['response'] = self.response
self.result['status'] = self.status
if self.params['output_level'] == 'debug':
if self.url is not None:
self.result['method'] = self.method
self.result['url'] = self.url
self.result.update(**kwargs)
self.module.fail_json(msg=msg, **self.result)
|