From e970237a2f288c9363b0c012e53c01f20f6b6a74 Mon Sep 17 00:00:00 2001 From: Dag Wieers Date: Mon, 17 Jul 2017 17:32:12 +0200 Subject: [PATCH] New module: access Cisco ACI (network/aci/aci_rest) (#26029) * aci_rest: New module to access Cisco ACI This PR includes: - Relicense as GPLv3+ - Check-mode support - Cosmetic changes to documentation - Examples in YAML format - Removal of incorrect requirements (for this module) - Do not log passwords - Implement native fetch_url instead of requests - Use standard hostname, username and password parameters - Add alias src for parameter config_file - Add mutual exclusive content option for inline data (and show some inline examples) - Add timeout parameter - Add validate_certs parameter - Handling ACI result output (identical for JSON as XML input) - Parse/expose ACI error output to user * Lower case method, add use_ssl, Use python dicts This commit includes: - Use lowercase method names - Add `use_ssl` parameter (not the `protocol` parameter) - Use a python dict for the request data (not a JSON string) - Documentation improvements * Ensure one of 'content' or 'src' is provided * Fix issue with totalCount being a string in JSON This fixes the problem with JSON output where totalCount is a string and not an integer. This fixes jedelman8/aci-ansible#7 * Improve code documentation * Improve error handling and module response * Small typo * Improve documentation and examples * Keep protocol parameter, but deprecate it * Extrude aci functions from module_utils * aci_rest: Add unit tests --- .github/BOTMETA.yml | 1 + lib/ansible/modules/network/aci/__init__.py | 0 lib/ansible/modules/network/aci/aci_rest.py | 395 ++++++++++++++++++ .../utils/module_docs_fragments/aci.py | 55 +++ test/runner/requirements/units.txt | 3 + .../modules/network/aci/test_aci_rest.py | 299 +++++++++++++ 6 files changed, 753 insertions(+) create mode 100644 lib/ansible/modules/network/aci/__init__.py create mode 100644 lib/ansible/modules/network/aci/aci_rest.py create mode 100644 lib/ansible/utils/module_docs_fragments/aci.py create mode 100644 test/units/modules/network/aci/test_aci_rest.py diff --git a/.github/BOTMETA.yml b/.github/BOTMETA.yml index b5898e1df2..fd8dd65880 100644 --- a/.github/BOTMETA.yml +++ b/.github/BOTMETA.yml @@ -429,6 +429,7 @@ files: $modules/net_tools/omapi_host.py: nerzhul $modules/net_tools/snmp_facts.py: ogenstad $modules/network/a10/: ericchou1 mischapeters + $modules/network/aci/: dagwieers jedelman8 $modules/network/aos/: dgarros jeremyschulman $modules/network/asa/asa_acl.py: gundalow ogenstad $modules/network/asa/asa_command.py: gundalow ogenstad privateip diff --git a/lib/ansible/modules/network/aci/__init__.py b/lib/ansible/modules/network/aci/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lib/ansible/modules/network/aci/aci_rest.py b/lib/ansible/modules/network/aci/aci_rest.py new file mode 100644 index 0000000000..2333a1cd98 --- /dev/null +++ b/lib/ansible/modules/network/aci/aci_rest.py @@ -0,0 +1,395 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright 2015 Jason Edelman , Network to Code, LLC +# Copyright 2017 Dag Wieers + +# This file is part of Ansible by Red Hat +# +# Ansible is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Ansible is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Ansible. If not, see . + +ANSIBLE_METADATA = {'metadata_version': '1.0', + 'status': ['preview'], + 'supported_by': 'community'} + +DOCUMENTATION = r''' +module: aci_rest +short_description: Direct access to the Cisco APIC REST API +description: +- Enables the management of the Cisco ACI fabric through direct access to the Cisco APIC REST API. +- More information regarding the Cisco APIC REST API is available from + U(http://www.cisco.com/c/en/us/td/docs/switches/datacenter/aci/apic/sw/2-x/rest_cfg/2_1_x/b_Cisco_APIC_REST_API_Configuration_Guide.html). +author: +- Jason Edelman (@jedelman8) +- Dag Wieers (@dagwieers) +version_added: '2.4' +requirements: +- lxml (when using XML content) +- xmljson >= 0.1.8 (when using XML content) +- python 2.7+ (when using xmljson) +extends_documentation_fragment: aci +options: + method: + description: + - The HTTP method of the request. + - Using C(delete) is typically used for deleting objects. + - Using C(get) is typically used for querying objects. + - Using C(post) is typically used for modifying objects. + required: true + default: get + choices: [ delete, get, post ] + aliases: [ action ] + path: + description: + - URI being used to execute API calls. + - Must end in C(.xml) or C(.json). + required: true + aliases: [ uri ] + content: + description: + - When used instead of C(src), sets the content of the API request directly. + - This may be convenient to template simple requests, for anything complex use the M(template) module. + src: + description: + - Name of the absolute path of the filname that includes the body + of the http request being sent to the ACI fabric. + aliases: [ config_file ] +notes: +- When using inline-JSON (using C(content)), YAML requires to start with a blank line. + Otherwise the JSON statement will be parsed as a YAML mapping (dictionary) and translated into invalid JSON as a result. +- XML payloads require the C(lxml) and C(xmljson) python libraries. For JSON payloads nothing special is needed. +''' + +EXAMPLES = r''' +- name: Add a tenant + aci_rest: + hostname: '{{ inventory_hostname }}' + username: '{{ aci_username }}' + password: '{{ aci_password }}' + method: post + path: /api/mo/uni.xml + src: /home/cisco/ansible/aci/configs/aci_config.xml + delegate_to: localhost + +- name: Get tenants + aci_rest: + hostname: '{{ inventory_hostname }}' + username: '{{ aci_username }}' + password: '{{ aci_password }}' + method: get + path: /api/node/class/fvTenant.json + delegate_to: localhost + +- name: Configure contracts + aci_rest: + hostname: '{{ inventory_hostname }}' + username: '{{ aci_username }}' + password: '{{ aci_password }}' + method: post + path: /api/mo/uni.xml + src: /home/cisco/ansible/aci/configs/contract_config.xml + delegate_to: localhost + +- name: Register leaves and spines + aci_rest: + hostname: '{{ inventory_hostname }}' + username: '{{ aci_username }}' + password: '{{ aci_password }}' + validate_certs: no + method: post + path: /api/mo/uni/controller/nodeidentpol.xml + content: | + + + + with_items: + - '{{ apic_leavesspines }}' + delegate_to: localhost + +- name: Wait for all controllers to become ready + aci_rest: + hostname: '{{ inventory_hostname }}' + username: '{{ aci_username }}' + password: '{{ aci_password }}' + validate_certs: no + path: /api/node/class/topSystem.json?query-target-filter=eq(topSystem.role,"controller") + register: apics + until: "'totalCount' in apics and apics.totalCount|int >= groups['apic']|count" + retries: 120 + delay: 30 + delegate_to: localhost + run_once: yes +''' + +RETURN = r''' +error_code: + description: The REST ACI return code, useful for troubleshooting on failure + returned: always + type: int + sample: 122 +error_text: + description: The REST ACI descriptive text, useful for troubleshooting on failure + returned: always + type: string + sample: unknown managed object class foo +imdata: + description: Converted output returned by the APIC REST (register this for post-processing) + returned: always + type: string + sample: [{"error": {"attributes": {"code": "122", "text": "unknown managed object class foo"}}}] +payload: + description: The (templated) payload send to the APIC REST API (xml or json) + returned: always + type: string + sample: '' +raw: + description: The raw output returned by the APIC REST API (xml or json) + returned: parse error + type: string + sample: '' +response: + description: HTTP response string + returned: always + type: string + sample: 'HTTP Error 400: Bad Request' +status_code: + description: HTTP status code + returned: always + type: int + sample: 400 +totalCount: + description: Number of items in the imdata array + returned: always + type: string + sample: '0' +''' + +import json +import os + +# Optional, only used for XML payload +try: + import lxml.etree + HAS_LXML_ETREE = True +except ImportError: + HAS_LXML_ETREE = False + +# Optional, only used for XML payload +try: + from xmljson import cobra + HAS_XMLJSON_COBRA = True +except ImportError: + HAS_XMLJSON_COBRA = False + +# from ansible.module_utils.aci import aci_login +from ansible.module_utils.basic import AnsibleModule, get_exception +from ansible.module_utils.urls import fetch_url +from ansible.module_utils._text import to_bytes + + +aci_argument_spec = dict( + hostname=dict(type='str', required=True, aliases=['host']), + username=dict(type='str', default='admin', aliases=['user']), + password=dict(type='str', required=True, no_log=True), + protocol=dict(type='str'), # Deprecated in v2.8 + timeout=dict(type='int', default=30), + use_ssl=dict(type='bool', default=True), + validate_certs=dict(type='bool', default=True), +) + + +def aci_login(module, result=dict()): + ''' Log in to APIC ''' + + # Set protocol based on use_ssl parameter + if module.params['protocol'] is None: + module.params['protocol'] = 'https' if module.params.get('use_ssl', True) else 'http' + + # Perform login request + url = '%(protocol)s://%(host)s/api/aaaLogin.json' % module.params + data = {'aaaUser': {'attributes': {'name': module.params['username'], 'pwd': module.params['password']}}} + resp, auth = fetch_url(module, url, data=json.dumps(data), method="POST", timeout=module.params['timeout']) + + # Handle APIC response + if auth['status'] != 200: + try: + result.update(aci_response(auth['body'], 'json')) + result['msg'] = 'Authentication failed: %(error_code)s %(error_text)s' % result + except KeyError: + result['msg'] = '%(msg)s for %(url)s' % auth + result['response'] = auth['msg'] + result['status_code'] = auth['status'] + module.fail_json(**result) + + return resp + + +def aci_response(rawoutput, rest_type='xml'): + ''' Handle APIC response output ''' + + result = dict() + + if rest_type == 'json': + # Use APIC response as module output + try: + result = json.loads(rawoutput) + except: + e = get_exception() + # Expose RAW output for troubleshooting + result['error_code'] = -1 + result['error_text'] = "Unable to parse output as JSON, see 'raw' output. %s" % e + result['raw'] = rawoutput + return result + else: + # NOTE: The XML-to-JSON conversion is using the "Cobra" convention + xmldata = None + try: + xml = lxml.etree.fromstring(to_bytes(rawoutput)) + xmldata = cobra.data(xml) + except: + e = get_exception() + # Expose RAW output for troubleshooting + result['error_code'] = -1 + result['error_text'] = "Unable to parse output as XML, see 'raw' output. %s" % e + result['raw'] = rawoutput + return result + + # Reformat as ACI does for JSON API output + if xmldata and 'imdata' in xmldata: + if 'children' in xmldata['imdata']: + result['imdata'] = xmldata['imdata']['children'] + result['totalCount'] = xmldata['imdata']['attributes']['totalCount'] + + # Handle possible APIC error information + try: + result['error_code'] = result['imdata'][0]['error']['attributes']['code'] + result['error_text'] = result['imdata'][0]['error']['attributes']['text'] + except KeyError: + if 'imdata' in result and 'totalCount' in result: + result['error_code'] = 0 + result['error_text'] = 'Success' + else: + result['error_code'] = -2 + result['error_text'] = 'This should not happen' + + return result + + +def main(): + argument_spec = dict( + path=dict(type='str', required=True, aliases=['uri']), + method=dict(type='str', default='get', choices=['delete', 'get', 'post'], aliases=['action']), + src=dict(type='path', aliases=['config_file']), + content=dict(type='str'), + ) + + argument_spec.update(aci_argument_spec) + + module = AnsibleModule( + argument_spec=argument_spec, + mutually_exclusive=[['content', 'src']], + supports_check_mode=True, + ) + + hostname = module.params['hostname'] + username = module.params['username'] + password = module.params['password'] + + path = module.params['path'] + content = module.params['content'] + src = module.params['src'] + + protocol = module.params['protocol'] + use_ssl = module.params['use_ssl'] + method = module.params['method'] + timeout = module.params['timeout'] + + result = dict( + changed=False, + payload='', + ) + + # Report missing file + file_exists = False + if src: + if os.path.isfile(src): + file_exists = True + else: + module.fail_json(msg='Cannot find/access src:\n%s' % src) + + # Find request type + if path.find('.xml') != -1: + rest_type = 'xml' + if not HAS_LXML_ETREE: + module.fail_json(msg='The lxml python library is missing, or lacks etree support.') + if not HAS_XMLJSON_COBRA: + module.fail_json(msg='The xmljson python library is missing, or lacks cobra support.') + elif path.find('.json') != -1: + rest_type = 'json' + else: + module.fail_json(msg='Failed to find REST API content type (neither .xml nor .json).') + + # Set protocol for further use + if protocol is None: + protocol = 'https' if use_ssl else 'http' + else: + module.deprecate("Parameter 'protocol' is deprecated, please use 'use_ssl' instead.", 2.8) + + # Perform login first + auth = aci_login(module, result) + + # Prepare request data + if content: + # We include the payload as it may be templated + result['payload'] = content + elif file_exists: + with open(src, 'r') as config_object: + # TODO: Would be nice to template this, requires action-plugin + result['payload'] = config_object.read() + + # Ensure changes are reported + if method in ('delete', 'post'): + # FIXME: Hardcoding changed is not idempotent + result['changed'] = True + + # In check_mode we assume it works, but we don't actually perform the requested change + # TODO: Could we turn this request in a GET instead ? + if module.check_mode: + module.exit_json(response='OK (Check mode)', status=200, **result) + else: + result['changed'] = False + + # Perform actual request using auth cookie + url = '%s://%s/%s' % (protocol, hostname, path.lstrip('/')) + headers = dict(Cookie=auth.headers['Set-Cookie']) + + resp, info = fetch_url(module, url, data=result['payload'], method=method.upper(), timeout=timeout, headers=headers) + result['response'] = info['msg'] + result['status_code'] = info['status'] + + # Report failure + if info['status'] != 200: + try: + result.update(aci_response(info['body'], rest_type)) + result['msg'] = 'Task failed: %(error_code)s %(error_text)s' % result + except KeyError: + result['msg'] = '%(msg)s for %(url)s' % info + module.fail_json(**result) + + # Report success + result.update(aci_response(resp.read(), rest_type)) + module.exit_json(**result) + +if __name__ == '__main__': + main() diff --git a/lib/ansible/utils/module_docs_fragments/aci.py b/lib/ansible/utils/module_docs_fragments/aci.py new file mode 100644 index 0000000000..5519e10826 --- /dev/null +++ b/lib/ansible/utils/module_docs_fragments/aci.py @@ -0,0 +1,55 @@ +# -*- coding: utf-8 -*- + +# Copyright 2017 Dag Wieers + +# This file is part of Ansible by Red Hat +# +# Ansible is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Ansible is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Ansible. If not, see . + + +class ModuleDocFragment(object): + # Standard files documentation fragment + DOCUMENTATION = ''' +options: + hostname: + description: + - IP Address or hostname of APIC resolvable by Ansible control host. + required: true + aliases: [ host ] + username: + description: + - The username to use for authentication. + required: true + default: admin + aliases: [ user ] + password: + description: + - The password to use for authentication. + required: true + timeout: + description: + - The socket level timeout in seconds. + default: 30 + 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) used on personally controlled sites using self-signed certificates. + type: bool + default: 'yes' +''' diff --git a/test/runner/requirements/units.txt b/test/runner/requirements/units.txt index cd330dec9f..dced151ca9 100644 --- a/test/runner/requirements/units.txt +++ b/test/runner/requirements/units.txt @@ -25,3 +25,6 @@ deepdiff # requirement for modules using Netconf protocol ncclient + +# requirement for aci_rest module +xmljson diff --git a/test/units/modules/network/aci/test_aci_rest.py b/test/units/modules/network/aci/test_aci_rest.py new file mode 100644 index 0000000000..b1a01fdb99 --- /dev/null +++ b/test/units/modules/network/aci/test_aci_rest.py @@ -0,0 +1,299 @@ +# -*- coding: utf-8 -*- + +# Copyright 2017 Dag Wieers + +# This file is part of Ansible by Red Hat +# +# Ansible is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Ansible is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Ansible. If not, see . + + +import sys + +from ansible.compat.tests import unittest +from ansible.modules.network.aci.aci_rest import aci_response + +from nose.plugins.skip import SkipTest + +from lxml import etree +if sys.version_info >= (2, 7): + from xmljson import cobra + + +class AciRest(unittest.TestCase): + + def test_invalid_aci_login(self): + self.maxDiff = None + + expected_result = { + u'error_code': u'401', + u'error_text': u'Username or password is incorrect - FAILED local authentication', + u'imdata': [{ + u'error': { + u'attributes': { + u'code': u'401', + u'text': u'Username or password is incorrect - FAILED local authentication', + }, + }, + }], + u'totalCount': '1', + } + + json_response = '{"totalCount":"1","imdata":[{"error":{"attributes":{"code":"401","text":"Username or password is incorrect - FAILED local authentication"}}}]}' # NOQA + json_result = aci_response(json_response, 'json') + self.assertEqual(expected_result, json_result) + + # Python 2.7+ is needed for xmljson + if sys.version_info < (2, 7): + return + + xml_response = ''' + + + ''' + xml_result = aci_response(xml_response, 'xml') + self.assertEqual(json_result, xml_result) + + def test_valid_aci_login(self): + self.maxDiff = None + + expected_result = { + u'error_code': 0, + u'error_text': u'Success', + u'imdata': [{ + u'aaaLogin': { + u'attributes': { + u'token': u'ZldYAsoO9d0FfAQM8xaEVWvQPSOYwpnqzhwpIC1r4MaToknJjlIuAt9+TvXqrZ8lWYIGPj6VnZkWiS8nJfaiaX/AyrdD35jsSxiP3zydh+849xym7ALCw/fFNsc7b5ik1HaMuSUtdrN8fmCEUy7Pq/QNpGEqkE8m7HaxAuHpmvXgtdW1bA+KKJu2zY1c/tem', # NOQA + u'siteFingerprint': u'NdxD72K/uXaUK0wn', + u'refreshTimeoutSeconds': u'600', + u'maximumLifetimeSeconds': u'86400', + u'guiIdleTimeoutSeconds': u'1200', + u'restTimeoutSeconds': u'90', + u'creationTime': u'1500134817', + u'firstLoginTime': u'1500134817', + u'userName': u'admin', + u'remoteUser': u'false', + u'unixUserId': u'15374', + u'sessionId': u'o7hObsqNTfCmDGcZI5c4ng==', + u'lastName': u'', + u'firstName': u'', + u'version': u'2.0(2f)', + u'buildTime': u'Sat Aug 20 23:07:07 PDT 2016', + u'node': u'topology/pod-1/node-1', + }, + u'children': [{ + u'aaaUserDomain': { + u'attributes': { + u'name': u'all', + u'rolesR': u'admin', + u'rolesW': u'admin', + }, + u'children': [{ + u'aaaReadRoles': { + u'attributes': {}, + }, + }, { + u'aaaWriteRoles': { + u'attributes': {}, + u'children': [{ + u'role': { + u'attributes': { + u'name': u'admin', + }, + }, + }], + }, + }], + }, + }, { + u'DnDomainMapEntry': { + u'attributes': { + u'dn': u'uni/tn-common', + u'readPrivileges': u'admin', + u'writePrivileges': u'admin', + }, + }, + }, { + u'DnDomainMapEntry': { + u'attributes': { + u'dn': u'uni/tn-infra', + u'readPrivileges': u'admin', + u'writePrivileges': u'admin', + }, + }, + }, { + u'DnDomainMapEntry': { + u'attributes': { + u'dn': u'uni/tn-mgmt', + u'readPrivileges': u'admin', + u'writePrivileges': u'admin', + }, + }, + }], + }, + }], + u'totalCount': u'1', + } + + json_response = '{"totalCount":"1","imdata":[{"aaaLogin":{"attributes":{"token":"ZldYAsoO9d0FfAQM8xaEVWvQPSOYwpnqzhwpIC1r4MaToknJjlIuAt9+TvXqrZ8lWYIGPj6VnZkWiS8nJfaiaX/AyrdD35jsSxiP3zydh+849xym7ALCw/fFNsc7b5ik1HaMuSUtdrN8fmCEUy7Pq/QNpGEqkE8m7HaxAuHpmvXgtdW1bA+KKJu2zY1c/tem","siteFingerprint":"NdxD72K/uXaUK0wn","refreshTimeoutSeconds":"600","maximumLifetimeSeconds":"86400","guiIdleTimeoutSeconds":"1200","restTimeoutSeconds":"90","creationTime":"1500134817","firstLoginTime":"1500134817","userName":"admin","remoteUser":"false","unixUserId":"15374","sessionId":"o7hObsqNTfCmDGcZI5c4ng==","lastName":"","firstName":"","version":"2.0(2f)","buildTime":"Sat Aug 20 23:07:07 PDT 2016","node":"topology/pod-1/node-1"},"children":[{"aaaUserDomain":{"attributes":{"name":"all","rolesR":"admin","rolesW":"admin"},"children":[{"aaaReadRoles":{"attributes":{}}},{"aaaWriteRoles":{"attributes":{},"children":[{"role":{"attributes":{"name":"admin"}}}]}}]}},{"DnDomainMapEntry":{"attributes":{"dn":"uni/tn-common","readPrivileges":"admin","writePrivileges":"admin"}}},{"DnDomainMapEntry":{"attributes":{"dn":"uni/tn-infra","readPrivileges":"admin","writePrivileges":"admin"}}},{"DnDomainMapEntry":{"attributes":{"dn":"uni/tn-mgmt","readPrivileges":"admin","writePrivileges":"admin"}}}]}}]}' # NOQA + json_result = aci_response(json_response, 'json') + + # Python 2.7+ is needed for xmljson + if sys.version_info < (2, 7): + return + + xml_response = '\n\n\n\n\n\n\n\n\n\n\n\n''' # NOQA + xml_result = aci_response(xml_response, 'xml') + + self.assertEqual(expected_result, json_result) + self.assertEqual(json_result, xml_result) + + def test_invalid_input(self): + self.maxDiff = None + + expected_result = { + u'error_code': u'401', + u'error_text': u'Username or password is incorrect - FAILED local authentication', + u'imdata': [{ + u'error': { + u'attributes': { + u'code': u'401', + u'text': u'Username or password is incorrect - FAILED local authentication', + }, + }, + }], + u'totalCount': '1', + } + + json_response = '{"totalCount":"1","imdata":[{"error":{"attributes":{"code":"401","text":"Username or password is incorrect - FAILED local authentication"}}}]}' # NOQA + json_result = aci_response(json_response, 'json') + + # Python 2.7+ is needed for xmljson + if sys.version_info < (2, 7): + return + + xml_response = ''' + + + ''' + xml_result = aci_response(xml_response, 'xml') + + self.assertEqual(expected_result, json_result) + self.assertEqual(json_result, xml_result) + + def test_empty_response(self): + self.maxDiffi = None + + if sys.version_info < (3, 0): + expected_json_result = { + 'error_code': -1, + 'error_text': "Unable to parse output as JSON, see 'raw' output. No JSON object could be decoded", + 'raw': '', + } + + else: + expected_json_result = { + u'error_code': -1, + u'error_text': u"Unable to parse output as JSON, see 'raw' output. Expecting value: line 1 column 1 (char 0)", + u'raw': u'', + } + + json_response = '' + json_result = aci_response(json_response, 'json') + self.assertEqual(expected_json_result, json_result) + + # Python 2.7+ is needed for xmljson + if sys.version_info < (2, 7): + return + + elif etree.LXML_VERSION < (3, 3, 0, 0): + expected_xml_result = { + 'error_code': -1, + 'error_text': "Unable to parse output as XML, see 'raw' output. None", + 'raw': '', + } + + elif sys.version_info < (3, 0): + expected_xml_result = { + 'error_code': -1, + 'error_text': "Unable to parse output as XML, see 'raw' output. None (line 0)", + 'raw': '', + } + + else: + expected_xml_result = { + u'error_code': -1, + u'error_text': u"Unable to parse output as XML, see 'raw' output. None (line 0)", + u'raw': u'', + } + + xml_response = '' + xml_result = aci_response(xml_response, 'xml') + self.assertEqual(expected_xml_result, xml_result) + + def test_invalid_response(self): + self.maxDiff = None + + if sys.version_info < (2, 7): + expected_json_result = { + 'error_code': -1, + 'error_text': "Unable to parse output as JSON, see 'raw' output. Expecting object: line 1 column 8 (char 8)", + 'raw': '{ "aaa":', + } + + elif sys.version_info < (3, 0): + expected_json_result = { + 'error_code': -1, + 'error_text': "Unable to parse output as JSON, see 'raw' output. No JSON object could be decoded", + 'raw': '{ "aaa":', + } + + else: + expected_json_result = { + u'error_code': -1, + u'error_text': u"Unable to parse output as JSON, see 'raw' output. Expecting value: line 1 column 9 (char 8)", + u'raw': u'{ "aaa":', + } + + json_response = '{ "aaa":' + json_result = aci_response(json_response, 'json') + self.assertEqual(expected_json_result, json_result) + + # Python 2.7+ is needed for xmljson + if sys.version_info < (2, 7): + return + + elif etree.LXML_VERSION < (3, 3, 0, 0): + expected_xml_result = { + 'error_code': -1, + 'error_text': "Unable to parse output as XML, see 'raw' output. Couldn't find end of Start Tag aaa line 1, line 1, column 5", # NOQA + 'raw': ', line 1)", # NOQA + u'raw': u'