diff --git a/lib/ansible/module_utils/network/aci/msc.py b/lib/ansible/module_utils/network/aci/msc.py new file mode 100644 index 0000000000..99e159ffa8 --- /dev/null +++ b/lib/ansible/module_utils/network/aci/msc.py @@ -0,0 +1,299 @@ +# -*- coding: utf-8 -*- + +# This code is part of Ansible, but is an independent component + +# This particular file snippet, and this file snippet only, is BSD licensed. +# Modules you write using this snippet, which is embedded dynamically by Ansible +# still belong to the author of the module, and may assign their own license +# to the complete work. + +# Copyright: (c) 2018, Dag Wieers +# All rights reserved. + +# Redistribution and use in source and binary forms, with or without modification, +# are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. +# IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +# INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE +# USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +from copy import deepcopy +from ansible.module_utils.basic import AnsibleModule, json +from ansible.module_utils.six.moves.urllib.parse import urljoin +from ansible.module_utils.urls import fetch_url +from ansible.module_utils._text import to_native, to_bytes + + +def issubset(subset, superset): + ''' Recurse through nested dictionary and compare entries ''' + if subset is superset: + return True + + if subset == superset: + return True + + for key, value in subset.items(): + if key not in superset: + return False + elif isinstance(value, str): + if value != superset[key]: + return False + elif isinstance(value, dict): + if not issubset(superset[key], value): + return False + elif isinstance(value, list): + if not set(value) <= set(superset[key]): + return False + elif isinstance(value, set): + if not value <= superset[key]: + return False + else: + if not value == superset[key]: + return False + return True + + +def msc_argument_spec(): + return dict( + host=dict(type='str', required=True, aliases=['hostname']), + port=dict(type='int', required=False), + username=dict(type='str', default='admin'), + password=dict(type='str', required=True, no_log=True), + output_level=dict(type='str', default='normal', choices=['normal', 'info', 'debug']), + timeout=dict(type='int', default=30), + use_proxy=dict(type='bool', default=True), + use_ssl=dict(type='bool', default=True), + validate_certs=dict(type='bool', default=True), + ) + + +class MSCModule(object): + + def __init__(self, module): + self.module = module + self.params = module.params + self.result = dict(changed=False) + self.headers = {'Content-Type': 'text/json'} + + # normal output + self.existing = dict() + + # info output + self.previous = dict() + self.proposed = dict() + self.sent = dict() + + # debug output + self.filter_string = '' + self.method = None + self.path = None + self.response = None + self.status = None + self.url = None + + # Ensure protocol is set + self.params['protocol'] = 'https' if self.params.get('use_ssl', True) else 'http' + + # Set base_uri + if 'port' in self.params and self.params['port'] is not None: + self.baseuri = '{protocol}://{host}:{port}/api/v1/'.format(**self.params) + else: + self.baseuri = '{protocol}://{host}/api/v1/'.format(**self.params) + + if self.module._debug: + self.module.warn('Enable debug output because ANSIBLE_DEBUG was set.') + self.params['output_level'] = 'debug' + + if self.params['password']: + # Perform password-based authentication, log on using password + self.login() + else: + self.module.fail_json(msg="Parameter 'password' is required for authentication") + + def login(self): + ''' Log in to MSC ''' + + # Perform login request + self.url = urljoin(self.baseuri, 'auth/login') + payload = {'username': self.params['username'], 'password': self.params['password']} + resp, auth = fetch_url(self.module, + self.url, + data=json.dumps(payload), + method='POST', + headers=self.headers, + timeout=self.params['timeout'], + use_proxy=self.params['use_proxy']) + + # Handle MSC response + if auth['status'] != 201: + self.response = auth['msg'] + self.status = auth['status'] + self.fail_json(msg='Authentication failed: {msg}'.format(**auth)) + + payload = json.loads(resp.read()) + + self.headers['Authorization'] = 'Bearer {token}'.format(**payload) + + def request(self, path, method=None, data=None): + ''' Generic HTTP method for MSC requests. ''' + self.path = path + + if method is not None: + self.method = method + + self.url = urljoin(self.baseuri, path) + resp, info = fetch_url(self.module, + self.url, + headers=self.headers, + data=json.dumps(data), + method=self.method, + timeout=self.params['timeout'], + use_proxy=self.params['use_proxy'], + ) + self.response = info['msg'] + self.status = info['status'] + + # 200: OK, 201: Created, 202: Accepted, 204: No Content + if self.status in (200, 201, 202, 204): + output = resp.read() + if self.method in ('DELETE', 'PATCH', 'POST', 'PUT') and self.status in (200, 201, 204): + self.result['changed'] = True + if output: + return json.loads(output) + + # 404: Not Found + elif self.method == 'DELETE' and self.status == 404: + return {} + + # 400: Bad Request, 401: Unauthorized, 403: Forbidden, + # 405: Method Not Allowed, 406: Not Acceptable + # 500: Internal Server Error, 501: Not Implemented + elif self.status >= 400: + try: + payload = json.loads(resp.read()) + except: + payload = json.loads(info['body']) + if 'code' in payload: + self.fail_json(msg='MSC Error {code}: {message} [{info}]'.format(**payload), payload=data) + else: + self.fail_json(msg='MSC Error:'.format(**payload), info=info, output=output) + + return {} + + def query_objs(self, path, **kwargs): + found = [] + objs = self.request(path, method='GET') + for obj in objs[path]: + for key in kwargs.keys(): + if kwargs[key] is None: + continue + if obj[key] != kwargs[key]: + break + else: + found.append(obj) + return found + + def get_obj(self, path, **kwargs): + objs = self.query_objs(path, **kwargs) + if len(objs) == 0: + return {} + if len(objs) > 1: + self.fail_json('More than one object matches unique filter: {1}'.format(kwargs)) + return objs[0] + + def sanitize(self, updates, collate=False): + self.proposed = deepcopy(self.existing) + self.sent = deepcopy(self.existing) + + # Clean up self.sent + for key in updates: + # Always retain 'id' + if key in ('id'): + pass + + # Remove unspecified values + elif updates[key] is None: + if key in self.existing: + del(self.sent[key]) + continue + + # Remove identical values + elif not collate and key in self.existing and updates[key] == self.existing[key]: + del(self.sent[key]) + continue + + # Add everything else + if updates[key] is not None: + self.sent[key] = updates[key] + + # Update self.proposed + self.proposed.update(self.sent) + + def exit_json(self, **kwargs): + ''' Custom written method to exit from module. ''' + + if self.params['state'] in ('absent', 'present'): + if self.params['output_level'] in ('debug', 'info'): + self.result['previous'] = self.previous + if self.previous != self.existing: + self.result['changed'] = True + + # Return the gory details when we need it + if self.params['output_level'] == 'debug': + self.result['method'] = self.method + self.result['response'] = self.response + self.result['status'] = self.status + self.result['url'] = self.url + + if self.params['state'] in ('absent', 'present'): + self.result['sent'] = self.sent + self.result['proposed'] = self.proposed + + self.result['current'] = self.existing + + if self.module._diff: + self.result['diff'] = dict( + before=self.existing, + after=self.sent, + ) + + self.result.update(**kwargs) + self.module.exit_json(**self.result) + + def fail_json(self, msg, **kwargs): + ''' Custom written method to return info on failure. ''' + + if self.params['state'] in ('absent', 'present'): + if self.params['output_level'] in ('debug', 'info'): + self.result['previous'] = self.previous + if self.previous != self.existing: + self.result['changed'] = True + + # Return the gory details when we need it + if self.params['output_level'] == 'debug': + if self.url is not None: + self.result['method'] = self.method + self.result['response'] = self.response + self.result['status'] = self.status + self.result['url'] = self.url + + if self.params['state'] in ('absent', 'present'): + self.result['sent'] = self.sent + self.result['proposed'] = self.proposed + + self.result['current'] = self.existing + + self.result.update(**kwargs) + self.module.fail_json(msg=msg, **self.result) diff --git a/lib/ansible/utils/module_docs_fragments/msc.py b/lib/ansible/utils/module_docs_fragments/msc.py new file mode 100644 index 0000000000..ac387c2de4 --- /dev/null +++ b/lib/ansible/utils/module_docs_fragments/msc.py @@ -0,0 +1,60 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2018, Dag Wieers (@dagwieers) +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + + +class ModuleDocFragment(object): + # Standard files documentation fragment + DOCUMENTATION = ''' +options: + host: + description: + - IP Address or hostname of ACI Multi-Site host. + required: yes + aliases: [ hostname ] + port: + description: + - Port number to be used for REST connection. + - The default value depends on parameter `use_ssl`. + username: + description: + - The username to use for authentication. + default: admin + password: + description: + - The password to use for authentication. + - This option is mutual exclusive with C(private_key). If C(private_key) is provided too, it will be used instead. + required: yes + output_level: + description: + - Influence the output of this ACI module. + - C(normal) means the standard output, incl. C(current) dict + - C(info) adds informational output, incl. C(previous), C(proposed) and C(sent) dicts + - C(debug) adds debugging output, incl. C(filter_string), C(method), C(response), C(status) and C(url) information + choices: [ debug, info, normal ] + default: normal + timeout: + description: + - The socket level timeout in seconds. + type: int + default: 30 + use_proxy: + description: + - If C(no), it will not use a proxy, even if one is defined in an environment variable on the target hosts. + type: bool + default: 'yes' + use_ssl: + description: + - If C(no), an HTTP connection will be used instead of the default HTTPS connection. + type: bool + default: 'yes' + validate_certs: + description: + - If C(no), SSL certificates will not be validated. + - This should only set to C(no) when used on personally controlled sites using self-signed certificates. + type: bool + default: 'yes' +notes: +- Please read the :ref:`aci_guide` for more detailed information on how to manage your ACI infrastructure using Ansible. +''' diff --git a/test/integration/inventory b/test/integration/inventory index 55cd4bc113..11a709dee0 100644 --- a/test/integration/inventory +++ b/test/integration/inventory @@ -51,7 +51,7 @@ overridden_in_parent=2000 [aci:vars] aci_hostname=your-apic-1 aci_username=admin -aci_password=your-password +aci_password=your-apic-password aci_validate_certs=no aci_use_ssl=yes aci_use_proxy=no @@ -59,6 +59,17 @@ aci_use_proxy=no [aci] localhost ansible_ssh_host=127.0.0.1 ansible_connection=local +[msc:vars] +msc_hostname=your-msc-1 +msc_username=admin +msc_password=your-msc-password +msc_validate_certs=no +msc_use_ssl=yes +msc_use_proxy=no + +[msc] +localhost ansible_ssh_host=127.0.0.1 ansible_connection=local + [amazon] localhost ansible_ssh_host=127.0.0.1 ansible_connection=local