Hide keyboard shortcuts

Hot-keys on this page

r m x p   toggle line displays

j k   next/prev highlighted chunk

0   (zero) top of page

1   (one) first highlighted chunk

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

25

26

27

28

29

30

31

32

33

34

35

36

37

38

39

40

41

42

43

44

45

46

47

48

49

50

51

52

53

54

55

56

57

58

59

60

61

62

63

64

65

66

67

68

69

70

71

72

73

74

75

76

77

78

79

80

81

82

83

84

85

86

87

88

89

90

91

92

93

94

95

96

97

98

99

100

101

102

103

104

105

106

107

108

109

110

111

112

113

114

115

116

117

118

119

120

121

122

123

124

125

126

127

128

129

130

131

132

133

134

135

136

137

138

139

140

141

142

143

144

145

146

147

148

149

150

151

152

153

154

155

156

157

158

159

160

161

162

163

164

165

166

167

168

169

170

171

172

173

174

175

176

177

178

179

180

181

182

183

184

185

186

187

188

189

190

191

192

193

194

195

196

197

198

199

200

201

202

203

204

205

206

207

208

209

210

211

212

213

214

215

216

217

218

219

220

221

222

223

224

225

226

227

228

229

230

231

232

233

234

235

236

237

238

239

240

241

242

243

244

245

246

247

248

249

250

251

252

253

254

255

256

257

258

259

260

261

262

263

264

265

266

267

268

269

270

271

272

273

274

275

276

277

278

279

280

281

282

283

284

285

286

287

288

289

290

291

292

293

294

295

296

297

298

299

300

301

302

303

304

305

306

307

308

309

310

311

312

313

314

315

316

317

318

319

320

321

322

323

324

325

326

327

328

329

330

331

332

333

334

335

336

337

338

339

340

341

342

343

344

345

346

347

348

349

350

351

352

353

354

355

356

357

358

359

360

361

362

363

364

365

366

367

368

369

370

371

372

373

374

375

376

377

378

379

380

381

382

383

384

385

386

387

388

389

390

391

392

393

394

395

396

397

398

399

400

401

402

403

404

405

406

407

408

409

410

411

412

413

414

415

416

417

418

419

420

421

422

423

424

425

426

427

428

429

430

431

432

433

434

435

436

437

438

439

440

441

442

443

444

445

446

447

448

449

450

451

452

453

454

455

456

457

458

459

460

461

462

463

464

465

466

467

# -*- 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)