1# -*- coding: utf-8 -*- 2 3# This code is part of Ansible, but is an independent component 4 5# This particular file snippet, and this file snippet only, is BSD licensed. 6# Modules you write using this snippet, which is embedded dynamically by Ansible 7# still belong to the author of the module, and may assign their own license 8# to the complete work. 9 10# Copyright: (c) 2017, Dag Wieers <dag@wieers.com> 11# Copyright: (c) 2017, Jacob McGill (@jmcgill298) 12# Copyright: (c) 2017, Swetha Chunduri (@schunduri) 13# Copyright: (c) 2019, Rob Huelga (@RobW3LGA) 14# All rights reserved. 15 16# Redistribution and use in source and binary forms, with or without modification, 17# are permitted provided that the following conditions are met: 18# 19# * Redistributions of source code must retain the above copyright 20# notice, this list of conditions and the following disclaimer. 21# * Redistributions in binary form must reproduce the above copyright notice, 22# this list of conditions and the following disclaimer in the documentation 23# and/or other materials provided with the distribution. 24# 25# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 26# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 27# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. 28# IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, 29# INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, 30# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS 31# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT 32# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE 33# USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 34 35import base64 36import json 37import os 38from copy import deepcopy 39 40from ansible.module_utils.parsing.convert_bool import boolean 41from ansible.module_utils.urls import fetch_url 42from ansible.module_utils._text import to_bytes, to_native 43 44# Optional, only used for APIC signature-based authentication 45try: 46 from OpenSSL.crypto import FILETYPE_PEM, load_privatekey, sign 47 HAS_OPENSSL = True 48except ImportError: 49 HAS_OPENSSL = False 50 51# Optional, only used for XML payload 52try: 53 import lxml.etree 54 HAS_LXML_ETREE = True 55except ImportError: 56 HAS_LXML_ETREE = False 57 58# Optional, only used for XML payload 59try: 60 from xmljson import cobra 61 HAS_XMLJSON_COBRA = True 62except ImportError: 63 HAS_XMLJSON_COBRA = False 64 65 66def aci_argument_spec(): 67 return dict( 68 host=dict(type='str', required=True, aliases=['hostname']), 69 port=dict(type='int', required=False), 70 username=dict(type='str', default='admin', aliases=['user']), 71 password=dict(type='str', no_log=True), 72 private_key=dict(type='str', aliases=['cert_key'], no_log=True), # Beware, this is not the same as client_key ! 73 certificate_name=dict(type='str', aliases=['cert_name']), # Beware, this is not the same as client_cert ! 74 output_level=dict(type='str', default='normal', choices=['debug', 'info', 'normal']), 75 timeout=dict(type='int', default=30), 76 use_proxy=dict(type='bool', default=True), 77 use_ssl=dict(type='bool', default=True), 78 validate_certs=dict(type='bool', default=True), 79 ) 80 81 82class ACIModule(object): 83 84 def __init__(self, module): 85 self.module = module 86 self.params = module.params 87 self.result = dict(changed=False) 88 self.headers = dict() 89 self.child_classes = set() 90 91 # error output 92 self.error = dict(code=None, text=None) 93 94 # normal output 95 self.existing = None 96 97 # info output 98 self.config = dict() 99 self.original = None 100 self.proposed = dict() 101 102 # debug output 103 self.filter_string = '' 104 self.method = None 105 self.path = None 106 self.response = None 107 self.status = None 108 self.url = None 109 110 # aci_rest output 111 self.imdata = None 112 self.totalCount = None 113 114 # Ensure protocol is set 115 self.define_protocol() 116 117 if self.module._debug: 118 self.module.warn('Enable debug output because ANSIBLE_DEBUG was set.') 119 self.params['output_level'] = 'debug' 120 121 if self.params['private_key']: 122 # Perform signature-based authentication, no need to log on separately 123 if not HAS_OPENSSL: 124 self.module.fail_json(msg='Cannot use signature-based authentication because pyopenssl is not available') 125 elif self.params['password'] is not None: 126 self.module.warn("When doing ACI signatured-based authentication, providing parameter 'password' is not required") 127 elif self.params['password']: 128 # Perform password-based authentication, log on using password 129 self.login() 130 else: 131 self.module.fail_json(msg="Either parameter 'password' or 'private_key' is required for authentication") 132 133 def boolean(self, value, true='yes', false='no'): 134 ''' Return an acceptable value back ''' 135 136 # When we expect value is of type=bool 137 if value is None: 138 return None 139 elif value is True: 140 return true 141 elif value is False: 142 return false 143 144 # If all else fails, escalate back to user 145 self.module.fail_json(msg="Boolean value '%s' is an invalid ACI boolean value.") 146 147 def iso8601_format(self, dt): 148 ''' Return an ACI-compatible ISO8601 formatted time: 2123-12-12T00:00:00.000+00:00 ''' 149 try: 150 return dt.isoformat(timespec='milliseconds') 151 except Exception: 152 tz = dt.strftime('%z') 153 return '%s.%03d%s:%s' % (dt.strftime('%Y-%m-%dT%H:%M:%S'), dt.microsecond / 1000, tz[:3], tz[3:]) 154 155 def define_protocol(self): 156 ''' Set protocol based on use_ssl parameter ''' 157 158 # Set protocol for further use 159 self.params['protocol'] = 'https' if self.params.get('use_ssl', True) else 'http' 160 161 def define_method(self): 162 ''' Set method based on state parameter ''' 163 164 # Set method for further use 165 state_map = dict(absent='delete', present='post', query='get') 166 self.params['method'] = state_map[self.params['state']] 167 168 def login(self): 169 ''' Log in to APIC ''' 170 171 # Perform login request 172 if 'port' in self.params and self.params['port'] is not None: 173 url = '%(protocol)s://%(host)s:%(port)s/api/aaaLogin.json' % self.params 174 else: 175 url = '%(protocol)s://%(host)s/api/aaaLogin.json' % self.params 176 payload = {'aaaUser': {'attributes': {'name': self.params['username'], 'pwd': self.params['password']}}} 177 resp, auth = fetch_url(self.module, url, 178 data=json.dumps(payload), 179 method='POST', 180 timeout=self.params['timeout'], 181 use_proxy=self.params['use_proxy']) 182 183 # Handle APIC response 184 if auth['status'] != 200: 185 self.response = auth['msg'] 186 self.status = auth['status'] 187 try: 188 # APIC error 189 self.response_json(auth['body']) 190 self.fail_json(msg='Authentication failed: %(code)s %(text)s' % self.error) 191 except KeyError: 192 # Connection error 193 self.fail_json(msg='Connection failed for %(url)s. %(msg)s' % auth) 194 195 # Retain cookie for later use 196 self.headers['Cookie'] = resp.headers['Set-Cookie'] 197 198 def cert_auth(self, path=None, payload='', method=None): 199 ''' Perform APIC signature-based authentication, not the expected SSL client certificate authentication. ''' 200 201 if method is None: 202 method = self.params['method'].upper() 203 204 # NOTE: ACI documentation incorrectly uses complete URL 205 if path is None: 206 path = self.path 207 path = '/' + path.lstrip('/') 208 209 if payload is None: 210 payload = '' 211 212 # Check if we got a private key. This allows the use of vaulting the private key. 213 if self.params['private_key'].startswith('-----BEGIN PRIVATE KEY-----'): 214 try: 215 sig_key = load_privatekey(FILETYPE_PEM, self.params['private_key']) 216 except Exception: 217 self.module.fail_json(msg="Cannot load provided 'private_key' parameter.") 218 # Use the username as the certificate_name value 219 if self.params['certificate_name'] is None: 220 self.params['certificate_name'] = self.params['username'] 221 elif self.params['private_key'].startswith('-----BEGIN CERTIFICATE-----'): 222 self.module.fail_json(msg="Provided 'private_key' parameter value appears to be a certificate. Please correct.") 223 else: 224 # If we got a private key file, read from this file. 225 # NOTE: Avoid exposing any other credential as a filename in output... 226 if not os.path.exists(self.params['private_key']): 227 self.module.fail_json(msg="The provided private key file does not appear to exist. Is it a filename?") 228 try: 229 with open(self.params['private_key'], 'r') as fh: 230 private_key_content = fh.read() 231 except Exception: 232 self.module.fail_json(msg="Cannot open private key file '%s'." % self.params['private_key']) 233 if private_key_content.startswith('-----BEGIN PRIVATE KEY-----'): 234 try: 235 sig_key = load_privatekey(FILETYPE_PEM, private_key_content) 236 except Exception: 237 self.module.fail_json(msg="Cannot load private key file '%s'." % self.params['private_key']) 238 # Use the private key basename (without extension) as certificate_name 239 if self.params['certificate_name'] is None: 240 self.params['certificate_name'] = os.path.basename(os.path.splitext(self.params['private_key'])[0]) 241 elif private_key_content.startswith('-----BEGIN CERTIFICATE-----'): 242 self.module.fail_json(msg="Provided private key file %s appears to be a certificate. Please correct." % self.params['private_key']) 243 else: 244 self.module.fail_json(msg="Provided private key file '%s' does not appear to be a private key. Please correct." % self.params['private_key']) 245 246 # NOTE: ACI documentation incorrectly adds a space between method and path 247 sig_request = method + path + payload 248 sig_signature = base64.b64encode(sign(sig_key, sig_request, 'sha256')) 249 sig_dn = 'uni/userext/user-%s/usercert-%s' % (self.params['username'], self.params['certificate_name']) 250 self.headers['Cookie'] = 'APIC-Certificate-Algorithm=v1.0; ' +\ 251 'APIC-Certificate-DN=%s; ' % sig_dn +\ 252 'APIC-Certificate-Fingerprint=fingerprint; ' +\ 253 'APIC-Request-Signature=%s' % to_native(sig_signature) 254 255 def response_json(self, rawoutput): 256 ''' Handle APIC JSON response output ''' 257 try: 258 jsondata = json.loads(rawoutput) 259 except Exception as e: 260 # Expose RAW output for troubleshooting 261 self.error = dict(code=-1, text="Unable to parse output as JSON, see 'raw' output. %s" % e) 262 self.result['raw'] = rawoutput 263 return 264 265 # Extract JSON API output 266 try: 267 self.imdata = jsondata['imdata'] 268 except KeyError: 269 self.imdata = dict() 270 self.totalCount = int(jsondata['totalCount']) 271 272 # Handle possible APIC error information 273 self.response_error() 274 275 def response_xml(self, rawoutput): 276 ''' Handle APIC XML response output ''' 277 278 # NOTE: The XML-to-JSON conversion is using the "Cobra" convention 279 try: 280 xml = lxml.etree.fromstring(to_bytes(rawoutput)) 281 xmldata = cobra.data(xml) 282 except Exception as e: 283 # Expose RAW output for troubleshooting 284 self.error = dict(code=-1, text="Unable to parse output as XML, see 'raw' output. %s" % e) 285 self.result['raw'] = rawoutput 286 return 287 288 # Reformat as ACI does for JSON API output 289 try: 290 self.imdata = xmldata['imdata']['children'] 291 except KeyError: 292 self.imdata = dict() 293 self.totalCount = int(xmldata['imdata']['attributes']['totalCount']) 294 295 # Handle possible APIC error information 296 self.response_error() 297 298 def response_error(self): 299 ''' Set error information when found ''' 300 301 # Handle possible APIC error information 302 if self.totalCount != '0': 303 try: 304 self.error = self.imdata[0]['error']['attributes'] 305 except (KeyError, IndexError): 306 pass 307 308 def request(self, path, payload=None): 309 ''' Perform a REST request ''' 310 311 # Ensure method is set (only do this once) 312 self.define_method() 313 self.path = path 314 315 if 'port' in self.params and self.params['port'] is not None: 316 self.url = '%(protocol)s://%(host)s:%(port)s/' % self.params + path.lstrip('/') 317 else: 318 self.url = '%(protocol)s://%(host)s/' % self.params + path.lstrip('/') 319 320 # Sign and encode request as to APIC's wishes 321 if self.params['private_key']: 322 self.cert_auth(path=path, payload=payload) 323 324 # Perform request 325 resp, info = fetch_url(self.module, self.url, 326 data=payload, 327 headers=self.headers, 328 method=self.params['method'].upper(), 329 timeout=self.params['timeout'], 330 use_proxy=self.params['use_proxy']) 331 332 self.response = info['msg'] 333 self.status = info['status'] 334 335 # Handle APIC response 336 if info['status'] != 200: 337 try: 338 # APIC error 339 self.response_json(info['body']) 340 self.fail_json(msg='APIC Error %(code)s: %(text)s' % self.error) 341 except KeyError: 342 # Connection error 343 self.fail_json(msg='Connection failed for %(url)s. %(msg)s' % info) 344 345 self.response_json(resp.read()) 346 347 def query(self, path): 348 ''' Perform a query with no payload ''' 349 350 self.path = path 351 352 if 'port' in self.params and self.params['port'] is not None: 353 self.url = '%(protocol)s://%(host)s:%(port)s/' % self.params + path.lstrip('/') 354 else: 355 self.url = '%(protocol)s://%(host)s/' % self.params + path.lstrip('/') 356 357 # Sign and encode request as to APIC's wishes 358 if self.params['private_key']: 359 self.cert_auth(path=path, method='GET') 360 361 # Perform request 362 resp, query = fetch_url(self.module, self.url, 363 data=None, 364 headers=self.headers, 365 method='GET', 366 timeout=self.params['timeout'], 367 use_proxy=self.params['use_proxy']) 368 369 # Handle APIC response 370 if query['status'] != 200: 371 self.response = query['msg'] 372 self.status = query['status'] 373 try: 374 # APIC error 375 self.response_json(query['body']) 376 self.fail_json(msg='APIC Error %(code)s: %(text)s' % self.error) 377 except KeyError: 378 # Connection error 379 self.fail_json(msg='Connection failed for %(url)s. %(msg)s' % query) 380 381 query = json.loads(resp.read()) 382 383 return json.dumps(query['imdata'], sort_keys=True, indent=2) + '\n' 384 385 def request_diff(self, path, payload=None): 386 ''' Perform a request, including a proper diff output ''' 387 self.result['diff'] = dict() 388 self.result['diff']['before'] = self.query(path) 389 self.request(path, payload=payload) 390 # TODO: Check if we can use the request output for the 'after' diff 391 self.result['diff']['after'] = self.query(path) 392 393 if self.result['diff']['before'] != self.result['diff']['after']: 394 self.result['changed'] = True 395 396 # TODO: This could be designed to update existing keys 397 def update_qs(self, params): 398 ''' Append key-value pairs to self.filter_string ''' 399 accepted_params = dict((k, v) for (k, v) in params.items() if v is not None) 400 if accepted_params: 401 if self.filter_string: 402 self.filter_string += '&' 403 else: 404 self.filter_string = '?' 405 self.filter_string += '&'.join(['%s=%s' % (k, v) for (k, v) in accepted_params.items()]) 406 407 # TODO: This could be designed to accept multiple obj_classes and keys 408 def build_filter(self, obj_class, params): 409 ''' Build an APIC filter based on obj_class and key-value pairs ''' 410 accepted_params = dict((k, v) for (k, v) in params.items() if v is not None) 411 if len(accepted_params) == 1: 412 return ','.join('eq({0}.{1},"{2}")'.format(obj_class, k, v) for (k, v) in accepted_params.items()) 413 elif len(accepted_params) > 1: 414 return 'and(' + ','.join(['eq({0}.{1},"{2}")'.format(obj_class, k, v) for (k, v) in accepted_params.items()]) + ')' 415 416 def _deep_url_path_builder(self, obj): 417 target_class = obj['target_class'] 418 target_filter = obj['target_filter'] 419 subtree_class = obj['subtree_class'] 420 subtree_filter = obj['subtree_filter'] 421 object_rn = obj['object_rn'] 422 mo = obj['module_object'] 423 add_subtree_filter = obj['add_subtree_filter'] 424 add_target_filter = obj['add_target_filter'] 425 426 if self.module.params['state'] in ('absent', 'present') and mo is not None: 427 self.path = 'api/mo/uni/{0}.json'.format(object_rn) 428 self.update_qs({'rsp-prop-include': 'config-only'}) 429 430 else: 431 # State is 'query' 432 if object_rn is not None: 433 # Query for a specific object in the module's class 434 self.path = 'api/mo/uni/{0}.json'.format(object_rn) 435 else: 436 self.path = 'api/class/{0}.json'.format(target_class) 437 438 if add_target_filter: 439 self.update_qs( 440 {'query-target-filter': self.build_filter(target_class, target_filter)}) 441 442 if add_subtree_filter: 443 self.update_qs( 444 {'rsp-subtree-filter': self.build_filter(subtree_class, subtree_filter)}) 445 446 if 'port' in self.params and self.params['port'] is not None: 447 self.url = '{protocol}://{host}:{port}/{path}'.format( 448 path=self.path, **self.module.params) 449 450 else: 451 self.url = '{protocol}://{host}/{path}'.format( 452 path=self.path, **self.module.params) 453 454 if self.child_classes: 455 self.update_qs( 456 {'rsp-subtree': 'full', 'rsp-subtree-class': ','.join(sorted(self.child_classes))}) 457 458 def _deep_url_parent_object(self, parent_objects, parent_class): 459 460 for parent_object in parent_objects: 461 if parent_object['aci_class'] is parent_class: 462 return parent_object 463 464 return None 465 466 def construct_deep_url(self, target_object, parent_objects=None, child_classes=None): 467 """ 468 This method is used to retrieve the appropriate URL path and filter_string to make the request to the APIC. 469 470 :param target_object: The target class dictionary containing parent_class, aci_class, aci_rn, target_filter, and module_object keys. 471 :param parent_objects: The parent class list of dictionaries containing parent_class, aci_class, aci_rn, target_filter, and module_object keys. 472 :param child_classes: The list of child classes that the module supports along with the object. 473 :type target_object: dict 474 :type parent_objects: list[dict] 475 :type child_classes: list[string] 476 :return: The path and filter_string needed to build the full URL. 477 """ 478 479 self.filter_string = '' 480 rn_builder = None 481 subtree_classes = None 482 add_subtree_filter = False 483 add_target_filter = False 484 has_target_query = False 485 has_target_query_compare = False 486 has_target_query_difference = False 487 has_target_query_called = False 488 489 if child_classes is None: 490 self.child_classes = set() 491 else: 492 self.child_classes = set(child_classes) 493 494 target_parent_class = target_object['parent_class'] 495 target_class = target_object['aci_class'] 496 target_rn = target_object['aci_rn'] 497 target_filter = target_object['target_filter'] 498 target_module_object = target_object['module_object'] 499 500 url_path_object = dict( 501 target_class=target_class, 502 target_filter=target_filter, 503 subtree_class=target_class, 504 subtree_filter=target_filter, 505 module_object=target_module_object 506 ) 507 508 if target_module_object is not None: 509 rn_builder = target_rn 510 else: 511 has_target_query = True 512 has_target_query_compare = True 513 514 if parent_objects is not None: 515 current_parent_class = target_parent_class 516 has_parent_query_compare = False 517 has_parent_query_difference = False 518 is_first_parent = True 519 is_single_parent = None 520 search_classes = set() 521 522 while current_parent_class != 'uni': 523 parent_object = self._deep_url_parent_object( 524 parent_objects=parent_objects, parent_class=current_parent_class) 525 526 if parent_object is not None: 527 parent_parent_class = parent_object['parent_class'] 528 parent_class = parent_object['aci_class'] 529 parent_rn = parent_object['aci_rn'] 530 parent_filter = parent_object['target_filter'] 531 parent_module_object = parent_object['module_object'] 532 533 if is_first_parent: 534 is_single_parent = True 535 else: 536 is_single_parent = False 537 is_first_parent = False 538 539 if parent_parent_class != 'uni': 540 search_classes.add(parent_class) 541 542 if parent_module_object is not None: 543 if rn_builder is not None: 544 rn_builder = '{0}/{1}'.format(parent_rn, 545 rn_builder) 546 else: 547 rn_builder = parent_rn 548 549 url_path_object['target_class'] = parent_class 550 url_path_object['target_filter'] = parent_filter 551 552 has_target_query = False 553 else: 554 rn_builder = None 555 subtree_classes = search_classes 556 557 has_target_query = True 558 if is_single_parent: 559 has_parent_query_compare = True 560 561 current_parent_class = parent_parent_class 562 else: 563 raise ValueError("Reference error for parent_class '{0}'. Each parent_class must reference a valid object".format(current_parent_class)) 564 565 if not has_target_query_difference and not has_target_query_called: 566 if has_target_query is not has_target_query_compare: 567 has_target_query_difference = True 568 else: 569 if not has_parent_query_difference and has_target_query is not has_parent_query_compare: 570 has_parent_query_difference = True 571 has_target_query_called = True 572 573 if not has_parent_query_difference and has_parent_query_compare and target_module_object is not None: 574 add_target_filter = True 575 576 elif has_parent_query_difference and target_module_object is not None: 577 add_subtree_filter = True 578 self.child_classes.add(target_class) 579 580 if has_target_query: 581 add_target_filter = True 582 583 elif has_parent_query_difference and not has_target_query and target_module_object is None: 584 self.child_classes.add(target_class) 585 self.child_classes.update(subtree_classes) 586 587 elif not has_parent_query_difference and not has_target_query and target_module_object is None: 588 self.child_classes.add(target_class) 589 590 elif not has_target_query and is_single_parent and target_module_object is None: 591 self.child_classes.add(target_class) 592 593 url_path_object['object_rn'] = rn_builder 594 url_path_object['add_subtree_filter'] = add_subtree_filter 595 url_path_object['add_target_filter'] = add_target_filter 596 597 self._deep_url_path_builder(url_path_object) 598 599 def construct_url(self, root_class, subclass_1=None, subclass_2=None, subclass_3=None, child_classes=None): 600 """ 601 This method is used to retrieve the appropriate URL path and filter_string to make the request to the APIC. 602 603 :param root_class: The top-level class dictionary containing aci_class, aci_rn, target_filter, and module_object keys. 604 :param sublass_1: The second-level class dictionary containing aci_class, aci_rn, target_filter, and module_object keys. 605 :param sublass_2: The third-level class dictionary containing aci_class, aci_rn, target_filter, and module_object keys. 606 :param sublass_3: The fourth-level class dictionary containing aci_class, aci_rn, target_filter, and module_object keys. 607 :param child_classes: The list of child classes that the module supports along with the object. 608 :type root_class: dict 609 :type subclass_1: dict 610 :type subclass_2: dict 611 :type subclass_3: dict 612 :type child_classes: list 613 :return: The path and filter_string needed to build the full URL. 614 """ 615 self.filter_string = '' 616 617 if child_classes is None: 618 self.child_classes = set() 619 else: 620 self.child_classes = set(child_classes) 621 622 if subclass_3 is not None: 623 self._construct_url_4(root_class, subclass_1, subclass_2, subclass_3) 624 elif subclass_2 is not None: 625 self._construct_url_3(root_class, subclass_1, subclass_2) 626 elif subclass_1 is not None: 627 self._construct_url_2(root_class, subclass_1) 628 else: 629 self._construct_url_1(root_class) 630 631 if 'port' in self.params and self.params['port'] is not None: 632 self.url = '{protocol}://{host}:{port}/{path}'.format(path=self.path, **self.module.params) 633 else: 634 self.url = '{protocol}://{host}/{path}'.format(path=self.path, **self.module.params) 635 636 if self.child_classes: 637 # Append child_classes to filter_string if filter string is empty 638 self.update_qs({'rsp-subtree': 'full', 'rsp-subtree-class': ','.join(sorted(self.child_classes))}) 639 640 def _construct_url_1(self, obj): 641 """ 642 This method is used by construct_url when the object is the top-level class. 643 """ 644 obj_class = obj['aci_class'] 645 obj_rn = obj['aci_rn'] 646 obj_filter = obj['target_filter'] 647 mo = obj['module_object'] 648 649 if self.module.params['state'] in ('absent', 'present'): 650 # State is absent or present 651 self.path = 'api/mo/uni/{0}.json'.format(obj_rn) 652 self.update_qs({'rsp-prop-include': 'config-only'}) 653 elif mo is None: 654 # Query for all objects of the module's class (filter by properties) 655 self.path = 'api/class/{0}.json'.format(obj_class) 656 self.update_qs({'query-target-filter': self.build_filter(obj_class, obj_filter)}) 657 else: 658 # Query for a specific object in the module's class 659 self.path = 'api/mo/uni/{0}.json'.format(obj_rn) 660 661 def _construct_url_2(self, parent, obj): 662 """ 663 This method is used by construct_url when the object is the second-level class. 664 """ 665 parent_class = parent['aci_class'] 666 parent_rn = parent['aci_rn'] 667 parent_filter = parent['target_filter'] 668 parent_obj = parent['module_object'] 669 obj_class = obj['aci_class'] 670 obj_rn = obj['aci_rn'] 671 obj_filter = obj['target_filter'] 672 mo = obj['module_object'] 673 674 if self.module.params['state'] in ('absent', 'present'): 675 # State is absent or present 676 self.path = 'api/mo/uni/{0}/{1}.json'.format(parent_rn, obj_rn) 677 self.update_qs({'rsp-prop-include': 'config-only'}) 678 elif parent_obj is None and mo is None: 679 # Query for all objects of the module's class 680 self.path = 'api/class/{0}.json'.format(obj_class) 681 self.update_qs({'query-target-filter': self.build_filter(obj_class, obj_filter)}) 682 elif parent_obj is None: # mo is known 683 # Query for all objects of the module's class that match the provided ID value 684 self.path = 'api/class/{0}.json'.format(obj_class) 685 self.update_qs({'query-target-filter': self.build_filter(obj_class, obj_filter)}) 686 elif mo is None: # parent_obj is known 687 # Query for all object's of the module's class that belong to a specific parent object 688 self.child_classes.add(obj_class) 689 self.path = 'api/mo/uni/{0}.json'.format(parent_rn) 690 else: 691 # Query for specific object in the module's class 692 self.path = 'api/mo/uni/{0}/{1}.json'.format(parent_rn, obj_rn) 693 694 def _construct_url_3(self, root, parent, obj): 695 """ 696 This method is used by construct_url when the object is the third-level class. 697 """ 698 root_class = root['aci_class'] 699 root_rn = root['aci_rn'] 700 root_filter = root['target_filter'] 701 root_obj = root['module_object'] 702 parent_class = parent['aci_class'] 703 parent_rn = parent['aci_rn'] 704 parent_filter = parent['target_filter'] 705 parent_obj = parent['module_object'] 706 obj_class = obj['aci_class'] 707 obj_rn = obj['aci_rn'] 708 obj_filter = obj['target_filter'] 709 mo = obj['module_object'] 710 711 if self.module.params['state'] in ('absent', 'present'): 712 # State is absent or present 713 self.path = 'api/mo/uni/{0}/{1}/{2}.json'.format(root_rn, parent_rn, obj_rn) 714 self.update_qs({'rsp-prop-include': 'config-only'}) 715 elif root_obj is None and parent_obj is None and mo is None: 716 # Query for all objects of the module's class 717 self.path = 'api/class/{0}.json'.format(obj_class) 718 self.update_qs({'query-target-filter': self.build_filter(obj_class, obj_filter)}) 719 elif root_obj is None and parent_obj is None: # mo is known 720 # Query for all objects of the module's class matching the provided ID value of the object 721 self.path = 'api/class/{0}.json'.format(obj_class) 722 self.update_qs({'query-target-filter': self.build_filter(obj_class, obj_filter)}) 723 elif root_obj is None and mo is None: # parent_obj is known 724 # Query for all objects of the module's class that belong to any parent class 725 # matching the provided ID value for the parent object 726 self.child_classes.add(obj_class) 727 self.path = 'api/class/{0}.json'.format(parent_class) 728 self.update_qs({'query-target-filter': self.build_filter(parent_class, parent_filter)}) 729 elif parent_obj is None and mo is None: # root_obj is known 730 # Query for all objects of the module's class that belong to a specific root object 731 self.child_classes.update([parent_class, obj_class]) 732 self.path = 'api/mo/uni/{0}.json'.format(root_rn) 733 # NOTE: No need to select by root_filter 734 # self.update_qs({'query-target-filter': self.build_filter(root_class, root_filter)}) 735 elif root_obj is None: # mo and parent_obj are known 736 # Query for all objects of the module's class that belong to any parent class 737 # matching the provided ID values for both object and parent object 738 self.child_classes.add(obj_class) 739 self.path = 'api/class/{0}.json'.format(parent_class) 740 self.update_qs({'query-target-filter': self.build_filter(parent_class, parent_filter)}) 741 self.update_qs({'rsp-subtree-filter': self.build_filter(obj_class, obj_filter)}) 742 elif parent_obj is None: # mo and root_obj are known 743 # Query for all objects of the module's class that match the provided ID value and belong to a specific root object 744 self.child_classes.add(obj_class) 745 self.path = 'api/mo/uni/{0}.json'.format(root_rn) 746 # NOTE: No need to select by root_filter 747 # self.update_qs({'query-target-filter': self.build_filter(root_class, root_filter)}) 748 # TODO: Filter by parent_filter and obj_filter 749 self.update_qs({'rsp-subtree-filter': self.build_filter(obj_class, obj_filter)}) 750 elif mo is None: # root_obj and parent_obj are known 751 # Query for all objects of the module's class that belong to a specific parent object 752 self.child_classes.add(obj_class) 753 self.path = 'api/mo/uni/{0}/{1}.json'.format(root_rn, parent_rn) 754 # NOTE: No need to select by parent_filter 755 # self.update_qs({'query-target-filter': self.build_filter(parent_class, parent_filter)}) 756 else: 757 # Query for a specific object of the module's class 758 self.path = 'api/mo/uni/{0}/{1}/{2}.json'.format(root_rn, parent_rn, obj_rn) 759 760 def _construct_url_4(self, root, sec, parent, obj): 761 """ 762 This method is used by construct_url when the object is the fourth-level class. 763 """ 764 root_class = root['aci_class'] 765 root_rn = root['aci_rn'] 766 root_filter = root['target_filter'] 767 root_obj = root['module_object'] 768 sec_class = sec['aci_class'] 769 sec_rn = sec['aci_rn'] 770 sec_filter = sec['target_filter'] 771 sec_obj = sec['module_object'] 772 parent_class = parent['aci_class'] 773 parent_rn = parent['aci_rn'] 774 parent_filter = parent['target_filter'] 775 parent_obj = parent['module_object'] 776 obj_class = obj['aci_class'] 777 obj_rn = obj['aci_rn'] 778 obj_filter = obj['target_filter'] 779 mo = obj['module_object'] 780 781 if self.child_classes is None: 782 self.child_classes = [obj_class] 783 784 if self.module.params['state'] in ('absent', 'present'): 785 # State is absent or present 786 self.path = 'api/mo/uni/{0}/{1}/{2}/{3}.json'.format(root_rn, sec_rn, parent_rn, obj_rn) 787 self.update_qs({'rsp-prop-include': 'config-only'}) 788 # TODO: Add all missing cases 789 elif root_obj is None: 790 self.child_classes.add(obj_class) 791 self.path = 'api/class/{0}.json'.format(obj_class) 792 self.update_qs({'query-target-filter': self.build_filter(obj_class, obj_filter)}) 793 elif sec_obj is None: 794 self.child_classes.add(obj_class) 795 self.path = 'api/mo/uni/{0}.json'.format(root_rn) 796 # NOTE: No need to select by root_filter 797 # self.update_qs({'query-target-filter': self.build_filter(root_class, root_filter)}) 798 # TODO: Filter by sec_filter, parent and obj_filter 799 self.update_qs({'rsp-subtree-filter': self.build_filter(obj_class, obj_filter)}) 800 elif parent_obj is None: 801 self.child_classes.add(obj_class) 802 self.path = 'api/mo/uni/{0}/{1}.json'.format(root_rn, sec_rn) 803 # NOTE: No need to select by sec_filter 804 # self.update_qs({'query-target-filter': self.build_filter(sec_class, sec_filter)}) 805 # TODO: Filter by parent_filter and obj_filter 806 self.update_qs({'rsp-subtree-filter': self.build_filter(obj_class, obj_filter)}) 807 elif mo is None: 808 self.child_classes.add(obj_class) 809 self.path = 'api/mo/uni/{0}/{1}/{2}.json'.format(root_rn, sec_rn, parent_rn) 810 # NOTE: No need to select by parent_filter 811 # self.update_qs({'query-target-filter': self.build_filter(parent_class, parent_filter)}) 812 else: 813 # Query for a specific object of the module's class 814 self.path = 'api/mo/uni/{0}/{1}/{2}/{3}.json'.format(root_rn, sec_rn, parent_rn, obj_rn) 815 816 def delete_config(self): 817 """ 818 This method is used to handle the logic when the modules state is equal to absent. The method only pushes a change if 819 the object exists, and if check_mode is False. A successful change will mark the module as changed. 820 """ 821 self.proposed = dict() 822 823 if not self.existing: 824 return 825 826 elif not self.module.check_mode: 827 # Sign and encode request as to APIC's wishes 828 if self.params['private_key']: 829 self.cert_auth(method='DELETE') 830 831 resp, info = fetch_url(self.module, self.url, 832 headers=self.headers, 833 method='DELETE', 834 timeout=self.params['timeout'], 835 use_proxy=self.params['use_proxy']) 836 837 self.response = info['msg'] 838 self.status = info['status'] 839 self.method = 'DELETE' 840 841 # Handle APIC response 842 if info['status'] == 200: 843 self.result['changed'] = True 844 self.response_json(resp.read()) 845 else: 846 try: 847 # APIC error 848 self.response_json(info['body']) 849 self.fail_json(msg='APIC Error %(code)s: %(text)s' % self.error) 850 except KeyError: 851 # Connection error 852 self.fail_json(msg='Connection failed for %(url)s. %(msg)s' % info) 853 else: 854 self.result['changed'] = True 855 self.method = 'DELETE' 856 857 def get_diff(self, aci_class): 858 """ 859 This method is used to get the difference between the proposed and existing configurations. Each module 860 should call the get_existing method before this method, and add the proposed config to the module results 861 using the module's config parameters. The new config will added to the self.result dictionary. 862 863 :param aci_class: Type str. 864 This is the root dictionary key for the MO's configuration body, or the ACI class of the MO. 865 """ 866 proposed_config = self.proposed[aci_class]['attributes'] 867 if self.existing: 868 existing_config = self.existing[0][aci_class]['attributes'] 869 config = {} 870 871 # values are strings, so any diff between proposed and existing can be a straight replace 872 for key, value in proposed_config.items(): 873 existing_field = existing_config.get(key) 874 if value != existing_field: 875 config[key] = value 876 877 # add name back to config only if the configs do not match 878 if config: 879 # TODO: If URLs are built with the object's name, then we should be able to leave off adding the name back 880 # config["name"] = proposed_config["name"] 881 config = {aci_class: {'attributes': config}} 882 883 # check for updates to child configs and update new config dictionary 884 children = self.get_diff_children(aci_class) 885 if children and config: 886 config[aci_class].update({'children': children}) 887 elif children: 888 config = {aci_class: {'attributes': {}, 'children': children}} 889 890 else: 891 config = self.proposed 892 893 self.config = config 894 895 @staticmethod 896 def get_diff_child(child_class, proposed_child, existing_child): 897 """ 898 This method is used to get the difference between a proposed and existing child configs. The get_nested_config() 899 method should be used to return the proposed and existing config portions of child. 900 901 :param child_class: Type str. 902 The root class (dict key) for the child dictionary. 903 :param proposed_child: Type dict. 904 The config portion of the proposed child dictionary. 905 :param existing_child: Type dict. 906 The config portion of the existing child dictionary. 907 :return: The child config with only values that are updated. If the proposed dictionary has no updates to make 908 to what exists on the APIC, then None is returned. 909 """ 910 update_config = {child_class: {'attributes': {}}} 911 for key, value in proposed_child.items(): 912 existing_field = existing_child.get(key) 913 if value != existing_field: 914 update_config[child_class]['attributes'][key] = value 915 916 if not update_config[child_class]['attributes']: 917 return None 918 919 return update_config 920 921 def get_diff_children(self, aci_class): 922 """ 923 This method is used to retrieve the updated child configs by comparing the proposed children configs 924 agains the objects existing children configs. 925 926 :param aci_class: Type str. 927 This is the root dictionary key for the MO's configuration body, or the ACI class of the MO. 928 :return: The list of updated child config dictionaries. None is returned if there are no changes to the child 929 configurations. 930 """ 931 proposed_children = self.proposed[aci_class].get('children') 932 if proposed_children: 933 child_updates = [] 934 existing_children = self.existing[0][aci_class].get('children', []) 935 936 # Loop through proposed child configs and compare against existing child configuration 937 for child in proposed_children: 938 child_class, proposed_child, existing_child = self.get_nested_config(child, existing_children) 939 940 if existing_child is None: 941 child_update = child 942 else: 943 child_update = self.get_diff_child(child_class, proposed_child, existing_child) 944 945 # Update list of updated child configs only if the child config is different than what exists 946 if child_update: 947 child_updates.append(child_update) 948 else: 949 return None 950 951 return child_updates 952 953 def get_existing(self): 954 """ 955 This method is used to get the existing object(s) based on the path specified in the module. Each module should 956 build the URL so that if the object's name is supplied, then it will retrieve the configuration for that particular 957 object, but if no name is supplied, then it will retrieve all MOs for the class. Following this method will ensure 958 that this method can be used to supply the existing configuration when using the get_diff method. The response, status, 959 and existing configuration will be added to the self.result dictionary. 960 """ 961 uri = self.url + self.filter_string 962 963 # Sign and encode request as to APIC's wishes 964 if self.params['private_key']: 965 self.cert_auth(path=self.path + self.filter_string, method='GET') 966 967 resp, info = fetch_url(self.module, uri, 968 headers=self.headers, 969 method='GET', 970 timeout=self.params['timeout'], 971 use_proxy=self.params['use_proxy']) 972 self.response = info['msg'] 973 self.status = info['status'] 974 self.method = 'GET' 975 976 # Handle APIC response 977 if info['status'] == 200: 978 self.existing = json.loads(resp.read())['imdata'] 979 else: 980 try: 981 # APIC error 982 self.response_json(info['body']) 983 self.fail_json(msg='APIC Error %(code)s: %(text)s' % self.error) 984 except KeyError: 985 # Connection error 986 self.fail_json(msg='Connection failed for %(url)s. %(msg)s' % info) 987 988 @staticmethod 989 def get_nested_config(proposed_child, existing_children): 990 """ 991 This method is used for stiping off the outer layers of the child dictionaries so only the configuration 992 key, value pairs are returned. 993 994 :param proposed_child: Type dict. 995 The dictionary that represents the child config. 996 :param existing_children: Type list. 997 The list of existing child config dictionaries. 998 :return: The child's class as str (root config dict key), the child's proposed config dict, and the child's 999 existing configuration dict. 1000 """ 1001 for key in proposed_child.keys(): 1002 child_class = key 1003 proposed_config = proposed_child[key]['attributes'] 1004 existing_config = None 1005 1006 # FIXME: Design causes issues for repeated child_classes 1007 # get existing dictionary from the list of existing to use for comparison 1008 for child in existing_children: 1009 if child.get(child_class): 1010 existing_config = child[key]['attributes'] 1011 # NOTE: This is an ugly fix 1012 # Return the one that is a subset match 1013 if set(proposed_config.items()).issubset(set(existing_config.items())): 1014 break 1015 1016 return child_class, proposed_config, existing_config 1017 1018 def payload(self, aci_class, class_config, child_configs=None): 1019 """ 1020 This method is used to dynamically build the proposed configuration dictionary from the config related parameters 1021 passed into the module. All values that were not passed values from the playbook task will be removed so as to not 1022 inadvertently change configurations. 1023 1024 :param aci_class: Type str 1025 This is the root dictionary key for the MO's configuration body, or the ACI class of the MO. 1026 :param class_config: Type dict 1027 This is the configuration of the MO using the dictionary keys expected by the API 1028 :param child_configs: Type list 1029 This is a list of child dictionaries associated with the MOs config. The list should only 1030 include child objects that are used to associate two MOs together. Children that represent 1031 MOs should have their own module. 1032 """ 1033 proposed = dict((k, str(v)) for k, v in class_config.items() if v is not None) 1034 self.proposed = {aci_class: {'attributes': proposed}} 1035 1036 # add child objects to proposed 1037 if child_configs: 1038 children = [] 1039 for child in child_configs: 1040 child_copy = deepcopy(child) 1041 has_value = False 1042 for root_key in child_copy.keys(): 1043 for final_keys, values in child_copy[root_key]['attributes'].items(): 1044 if values is None: 1045 child[root_key]['attributes'].pop(final_keys) 1046 else: 1047 child[root_key]['attributes'][final_keys] = str(values) 1048 has_value = True 1049 if has_value: 1050 children.append(child) 1051 1052 if children: 1053 self.proposed[aci_class].update(dict(children=children)) 1054 1055 def post_config(self): 1056 """ 1057 This method is used to handle the logic when the modules state is equal to present. The method only pushes a change if 1058 the object has differences than what exists on the APIC, and if check_mode is False. A successful change will mark the 1059 module as changed. 1060 """ 1061 if not self.config: 1062 return 1063 elif not self.module.check_mode: 1064 # Sign and encode request as to APIC's wishes 1065 if self.params['private_key']: 1066 self.cert_auth(method='POST', payload=json.dumps(self.config)) 1067 1068 resp, info = fetch_url(self.module, self.url, 1069 data=json.dumps(self.config), 1070 headers=self.headers, 1071 method='POST', 1072 timeout=self.params['timeout'], 1073 use_proxy=self.params['use_proxy']) 1074 1075 self.response = info['msg'] 1076 self.status = info['status'] 1077 self.method = 'POST' 1078 1079 # Handle APIC response 1080 if info['status'] == 200: 1081 self.result['changed'] = True 1082 self.response_json(resp.read()) 1083 else: 1084 try: 1085 # APIC error 1086 self.response_json(info['body']) 1087 self.fail_json(msg='APIC Error %(code)s: %(text)s' % self.error) 1088 except KeyError: 1089 # Connection error 1090 self.fail_json(msg='Connection failed for %(url)s. %(msg)s' % info) 1091 else: 1092 self.result['changed'] = True 1093 self.method = 'POST' 1094 1095 def exit_json(self, **kwargs): 1096 1097 if 'state' in self.params: 1098 if self.params['state'] in ('absent', 'present'): 1099 if self.params['output_level'] in ('debug', 'info'): 1100 self.result['previous'] = self.existing 1101 1102 # Return the gory details when we need it 1103 if self.params['output_level'] == 'debug': 1104 if 'state' in self.params: 1105 self.result['filter_string'] = self.filter_string 1106 self.result['method'] = self.method 1107 # self.result['path'] = self.path # Adding 'path' in result causes state: absent in output 1108 self.result['response'] = self.response 1109 self.result['status'] = self.status 1110 self.result['url'] = self.url 1111 1112 if 'state' in self.params: 1113 self.original = self.existing 1114 if self.params['state'] in ('absent', 'present'): 1115 self.get_existing() 1116 1117 # if self.module._diff and self.original != self.existing: 1118 # self.result['diff'] = dict( 1119 # before=json.dumps(self.original, sort_keys=True, indent=4), 1120 # after=json.dumps(self.existing, sort_keys=True, indent=4), 1121 # ) 1122 self.result['current'] = self.existing 1123 1124 if self.params['output_level'] in ('debug', 'info'): 1125 self.result['sent'] = self.config 1126 self.result['proposed'] = self.proposed 1127 1128 self.result.update(**kwargs) 1129 self.module.exit_json(**self.result) 1130 1131 def fail_json(self, msg, **kwargs): 1132 1133 # Return error information, if we have it 1134 if self.error['code'] is not None and self.error['text'] is not None: 1135 self.result['error'] = self.error 1136 1137 if 'state' in self.params: 1138 if self.params['state'] in ('absent', 'present'): 1139 if self.params['output_level'] in ('debug', 'info'): 1140 self.result['previous'] = self.existing 1141 1142 # Return the gory details when we need it 1143 if self.params['output_level'] == 'debug': 1144 if self.imdata is not None: 1145 self.result['imdata'] = self.imdata 1146 self.result['totalCount'] = self.totalCount 1147 1148 if self.params['output_level'] == 'debug': 1149 if self.url is not None: 1150 if 'state' in self.params: 1151 self.result['filter_string'] = self.filter_string 1152 self.result['method'] = self.method 1153 # self.result['path'] = self.path # Adding 'path' in result causes state: absent in output 1154 self.result['response'] = self.response 1155 self.result['status'] = self.status 1156 self.result['url'] = self.url 1157 1158 if 'state' in self.params: 1159 if self.params['output_level'] in ('debug', 'info'): 1160 self.result['sent'] = self.config 1161 self.result['proposed'] = self.proposed 1162 1163 self.result.update(**kwargs) 1164 self.module.fail_json(msg=msg, **self.result) 1165