#!/usr/bin/python # -*- coding: utf-8 -*- # Copyright (c) 2017, Dag Wieers # GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) # SPDX-License-Identifier: GPL-3.0-or-later from __future__ import absolute_import, division, print_function __metaclass__ = type DOCUMENTATION = r''' --- module: imc_rest short_description: Manage Cisco IMC hardware through its REST API description: - Provides direct access to the Cisco IMC REST API. - Perform any configuration changes and actions that the Cisco IMC supports. - More information about the IMC REST API is available from U(http://www.cisco.com/c/en/us/td/docs/unified_computing/ucs/c/sw/api/3_0/b_Cisco_IMC_api_301.html). author: - Dag Wieers (@dagwieers) requirements: - lxml - xmljson >= 0.1.8 extends_documentation_fragment: - community.general.attributes attributes: check_mode: support: full diff_mode: support: none options: hostname: description: - IP Address or hostname of Cisco IMC, resolvable by Ansible control host. required: true aliases: [ host, ip ] type: str username: description: - Username used to login to the switch. default: admin aliases: [ user ] type: str password: description: - The password to use for authentication. default: password type: str path: description: - Name of the absolute path of the filename that includes the body of the http request being sent to the Cisco IMC REST API. - Parameter O(path) is mutual exclusive with parameter O(content). aliases: [ 'src', 'config_file' ] type: path content: description: - When used instead of O(path), sets the content of the API requests directly. - This may be convenient to template simple requests, for anything complex use the M(ansible.builtin.template) module. - You can collate multiple IMC XML fragments and they will be processed sequentially in a single stream, the Cisco IMC output is subsequently merged. - Parameter O(content) is mutual exclusive with parameter O(path). type: str protocol: description: - Connection protocol to use. default: https choices: [ http, https ] type: str timeout: description: - The socket level timeout in seconds. - This is the time that every single connection (every fragment) can spend. If this O(timeout) is reached, the module will fail with a C(Connection failure) indicating that C(The read operation timed out). default: 60 type: int validate_certs: description: - If V(false), SSL certificates will not be validated. - This should only set to V(false) used on personally controlled sites using self-signed certificates. type: bool default: true notes: - The XML fragments don't need an authentication cookie, this is injected by the module automatically. - The Cisco IMC XML output is being translated to JSON using the Cobra convention. - Any configConfMo change requested has a return status of 'modified', even if there was no actual change from the previous configuration. As a result, this module will always report a change on subsequent runs. In case this behaviour is fixed in a future update to Cisco IMC, this module will automatically adapt. - If you get a C(Connection failure) related to C(The read operation timed out) increase the O(timeout) parameter. Some XML fragments can take longer than the default timeout. - More information about the IMC REST API is available from U(http://www.cisco.com/c/en/us/td/docs/unified_computing/ucs/c/sw/api/3_0/b_Cisco_IMC_api_301.html) ''' EXAMPLES = r''' - name: Power down server community.general.imc_rest: hostname: '{{ imc_hostname }}' username: '{{ imc_username }}' password: '{{ imc_password }}' validate_certs: false # only do this when you trust the network! content: | delegate_to: localhost - name: Configure IMC using multiple XML fragments community.general.imc_rest: hostname: '{{ imc_hostname }}' username: '{{ imc_username }}' password: '{{ imc_password }}' validate_certs: false # only do this when you trust the network! timeout: 120 content: | delegate_to: localhost - name: Enable PXE boot and power-cycle server community.general.imc_rest: hostname: '{{ imc_hostname }}' username: '{{ imc_username }}' password: '{{ imc_password }}' validate_certs: false # only do this when you trust the network! content: | delegate_to: localhost - name: Reconfigure IMC to boot from storage community.general.imc_rest: hostname: '{{ imc_host }}' username: '{{ imc_username }}' password: '{{ imc_password }}' validate_certs: false # only do this when you trust the network! content: | delegate_to: localhost - name: Add customer description to server community.general.imc_rest: hostname: '{{ imc_host }}' username: '{{ imc_username }}' password: '{{ imc_password }}' validate_certs: false # only do this when you trust the network! content: | delegate_to: localhost - name: Disable HTTP and increase session timeout to max value 10800 secs community.general.imc_rest: hostname: '{{ imc_host }}' username: '{{ imc_username }}' password: '{{ imc_password }}' validate_certs: false # only do this when you trust the network! timeout: 120 content: | delegate_to: localhost ''' RETURN = r''' aaLogin: description: Cisco IMC XML output for the login, translated to JSON using Cobra convention returned: success type: dict sample: | "attributes": { "cookie": "", "outCookie": "1498902428/9de6dc36-417c-157c-106c-139efe2dc02a", "outPriv": "admin", "outRefreshPeriod": "600", "outSessionId": "114", "outVersion": "2.0(13e)", "response": "yes" } configConfMo: description: Cisco IMC XML output for any configConfMo XML fragments, translated to JSON using Cobra convention returned: success type: dict sample: | elapsed: description: Elapsed time in seconds returned: always type: int sample: 31 response: description: HTTP response message, including content length returned: always type: str sample: OK (729 bytes) status: description: The HTTP response status code returned: always type: dict sample: 200 error: description: Cisco IMC XML error output for last request, translated to JSON using Cobra convention returned: failed type: dict sample: | "attributes": { "cookie": "", "errorCode": "ERR-xml-parse-error", "errorDescr": "XML PARSING ERROR: Element 'computeRackUnit', attribute 'admin_Power': The attribute 'admin_Power' is not allowed. ", "invocationResult": "594", "response": "yes" } error_code: description: Cisco IMC error code returned: failed type: str sample: ERR-xml-parse-error error_text: description: Cisco IMC error message returned: failed type: str sample: | XML PARSING ERROR: Element 'computeRackUnit', attribute 'admin_Power': The attribute 'admin_Power' is not allowed. input: description: RAW XML input sent to the Cisco IMC, causing the error returned: failed type: str sample: | output: description: RAW XML output received from the Cisco IMC, with error details returned: failed type: str sample: > ''' import os import traceback LXML_ETREE_IMP_ERR = None try: import lxml.etree HAS_LXML_ETREE = True except ImportError: LXML_ETREE_IMP_ERR = traceback.format_exc() HAS_LXML_ETREE = False XMLJSON_COBRA_IMP_ERR = None try: from xmljson import cobra HAS_XMLJSON_COBRA = True except ImportError: XMLJSON_COBRA_IMP_ERR = traceback.format_exc() HAS_XMLJSON_COBRA = False from ansible.module_utils.basic import AnsibleModule, missing_required_lib from ansible.module_utils.six.moves import zip_longest from ansible.module_utils.urls import fetch_url from ansible_collections.community.general.plugins.module_utils.datetime import ( now, ) def imc_response(module, rawoutput, rawinput=''): ''' Handle IMC returned data ''' xmloutput = lxml.etree.fromstring(rawoutput) result = cobra.data(xmloutput) # Handle errors if xmloutput.get('errorCode') and xmloutput.get('errorDescr'): if rawinput: result['input'] = rawinput result['output'] = rawoutput result['error_code'] = xmloutput.get('errorCode') result['error_text'] = xmloutput.get('errorDescr') module.fail_json(msg='Request failed: %(error_text)s' % result, **result) return result def logout(module, url, cookie, timeout): ''' Perform a logout, if needed ''' data = '' % (cookie, cookie) resp, auth = fetch_url(module, url, data=data, method="POST", timeout=timeout) def merge(one, two): ''' Merge two complex nested datastructures into one''' if isinstance(one, dict) and isinstance(two, dict): copy = dict(one) # copy.update({key: merge(one.get(key, None), two[key]) for key in two}) copy.update(dict((key, merge(one.get(key, None), two[key])) for key in two)) return copy elif isinstance(one, list) and isinstance(two, list): return [merge(alpha, beta) for (alpha, beta) in zip_longest(one, two)] return one if two is None else two def main(): module = AnsibleModule( argument_spec=dict( hostname=dict(type='str', required=True, aliases=['host', 'ip']), username=dict(type='str', default='admin', aliases=['user']), password=dict(type='str', default='password', no_log=True), content=dict(type='str'), path=dict(type='path', aliases=['config_file', 'src']), protocol=dict(type='str', default='https', choices=['http', 'https']), timeout=dict(type='int', default=60), validate_certs=dict(type='bool', default=True), ), supports_check_mode=True, mutually_exclusive=[['content', 'path']], ) if not HAS_LXML_ETREE: module.fail_json(msg=missing_required_lib('lxml'), exception=LXML_ETREE_IMP_ERR) if not HAS_XMLJSON_COBRA: module.fail_json(msg=missing_required_lib('xmljson >= 0.1.8'), exception=XMLJSON_COBRA_IMP_ERR) hostname = module.params['hostname'] username = module.params['username'] password = module.params['password'] content = module.params['content'] path = module.params['path'] protocol = module.params['protocol'] timeout = module.params['timeout'] result = dict( failed=False, changed=False, ) # Report missing file file_exists = False if path: if os.path.isfile(path): file_exists = True else: module.fail_json(msg='Cannot find/access path:\n%s' % path) start = now() # Perform login first url = '%s://%s/nuova' % (protocol, hostname) data = '' % (username, password) resp, auth = fetch_url(module, url, data=data, method='POST', timeout=timeout) if resp is None or auth['status'] != 200: result['elapsed'] = (now() - start).seconds module.fail_json(msg='Task failed with error %(status)s: %(msg)s' % auth, **result) result.update(imc_response(module, resp.read())) # Store cookie for future requests cookie = '' try: cookie = result['aaaLogin']['attributes']['outCookie'] except Exception: module.fail_json(msg='Could not find cookie in output', **result) try: # Prepare request data if content: rawdata = content elif file_exists: with open(path, 'r') as config_object: rawdata = config_object.read() # Wrap the XML documents in a element xmldata = lxml.etree.fromstring('%s' % rawdata.replace('\n', '')) # Handle each XML document separately in the same session for xmldoc in list(xmldata): if xmldoc.tag is lxml.etree.Comment: continue # Add cookie to XML xmldoc.set('cookie', cookie) data = lxml.etree.tostring(xmldoc) # Perform actual request resp, info = fetch_url(module, url, data=data, method='POST', timeout=timeout) if resp is None or info['status'] != 200: result['elapsed'] = (now() - start).seconds module.fail_json(msg='Task failed with error %(status)s: %(msg)s' % info, **result) # Merge results with previous results rawoutput = resp.read() result = merge(result, imc_response(module, rawoutput, rawinput=data)) result['response'] = info['msg'] result['status'] = info['status'] # Check for any changes # NOTE: Unfortunately IMC API always report status as 'modified' xmloutput = lxml.etree.fromstring(rawoutput) results = xmloutput.xpath('/configConfMo/outConfig/*/@status') result['changed'] = ('modified' in results) # Report success result['elapsed'] = (now() - start).seconds module.exit_json(**result) finally: logout(module, url, cookie, timeout) if __name__ == '__main__': main()