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